From e6574bac58f5dc26a45c506de2d10804ff5c1250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B7=9C=ED=9A=8C?= <48755156+KimKyuHoi@users.noreply.github.com> Date: Mon, 19 Feb 2024 12:48:06 +0900 Subject: [PATCH] =?UTF-8?q?Develop=20->=20Main=20(=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C)=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: webSocket 기본 설정 , 소켓 연결, 종료시 대처 구현 * Feat/issue-29 (#38) * Feat:FE디렉토리 구조 * Feat:FE 디렉토리 구조 추가 수정 * Chore : 더미 파일 명 수정 * Feat: 라우터 경로 설정 * Test: 임시 컴포넌트 테스트 페이지 생성 * Test: 임시 테스트 페이지 라우터 설정 * Feat : 채팅 상태관리 및 스켈레톤 코드 구현 * Feat: 채팅 입력시 날짜 표현 * Remove : 필요없는 상태 코드 삭제 * Test: 최근 데이터 불러오기 코드 작성 및 날짜 체크 * Feat: 더미데이터 시간 중복 표시 구현 * Rename: RecentChat -> ChatRecent 파일명 변경 * Feat: 시간 중복일때 마지막 메시지만 시간 표시 * Chore: Chat 파일명 분리 --------- Co-authored-by: eastfilmm * feat: 메시지 ICE,JOIN,LEAVE시 메소드 구현 ,RtcService 추가 * feat: 방 존재하지 않을시 즉석으로 생성 * feat: gradle 버젼 업데이트 * FEAT: videocall 수정중 * FEAT:video call 수정 중 * FEAT:videocall 수정3 * FEAT:video call websocket 연결 해결 * Feat: signaling server 테스트 * Feat:video&mic on/off btn 구현 * Feat:video call 웹소켓 수정 중 * Feat: 소켓연결 이슈 수정 중 * Feat/issue-29 (#38) * Feat:FE디렉토리 구조 * Feat:FE 디렉토리 구조 추가 수정 * Chore : 더미 파일 명 수정 * Feat: 라우터 경로 설정 * Test: 임시 컴포넌트 테스트 페이지 생성 * Test: 임시 테스트 페이지 라우터 설정 * Feat : 채팅 상태관리 및 스켈레톤 코드 구현 * Feat: 채팅 입력시 날짜 표현 * Remove : 필요없는 상태 코드 삭제 * Test: 최근 데이터 불러오기 코드 작성 및 날짜 체크 * Feat: 더미데이터 시간 중복 표시 구현 * Rename: RecentChat -> ChatRecent 파일명 변경 * Feat: 시간 중복일때 마지막 메시지만 시간 표시 * Chore: Chat 파일명 분리 --------- Co-authored-by: eastfilmm * FEAT: videocall 수정중 * FEAT:video call 수정 중 * FEAT:videocall 수정3 * FEAT:video call websocket 연결 해결 * refactor: 임시 타입스크립트 수정 * Test: socket 서버 통신 테스트 * Test: websock 연결 테스트 2 * Feat/issue-29 (#38) * Feat:FE디렉토리 구조 * Feat:FE 디렉토리 구조 추가 수정 * Chore : 더미 파일 명 수정 * Feat: 라우터 경로 설정 * Test: 임시 컴포넌트 테스트 페이지 생성 * Test: 임시 테스트 페이지 라우터 설정 * Feat : 채팅 상태관리 및 스켈레톤 코드 구현 * Feat: 채팅 입력시 날짜 표현 * Remove : 필요없는 상태 코드 삭제 * Test: 최근 데이터 불러오기 코드 작성 및 날짜 체크 * Feat: 더미데이터 시간 중복 표시 구현 * Rename: RecentChat -> ChatRecent 파일명 변경 * Feat: 시간 중복일때 마지막 메시지만 시간 표시 * Chore: Chat 파일명 분리 --------- Co-authored-by: eastfilmm * FEAT: videocall 수정중 * Feat:video&mic on/off btn 구현 * Feat:video call 웹소켓 수정 중 * Feat: 소켓연결 이슈 수정 중 * Fix: webSocket 서버 end point 수정 * Test: WebSocket connect test 완료 * Feat: websocket 서버 연결후 msg 전송 까지 완료 * merge * Feat: video call 기본구현 완료 * Feat/issue-39 (#41) * Feat: 프로젝트 생성 및 Discovery service 연동 완료 * Feat: WebSocket, STOMP를 활용한 채팅 기능 구현 * Chore: 실행 시 Eureka server에 등록되도록 어노테이션 추가 * Feat: 채팅 정적 템플릿 추가 * Chore: 변수명 및 채팅방 각각에 대해 채팅이 이루어지도록 수정 * Chore: SockJS에서 Websocket으로 변경에 따른 수정 * Chore: 채팅 기능 점검 완료 후 정적 템플릿 뷰 삭제 * Chore: 스프링부트 버전 통일을 위한 다운그레이드 * Feat/issue-17 (#32) * Feat: 회원가입 초기 구현 * Feat: SignupResponseDto 생성 * Feat: Security 설정 추가, 권한 추가, port 번호 임시 변경 * Feat: accessToken, refreshToken 발급 * Chore: 쓸모없는 주석 제거 * Feat: RefreshToken redis 저장 * Refactoring: exists 수정 * Refactoring: tokenProvider 로직 수정 * Feat: 최신 refreshToken이 아닐 경우 인증 x * Feat: refreshToken 정보가 있을 경우 header에 똑같은 token 정보 전달 * Feat: logout, custom exception 처리 * Refactoring: cors 허용 * Feat: username, email 인증 * Refactoring: token header 이름 변경 * Feat/issue-22 (#48) * Feat: Chatting Stompjs로 연결 * Remove: react-query 제거 * Feat: SockJS, StompJS connect, subscribe구현 * feat: SockJS 삭제 및 StompJS, Websocket연결 * Feat: publish 메시지 구현 * HOTFIX: Remove socket.io * Feat/issue-47 (#50) * Feat: oauth 초기 구현 * HotFix: OAuth 소셜 가입 후순위 * Feat: 이메일 인증 * Feat: 이메일 인증 시 200 반환, token 발급 시간 body에 추가 * Feat: 회원가입 이메일 인증 체크 * Feat: config ExposedHeader 추가 * Feat: 회원가입 authCode body 추가 * Feat/issue-53 (#57) * Fix: 메시지 보냈을때 받아올 수 있도록 수정 * Feat: 채팅 입력시 사용자 명 뜨도록 변환 추후 날짜 추가 필요함. * Remove: 파일 제거 * Design: prettier 작업 수정 및 설정 완료 * Style: EsLint 설정 및 코드 스타일 수정 * HotFix: member state 중복 체크 로직 삭제 (#59) * Feat/issue-42 (#52) * Feat: 프로젝트 생성 및 Discovery service 연동 완료 * Feat: WebSocket, STOMP를 활용한 채팅 기능 구현 * Chore: DB 설정 정보 gitignore 설정 * Feat: mongoDB 연결 및 이전 채팅 내용 불러오는 기능 구현 * Chore: 이전 채팅 리스트 불러오는 API 반환 포맷 수정 * Feat: 채팅 메시지를 DB에 저장하는 기능 추가 * Refactor: 웹소켓 메시지 처리 컨트롤러가 아닌 자체 클래스 내부 메서드를 활용해 객체를 변경하도록 수정 * Chore: 채팅 도메인 유효성 검사 로직 추가 * Chore: application 파일 로컬 설정 완료 * Refactor: Chat Entity id 컬럼 _id에서 id로 변경 * feat/issue-19 (#60) * Test: socket.io test코드 * Test: websocket testing * refactor: 불필요한 파일 제거 * feat: 1대1 video call 스켈레톤 구현 * chore: reinstall pnpm * Feat/issue-61 (#63) * Design: css 전역 변수 설정 * HOTFIX!: 전역변수 color white추가 * feat/issue-20 (#64) * Feat: 1대1 video call API 연결 완료 * Fix: create answer에서 getUserMedia 수정 * Feat/issue-65 * Design: CSS layout 설정 (#69) * Chore/issue-55 (#70) * Chore: 채팅 메시지 반환값에 날짜 추가 * Refactor: 채팅 메시지 Dto 수정 및 목적이 명확하도록 클래스명 변경 * Feat/issue-27 (#73) * Remove: Test 폴더 제거 * Design: 채팅방 레이아웃 설정 * Chore: 컴포넌트 분리 * Remove: ChatRecent 파일 제거 * Chore: 공통 인터페이스 분리 * Refactor: 공용 컴포넌트 분리 * Design: 채팅방 오버플로우 레이아웃 수정 및 스크롤바 추가 * Design: 채팅창 및 본인일때 채팅창 위치 및 색 다르게 구현 * Feat/issue-74 (#75) * Fix: 미리초, 초 예외처리 수정 * Feat/issue-31 (#35) * feat/issue 16 (#25) * Feat:FE디렉토리 구조 * Feat:FE 디렉토리 구조 추가 수정 * Chore : 더미 파일 명 수정 * Feat: 라우터 경로 설정 --------- Co-authored-by: Quvid <48755156+KimKyuHoi@users.noreply.github.com> * Feat: 로그인 페이지 스켈레톤 코드 구현 * Feat: 중복되는 부분 컴포넌트화, 재사용성 높이기 * Feat: 카카오 로그인 기본 세팅 * Chore: 불필요한 코드 제거 * Feat: 카카오 API키 개인정보보호관련코드 * Feat: zustand로 변경 적용확인 * Feat: zustand useStore 사용 / devTools 적용 * Feat: Dev mode에만 적용하도록 / build시 devtools 불가 코드 * Refactor: 중복코드 제거중 / 삼항연산자 사 * Refactor: children 문법 적용완료 삼항연산자 * Refactor: if문 가독성 향상 / PROD시 미적용 확인 * Refactor: FC type 적용 * Feat: 회원가입페이지(SignUp) 스켈레톤 코드 구현 * Feat: API 코드 구현 / URL 연결필요 * Feat: API 코드 구현 / URL 연결 * Feat: 객체화해서 한 번에 관리 * Chore: useState 제거 * Chore: 코드 정리 * Feat: 예외 : 중복확인하고 값을 변경하면 false처리 * Chore: 대문자로 통일 commit 정리 * Feat: 기본 API 코드 추가 * Refactor: API 코드 모듈화 * Refactor: 모듈최적화해서 각자 호출할 수 있도록 설계 * Feat: query문 작성 * Refactor: type지정 * Feat: refreshToken interceptor 추가 * Feat: UX 개선 및 중복시 알림 수정 * Feat: Header 저장/ 재발급 확인 * Chore: 카카오 -> Naver로 변경 * Chore: Token 콘솔에서 삭 * Chore: 피드백 수정 * Feat: 네이버 로그인 기능 : 유저프로필 조회 완료 * Feat: 네이버 로그인 기능을 위한 sdk 불러오기 * Feat: zustand 적용 완료 --------- Co-authored-by: Dongpil Jo <91816664+eastfilmm@users.noreply.github.com> Co-authored-by: Quvid <48755156+KimKyuHoi@users.noreply.github.com> * Feat/issue-24 (#44) * feat/issue 16 (#25) * Feat:FE디렉토리 구조 * Feat:FE 디렉토리 구조 추가 수정 * Chore : 더미 파일 명 수정 * Feat: 라우터 경로 설정 --------- Co-authored-by: Quvid <48755156+KimKyuHoi@users.noreply.github.com> * Feat: 채팅방 썸네일 스켈레톤 코드 구현하기 * Feat: 채팅방 썸네일 리액트쿼리 적용 * Chore: 코드정리 --------- Co-authored-by: Dongpil Jo <91816664+eastfilmm@users.noreply.github.com> Co-authored-by: Quvid <48755156+KimKyuHoi@users.noreply.github.com> * Hotfix: Testpage 제거 * Feat/issue-33 (#43) * feat/issue 16 (#25) * Feat:FE디렉토리 구조 * Feat:FE 디렉토리 구조 추가 수정 * Chore : 더미 파일 명 수정 * Feat: 라우터 경로 설정 --------- Co-authored-by: Quvid <48755156+KimKyuHoi@users.noreply.github.com> * Feat: 로그인 페이지 스켈레톤 코드 구현 * Feat: 중복되는 부분 컴포넌트화, 재사용성 높이기 * Feat: 카카오 로그인 기본 세팅 * Chore: 불필요한 코드 제거 * Feat: 카카오 API키 개인정보보호관련코드 * Feat: 회원가입페이지(SignUp) 스켈레톤 코드 구현 * Feat: API 코드 구현 / URL 연결필요 * Feat: API 코드 구현 / URL 연결 * Feat: 객체화해서 한 번에 관리 * Feat: 예외 : 중복확인하고 값을 변경하면 false처리 * Chore: 대문자로 통일 commit 정리 * Feat: 기본 API 코드 추가 * Refactor: API 코드 모듈화 * Refactor: 모듈최적화해서 각자 호출할 수 있도록 설계 * Feat: query문 작성 * Refactor: type지정 * Feat: refreshToken interceptor 추가 * Feat: UX 개선 및 중복시 알림 수정 * Feat: Header 저장/ 재발급 확인 * Feat: 이메일 인증기능 구현 예외처리 확인 * Chore: 옛 파일제거 * Chore: 확인완료, 충돌방지 코드정리 --------- Co-authored-by: Dongpil Jo <91816664+eastfilmm@users.noreply.github.com> Co-authored-by: Quvid <48755156+KimKyuHoi@users.noreply.github.com> * Feat/issue-54 (#62) * Feat: API 라우팅을 위한 yml 설정, cors 설정 * Feat: chatting-service yml 추가, authFilter 추가 * Feat: AuthFilter 동작 * Refactoring: else-if 제거 * Feat: jwt token secret key를 secret yml로 설정 * Feat: Naver 로그인 FE / BE 버튼 (FE만 가능) (#79) * Hotfix: signin page누락 * Hotfix: BE 네이버 로그인테스트 * Feat: 네이버 소셜 로그인 (#68) * Feat/issue-76 (#80) * Feat: Figma -> Sidebar 기본코드 작성중 * Feat: Sidebar 스켈레톤 코드 -> 컴포넌트화 * Feat: Login 버튼 추가 * Style: SVG 파일 추가 * Feat: SVG 동적연결 * Style: 중앙정렬 SVG 재추출 * Style: css 적용 / 글로벌 변수 적용 * Feat: Sidebar Search 추가 * Style: wildSand추가, Sidebar 스타일점검 * Feat/issue-85 (#87) * Remove: Chat 파일 삭제 * Chore/issue-40 (#72) * Chore: .gitignore 추가 * Chore: .idea 삭제 * Design: 공용 css 분리 * Feat: UserList 기본 ui 구현 * Test: userId 접속에 따른 userList 출력 완료 * Feat: Message Body Type 추가 * feat: 로그인시 새로 유저 리스트 발행 * Feat/issue-88 (#93) * Remove: Chat 파일 삭제 * Design: 공용 css 분리 * Feat: UserList 기본 ui 구현 * Test: userId 접속에 따른 userList 출력 완료 * Feat: Message Body Type 추가 * Test: 더미데이터 작성 * Feat: OAuth 로그인 성공 (#91) * Feat: BE 네이버 로그인 기능 구현 (#99) * Feat: Token을 통한 유저 정보 조회 (#97) * Feat/issue-94 (#104) * Feat: UserData를 받아서 userlist와 chattingroom에 뿌려주기 * Hotfix: login 버그 해결 (#106) * Feat/issue-101 (#103) * Design: svg 추가 * Feat: InputForm CSS 적용 * 로그인창 CSS 완료 * 로그인 성공시 이동하기로 변경 * Feat: 토스트 알림기능 추가 * Design: 회원가입창 CSS 완료 * Feat/issue-107 (#108) * Chore: React-Toastify 라이브러리 다운로드 * Fix: Authorization -> Accesstoken으로 수정 * Refactor: StaleTime 수정 * Feat: 메시지가 본인 일경우 오른쪽 메시지 배치 * Chore: id 코드 주석처리 * Chore: 코드 주석처리 * Feat/issue-109 (#110) * Refactor: falsy bug 수정 * Feat: authCode api 누락확인 * Feat/issue-90 (#92) * Feat: chatRoom 방 생성 * chore: 패키지 변경 * Feat: chatMember 중간 테이블 생성 * Feat: 방 입장 * Feat: 방 전체 리스트 조회 * Feat: 방 삭제 * Feat: 채팅방 강퇴 * Feat: 채팅 방 강퇴시 responseBody에 강퇴당한 사람 출력 * Feat: 강퇴 당한 사용자 못들어오도록, 인원수 증가 감소 * Feat: 채팅 방 중복 입장 불가, participation 증가, 감소 * Feat: 채팅 방 삭제시 owner 검증 * Feat: 방장 위임 * Feat: exception 처리, valid 처리 * Feat: 방장 위임 할 수 없을 경우 exception 처리 * feat/issue-113 (#115) * Feat: chatroom 서비스 apigateway 등록 * Refactoring: token decoding 로직 수정 * Feat: 채팅방 개별 조회 API (#122) * Feat: 채팅방 개별 조회 API * Feat: chatRoom-service cors 설정 * Feat: category -> hashTag로 네이밍 변경 * feat: kafka config 연동 * feat: kafka config 연동 * feat: 유레카 설정 삭제 * FE-feat/issue-100 (#117) * Chore: 공통 폴더 이동 및 파일 분리 * Fix: 파일 경로 설정 * Fix: sidebar 여백 수정 * Refactor: 비슷한 파일이름변경 * Feat: Wrapper 설정 및 Modal 추가 * Fix: 로그인성공 헤더로 구분 * Feat: 모달창 생성중 * Feat: 방생성 api 확인필요 * Design: CreateRoom CSS 작업완료 * Fix: z-index로 모달창 위치 올리기 * Feat: Username 가져오기 * Feat: 방 생성확인, 리스트생성 Store 생성 * Fix: layout 복구 * Feat: 전체방 불러오기 zustand, api 작성 * Refactor: 10초간격으로 가져오기 * Feat: Reactquery로 방불러오기 * Feat: 홈- 채팅방 연결 --------- Co-authored-by: KimKyuHoi * feat: kafka consumer 테스트 * Feat: ChatRoom kafka producer 설정 * feat/issue-121 (#126) * Feat: VideoBox 추가 중 * Feat: videobox 기본 구현 완료 * Feat: videobox 구현 완료 * Feat: userId 출력 완료 * feat: kafka설정 및 유저 입장시 온라인 처리 * Feat/issue 130 (#131) * Feat: ChatRoomInfo 스켈레톤 코드 완료 * Teat: 더미데이터 test완료 * Refactoring: token claim 이메일로 수정 (#118) * Refactoring: 네이버 소셜 로그인 token claim 이메일로 수정 * Feat: RefreshToken redis에 저장 * Feat/issue-112 (#129) * Feat: 로그아웃 토스트 추가 * Fix: Type -> Interface 수정 * Fix: Type -> Interface 수정 * Refactor: type 분리 * Fix: userlist 상태관리 로직 변경 * Fix: UserList interface 추가 및 수정 * Feat: 로그인 했을때 웹소켓 연결 및 커스텀 훅 구현 * Feat: WelcomePage 로그인했을떄 웹소켓 연결 및 로그아웃 구현 * Fix: 로그인 후 로그아웃 됐을때 publish 요청되는 부분 수정 * Fix: 로그인 후 로그아웃을 중복 시도 했을때 connect랑 publish 에러 안나도록 수정 * Chore: 에러 console 추가 * Feat: Message Body Type 추가 * feat: 로그인시 새로 유저 리스트 발행 * feat: kafka config 연동 * feat: kafka config 연동 * feat: 유레카 설정 삭제 * Feat: 채팅방 개별 조회 API (#122) * Feat: 채팅방 개별 조회 API * Feat: chatRoom-service cors 설정 * Feat: category -> hashTag로 네이밍 변경 * FE-feat/issue-100 (#117) * Chore: 공통 폴더 이동 및 파일 분리 * Fix: 파일 경로 설정 * Fix: sidebar 여백 수정 * Refactor: 비슷한 파일이름변경 * Feat: Wrapper 설정 및 Modal 추가 * Fix: 로그인성공 헤더로 구분 * Feat: 모달창 생성중 * Feat: 방생성 api 확인필요 * Design: CreateRoom CSS 작업완료 * Fix: z-index로 모달창 위치 올리기 * Feat: Username 가져오기 * Feat: 방 생성확인, 리스트생성 Store 생성 * Fix: layout 복구 * Feat: 전체방 불러오기 zustand, api 작성 * Refactor: 10초간격으로 가져오기 * Feat: Reactquery로 방불러오기 * Feat: 홈- 채팅방 연결 --------- Co-authored-by: KimKyuHoi * feat/issue-121 (#126) * Feat: VideoBox 추가 중 * Feat: videobox 기본 구현 완료 * Feat: videobox 구현 완료 * Feat: userId 출력 완료 * Feat: WelcomePage 로그인했을떄 웹소켓 연결 및 로그아웃 구현 --------- Co-authored-by: starwook Co-authored-by: bw1611 <97327583+leebuwon@users.noreply.github.com> Co-authored-by: DingX2 <96682768+DingX2@users.noreply.github.com> Co-authored-by: Dongpil Jo <91816664+eastfilmm@users.noreply.github.com> * HOTFIX!: Pull Request 반영안된 부분 수정 * refactor: Impl 삭제 * refactor: 기존 chatService 삭제 * feat: stomp -> kafka 변화 * feat: 카프카 관련 설계 로직 변경 이제는 리스트를 refresh 하라고만 명령 * feat: Chatting service- redis 설정 이제는 리스트를 refresh 하라고만 명령 * feat: 상태관리 서버 생성, 서버간 카프카 통신 * Fe feat/issue 132 (#136) * Feat: icon 추가 * Feat: KickBtn 스켈레톤 완료 * Feat: Change Owner Btn 스캘레톤 코 * feat: kafka service 생성 모든 파티션에 전송 로직 구현 * feat(Session-service): 모든 파티션 메시지 보내는 메소드 추가 * feat: username을 키로 하여 파티션 내의 순서 보장 * Feat/issue-28 (#134) * Fix: pull 용 * Refactor: Reactquery로 코드 수정 * Fix: 10->3초마다 방리스트 불러오기로 수정 * Feat: WelcomePage 채팅리스트 제작 * Feat: Click hover * Design: 텍스트 수직 중앙정렬 * Feat: WelocomePage 이미지썸네일 Preview 완성 * Fix: 검색기능 수정, 버튼클릭가능 * Fix: 회원가입 재전송 CSS 수정 * Feat: ChatRoomInfo 상태관리 zustand로 변경 (#139) * Hotfix: hastag로 수정 / 연결 확인완료 * Feat/issue 140 (#143) * Fix: roomId -> chatroom_Id 로 변경 * Fix: fetchRoomInfo fetch url 수정 * Feat: ChatRoomInfo 추가 --------- Co-authored-by: KimKyuHoi * HOTFIX!: build.gradle MYSQL 추가 * feat: 세션 서버 zookeeper 추가 * feat: zookeeper설정 * Feat: HashTage 추가, 인원 제한 exception 추가 (#147) * feat/issue-148 (#149) * Feat: 참여자 수, 방장, 참여 멤버 / 방 제목 api 따로 요청 * Refactoring: 강퇴 url username으로 수정 * Bug: 채팅방 강퇴 유저 list에 포함되지 않게하기 (#151) * Feat/issue 145 (#152) * Style: ChatRoomInfo 1차 css 적용 * Style: ChatRoomInfo css 2차 수정 * Style: ChatRoomInfo style 수정 * Test: API 테스트 완료 * Feat/issue-142 (#155) * Chore: printWidth prettier 넓이 변경 * Fix: 로그인이 안되어 있을경우 프론트에서 접근 막기 * Feat: 방 생성되자마자 방으로 이동할 수 있도록 하기 * Fix: StaleTime 수정 * Refactor: 웹소켓 연결 코드 커스텀 훅 분리 * Feat: 유저 리스트 웹소켓 연결 구현 * Feat: return에 userSubscribe 추가 * Fix: 변수명 변경 * Feat/issue 154 (#156) * Feat: zustand 적용 중 * Fix: video defualt 이미지 추가 * HOTFIX!: roomName url 받아오기 수정 * HOTFIX!: roomName받는 url 수정 * Hotfix: git ignore, mysql 추가 (#158) * Hotfix: git ignore, mysql 추가 * Hotfix: git ignore 추가 * Feat/issue 128 (#159) * Feat: BTN 추가 및 icon 추가 * Fix: BTN 기능 구현 중 * Fix: videocall css, roomName 받아오기 완료 * Style: VideoCall css 적용 * Feat: BTN 구현 완료 * Bug: PathVariable name 명시 (#161) * HOTFIX!: url 수정 * Fix/issue 163 (#164) * Fix: Chatroom 입장시 해당 post API 누락 * Fix: chatRoom 입장 API 호출 후 리렌더링 하도록 수정 * Fix: API 호출 로직 수정 * Fix: API url 수정 * Style: VideoCall Css 일부 수정 완료 (#166) * Feat/issue 141 (#144) * Feat: 로딩이미지 * Fix: 로딩이미지 정렬 * Design: 이미지 회색부분 수정 * HotFix: bug 파일 수정 * HotFIx: bug 파일 수정 * HotFix: mysql db 추가, discovery client 어노테이션 삭제 * HotFix: Zookeeper 라이브러리 삭제 * HotFix: my sql db 추가, git ignore 추가 * HOTFIX!: userlist받아오는 로직 수정 * HOTFIX!: 멤버 정보 가져오기 setInterval, 3000으로 가져오기 --------- Co-authored-by: starwook Co-authored-by: eastfilmm Co-authored-by: 유성욱 <88507708+starwook@users.noreply.github.com> Co-authored-by: Minhyuk Kim Co-authored-by: bw1611 <97327583+leebuwon@users.noreply.github.com> Co-authored-by: 김규회 Co-authored-by: Dongpil Jo <91816664+eastfilmm@users.noreply.github.com> Co-authored-by: DingX2 <96682768+DingX2@users.noreply.github.com> Co-authored-by: DingX2 Co-authored-by: bw1611 --- .gitignore | 373 +++++ backend/.idea/.gitignore | 8 + backend/.idea/backend.iml | 9 + backend/.idea/misc.xml | 6 + backend/.idea/modules.xml | 8 + backend/.idea/vcs.xml | 6 + backend/apigateway-service/.gitignore | 2 + backend/apigateway-service/build.gradle | 6 + .../apigatewayservice/config/CORSConfig.java | 20 + .../apigatewayservice/filter/AuthFilter.java | 103 ++ .../src/main/resources/application.yml | 38 +- backend/chatroom-service/.gitignore | 40 + backend/chatroom-service/build.gradle | 55 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/chatroom-service/gradlew | 249 +++ backend/chatroom-service/gradlew.bat | 92 ++ backend/chatroom-service/settings.gradle | 1 + .../ChatroomServiceApplication.java | 18 + .../dto/response/ChatMemberResponse.java | 22 + .../dto/response/EnterChatMemberResponse.java | 28 + .../domain/chatmember/entity/ChatMember.java | 39 + .../chatmember/entity/ChatMemberType.java | 5 + .../repository/ChatMemberRepository.java | 15 + .../chatmember/service/ChatMemberService.java | 88 ++ .../controller/ChatRoomController.java | 98 ++ .../chatroom/dto/request/ChatRoomRequest.java | 15 + .../dto/request/CreateChatroomRequest.java | 19 + .../dto/response/ChangeOwnerResponse.java | 24 + .../dto/response/ChatRoomInfoResponse.java | 29 + .../dto/response/ChatRoomNameResponse.java | 25 + .../dto/response/ChatRoomResponse.java | 34 + .../dto/response/CreateChatroomResponse.java | 36 + .../dto/response/KickMemberResponse.java | 30 + .../domain/chatroom/entity/ChatRoom.java | 78 + .../exception/AlreadyKickedException.java | 11 + .../CannotTransferOwnershipException.java | 10 + .../NotFoundChatMemberException.java | 10 + .../exception/NotFoundChatRoomException.java | 11 + .../exception/NotRoomOwnerException.java | 11 + .../exception/OverParticipationException.java | 11 + .../domain/chatroom/mq/KafkaProducer.java | 39 + .../chatroom/mq/dto/EnterKafkaRequest.java | 27 + .../repository/ChatRoomRepository.java | 10 + .../chatroom/service/ChatRoomService.java | 158 ++ .../chatroomservice/global/CorsConfig.java | 2 + .../global/config/CorsConfig.java | 25 + .../global/config/KafkaProducerConfig.java | 36 + .../global/error/ErrorCode.java | 44 + .../global/error/ErrorResponse.java | 86 ++ .../global/error/GlobalExceptionHandler.java | 122 ++ .../error/common/BusinessException.java | 22 + .../global/p6spy/P6SpySqlFormatter.java | 42 + .../src/main/resources/application.yml | 24 + .../ChatroomServiceApplicationTests.java | 13 + backend/chatting-service/.gitignore | 44 + backend/chatting-service/build.gradle | 55 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/chatting-service/gradlew | 249 +++ backend/chatting-service/gradlew.bat | 92 ++ backend/chatting-service/settings.gradle | 1 + .../ChattingServiceApplication.java | 14 + .../chat/controller/ChatController.java | 64 + .../chat/controller/KafkaController.java | 33 + .../chat/controller/OnlineController.java | 24 + .../domain/chat/dto/request/ChatRequest.java | 19 + .../dto/request/KafkaEnterChatRoomDto.java | 10 + .../domain/chat/dto/request/MessageType.java | 7 + .../dto/request/UserInformationRequest.java | 16 + .../dto/response/ChatEnterLeaveResponse.java | 27 + .../dto/response/ChatHistoryResponse.java | 29 + .../chat/dto/response/ChatListResponse.java | 23 + .../chat/dto/response/ChatResponse.java | 43 + .../chat/dto/response/UserParticipation.java | 21 + .../domain/chat/entity/Chat.java | 35 + .../domain/chat/entity/OnlineUser.java | 15 + .../exception/NotFoundChatListException.java | 10 + .../chat/exception/NotSaveChatException.java | 10 + .../chat/exception/validation/Validation.java | 22 + .../chat/repository/ChatRepository.java | 10 + .../chat/repository/OnlineUserRepository.java | 7 + .../domain/chat/service/ChatService.java | 69 + .../domain/chat/service/KafkaService.java | 30 + .../domain/chat/service/UserService.java | 61 + .../global/config/KafkaConsumerConfig.java | 38 + .../global/config/KafkaProducerConfig.java | 29 + .../global/config/WebSocketConfig.java | 24 + .../global/config/WebSocketEventListener.java | 29 + .../global/error/ErrorCode.java | 54 + .../global/error/ErrorResponse.java | 85 + .../global/error/GlobalExceptionHandler.java | 111 ++ .../error/common/BusinessException.java | 22 + .../src/main/resources/application.yml | 34 + .../resources/docker-compose-kafka-ui.yml | 12 + .../main/resources/docker-compose-single.yml | 41 + .../src/main/resources/docker-compose.yaml | 57 + .../src/main/resources/static/css/main.css | 287 ++++ .../src/main/resources/static/index.html | 53 + .../src/main/resources/static/js/main.js | 118 ++ .../ChattingServiceApplicationTests.java | 13 + backend/session-service/.gitignore | 40 + backend/session-service/build.gradle | 51 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/session-service/gradlew | 249 +++ backend/session-service/gradlew.bat | 92 ++ backend/session-service/settings.gradle | 1 + .../underdog/session/SessionApplication.java | 13 + .../session/config/KafkaConsumerConfig.java | 34 + .../session/config/KafkaProducerConfig.java | 31 + .../session/controller/StatusListener.java | 19 + .../controller/ZookeeperController.java | 23 + .../underdog/session/domain/OnlineUser.java | 25 + .../underdog/session/dto/ServerInfoDto.java | 10 + .../session/repository/StatusRepository.java | 14 + .../session/service/KafkaService.java | 19 + .../session/service/StatusService.java | 59 + .../session/service/ZookeeperService.java | 27 + .../src/main/resources/application.yml | 10 + .../session/SessionApplicationTests.java | 13 + backend/user-service/.gitignore | 5 +- backend/user-service/build.gradle | 23 + .../userservice/UserServiceApplication.java | 2 + .../domain/controller/UserController.java | 19 - .../email/controller/EmailController.java | 31 + .../domain/email/dto/EmailRequestDto.java | 10 + .../domain/email/dto/EmailResponseDto.java | 22 + .../email/repository/EmailRepository.java | 26 + .../domain/email/service/EmailService.java | 98 ++ .../member/controller/MemberController.java | 88 ++ .../member/dto/request/LoginRequestDto.java | 24 + .../member/dto/request/SignupRequestDto.java | 44 + .../dto/response/CheckEmailResponseDto.java | 25 + .../response/DuplicateCheckResponseDto.java | 21 + .../dto/response/MemberResponseDto.java | 24 + .../dto/response/SignupResponseDto.java | 34 + .../member/dto/response/TokenResponseDto.java | 24 + .../domain/member/entity/Member.java | 45 + .../domain/member/entity/Role.java | 5 + .../domain/member/entity/State.java | 5 + .../DuplicateMemberEmailException.java | 12 + .../DuplicateMemberUsernameException.java | 11 + .../exception/EmailNotVerifiedException.java | 10 + .../exception/MemberStateException.java | 11 + .../exception/NotFoundMemberException.java | 11 + .../exception/NotMatchLogoutException.java | 11 + .../exception/NotMatchPasswordException.java | 11 + .../member/repository/MemberRepository.java | 17 + .../domain/member/service/MemberService.java | 204 +++ .../domain/refresh/entity/RefreshToken.java | 19 + .../repository/RefreshTokenRepository.java | 39 + .../userservice/global/error/ErrorCode.java | 53 + .../global/error/ErrorResponse.java | 86 ++ .../global/error/GlobalExceptionHandler.java | 131 ++ .../error/common/BusinessException.java | 22 + .../global/jwt/config/JwtSecurityConfig.java | 22 + .../jwt/exception/NotFoundTokenException.java | 11 + .../global/jwt/filter/JwtFilter.java | 86 ++ .../jwt/handler/JwtAccessDeniedHandler.java | 20 + .../handler/JwtAuthenticationEntryPoint.java | 23 + .../global/jwt/provider/TokenProvider.java | 165 ++ .../global/oauth/CustomOAuth2UserService.java | 65 + .../global/oauth/OAuth2CustomMember.java | 32 + .../oauth/OAuth2MemberSuccessHandler.java | 62 + .../global/oauth/OAuthAttributes.java | 54 + .../global/p6spy/P6SpySqlFormatter.java | 42 + .../global/redis/config/RedisConfig.java | 34 + .../global/security/config/CorsConfig.java | 24 + .../security/config/SecurityConfig.java | 74 + .../userdetail/CustomUserDetailsService.java | 64 + .../src/main/resources/application.yml | 20 +- backend/webrtc-service/.gitignore | 37 + backend/webrtc-service/build.gradle | 32 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/webrtc-service/gradlew | 249 +++ backend/webrtc-service/gradlew.bat | 92 ++ backend/webrtc-service/settings.gradle | 1 + .../signaling/server/ServerApplication.java | 13 + .../signaling/server/config/StompConfig.java | 25 + .../signaling/server/config/WebRtcConfig.java | 38 + .../server/controller/WebRtcController.java | 18 + .../server/domain/WebSocketMessage.java | 19 + .../signaling/server/dto/ChatroomDto.java | 15 + .../server/handler/SignalHandler.java | 142 ++ .../signaling/server/service/RtcService.java | 33 + .../src/main/resources/application.properties | 1 + .../server/ServerApplicationTests.java | 13 + frontend/.eslintrc.json | 4 +- frontend/.gitignore | 6 + frontend/.prettierrc.json | 2 +- frontend/index.html | 3 +- frontend/package.json | 91 +- frontend/pnpm-lock.yaml | 1366 ++++++++++------- frontend/src/App.tsx | 12 +- frontend/src/assets/Bookmark.svg | 3 + frontend/src/assets/Close.svg | 3 + frontend/src/assets/Create.svg | 3 + frontend/src/assets/DefaultUser.svg | 11 + frontend/src/assets/Email.svg | 9 + frontend/src/assets/Home.svg | 3 + frontend/src/assets/Logo.svg | 9 + frontend/src/assets/Logout.svg | 3 + frontend/src/assets/Password.svg | 9 + frontend/src/assets/Search.svg | 4 + frontend/src/assets/Star.svg | 3 + frontend/src/assets/TadakTadak.svg | 3 + frontend/src/assets/User.svg | 9 + frontend/src/assets/react.svg | 1 - frontend/src/components/auth/InputForm.tsx | 88 ++ .../src/components/auth/NaverLoginButton.tsx | 124 ++ frontend/src/components/chat/ChatForm.tsx | 80 + frontend/src/components/chat/ChatMessage.tsx | 177 +++ frontend/src/components/chat/ChatRoom.tsx | 35 + .../src/components/chatRoomInfo/ChangeBtn.tsx | 10 + .../components/chatRoomInfo/ChatRoomInfo.tsx | 103 ++ .../src/components/chatRoomInfo/KickBtn.tsx | 11 + .../src/components/chatRoomInfo/Member.tsx | 88 ++ .../components/chatRoomInfo/RoomMember.tsx | 42 + .../src/components/chatRoomInfo/RoomName.tsx | 35 + frontend/src/components/common/Button.tsx | 35 + frontend/src/components/common/Toast.tsx | 90 ++ .../roomPreview/CreateRoomPreview.tsx | 107 ++ .../components/roomPreview/RoomPreview.tsx | 143 ++ .../roomPreview/RoomPreviewList.tsx | 43 + .../roomPreview/RoomPreviewSkeleton.tsx | 78 + frontend/src/components/user/List.tsx | 39 + frontend/src/components/user/UserList.tsx | 37 + frontend/src/components/user/UserProfile.tsx | 63 + frontend/src/components/video/AudioBtn.tsx | 27 + frontend/src/components/video/Video.tsx | 207 +++ frontend/src/components/video/VideoBox.tsx | 92 ++ frontend/src/components/video/VideoBtn.tsx | 28 + frontend/src/components/video/VideoCall.tsx | 237 +++ frontend/src/components/welcome/Favorite.tsx | 7 + frontend/src/components/welcome/Search.tsx | 61 + frontend/src/components/welcome/Sidebar.tsx | 168 ++ .../hooks/custom-hook/useConnectChatRoom.ts | 71 + .../hooks/custom-hook/useLoginWebSocket.ts | 70 + .../src/hooks/react-query/useAuthQuery.ts | 159 ++ .../src/hooks/react-query/useGetAllRoom.ts | 23 + .../src/hooks/react-query/useGetRoomNames.ts | 24 + frontend/src/hooks/react-query/useUserData.ts | 24 + frontend/src/index.css | 69 +- frontend/src/interface/ChatInterface.ts | 9 + frontend/src/interface/CommonInterface.ts | 3 + frontend/src/interface/RoomInterface.ts | 3 + frontend/src/interface/UserListInterface.ts | 21 + frontend/src/pages/ChattingListPage.tsx | 17 +- frontend/src/pages/ChattingRoomPage.tsx | 87 +- frontend/src/pages/SigninPage.tsx | 112 +- frontend/src/pages/SignupPage.tsx | 256 ++- frontend/src/pages/Signupnaver.tsx | 60 + frontend/src/pages/VideoCallPage.tsx | 67 + frontend/src/pages/WelcomePage.tsx | 177 ++- frontend/src/stores/UserInfoStore.ts | 31 + frontend/src/stores/useChatStore.ts | 20 + frontend/src/stores/useRoomInfoStore.ts | 29 + frontend/src/stores/useRoomStore.ts | 66 + frontend/src/stores/useUserListStore.ts | 12 + frontend/src/stores/useVideoCallStore.ts | 15 + frontend/src/styles/ComponentLayout.ts | 32 + frontend/src/styles/GlobalStyle.ts | 41 + frontend/src/styles/Layout.ts | 41 + frontend/tsconfig.json | 1 + frontend/vite.config.ts | 33 +- 267 files changed, 12591 insertions(+), 681 deletions(-) create mode 100644 .gitignore create mode 100644 backend/.idea/.gitignore create mode 100644 backend/.idea/backend.iml create mode 100644 backend/.idea/misc.xml create mode 100644 backend/.idea/modules.xml create mode 100644 backend/.idea/vcs.xml create mode 100644 backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/config/CORSConfig.java create mode 100644 backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/filter/AuthFilter.java create mode 100644 backend/chatroom-service/.gitignore create mode 100644 backend/chatroom-service/build.gradle create mode 100644 backend/chatroom-service/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/chatroom-service/gradle/wrapper/gradle-wrapper.properties create mode 100644 backend/chatroom-service/gradlew create mode 100644 backend/chatroom-service/gradlew.bat create mode 100644 backend/chatroom-service/settings.gradle create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/ChatroomServiceApplication.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/ChatMemberResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/EnterChatMemberResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMember.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMemberType.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/repository/ChatMemberRepository.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/service/ChatMemberService.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/controller/ChatRoomController.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/ChatRoomRequest.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/CreateChatroomRequest.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChangeOwnerResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomInfoResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomNameResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/CreateChatroomResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/KickMemberResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/entity/ChatRoom.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/AlreadyKickedException.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/CannotTransferOwnershipException.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatMemberException.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatRoomException.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotRoomOwnerException.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/OverParticipationException.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/KafkaProducer.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/dto/EnterKafkaRequest.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/repository/ChatRoomRepository.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/service/ChatRoomService.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/CorsConfig.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/CorsConfig.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/KafkaProducerConfig.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorCode.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorResponse.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/GlobalExceptionHandler.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/common/BusinessException.java create mode 100644 backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/p6spy/P6SpySqlFormatter.java create mode 100644 backend/chatroom-service/src/main/resources/application.yml create mode 100644 backend/chatroom-service/src/test/java/com/tadak/chatroomservice/ChatroomServiceApplicationTests.java create mode 100644 backend/chatting-service/.gitignore create mode 100644 backend/chatting-service/build.gradle create mode 100644 backend/chatting-service/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/chatting-service/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/chatting-service/gradlew create mode 100644 backend/chatting-service/gradlew.bat create mode 100644 backend/chatting-service/settings.gradle create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/ChattingServiceApplication.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/ChatController.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/KafkaController.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/OnlineController.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/ChatRequest.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/KafkaEnterChatRoomDto.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/MessageType.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/UserInformationRequest.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatEnterLeaveResponse.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatHistoryResponse.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatListResponse.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatResponse.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/UserParticipation.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/Chat.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/OnlineUser.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotFoundChatListException.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotSaveChatException.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/validation/Validation.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/ChatRepository.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/OnlineUserRepository.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/ChatService.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/KafkaService.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/UserService.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaConsumerConfig.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaProducerConfig.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketConfig.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketEventListener.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorCode.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorResponse.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/error/GlobalExceptionHandler.java create mode 100644 backend/chatting-service/src/main/java/com/example/chattingservice/global/error/common/BusinessException.java create mode 100644 backend/chatting-service/src/main/resources/application.yml create mode 100644 backend/chatting-service/src/main/resources/docker-compose-kafka-ui.yml create mode 100644 backend/chatting-service/src/main/resources/docker-compose-single.yml create mode 100644 backend/chatting-service/src/main/resources/docker-compose.yaml create mode 100644 backend/chatting-service/src/main/resources/static/css/main.css create mode 100644 backend/chatting-service/src/main/resources/static/index.html create mode 100644 backend/chatting-service/src/main/resources/static/js/main.js create mode 100644 backend/chatting-service/src/test/java/com/example/chattingservice/ChattingServiceApplicationTests.java create mode 100644 backend/session-service/.gitignore create mode 100644 backend/session-service/build.gradle create mode 100644 backend/session-service/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/session-service/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/session-service/gradlew create mode 100644 backend/session-service/gradlew.bat create mode 100644 backend/session-service/settings.gradle create mode 100644 backend/session-service/src/main/java/com/underdog/session/SessionApplication.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/config/KafkaConsumerConfig.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/config/KafkaProducerConfig.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/controller/StatusListener.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/controller/ZookeeperController.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/domain/OnlineUser.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/dto/ServerInfoDto.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/repository/StatusRepository.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/service/KafkaService.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/service/StatusService.java create mode 100644 backend/session-service/src/main/java/com/underdog/session/service/ZookeeperService.java create mode 100644 backend/session-service/src/main/resources/application.yml create mode 100644 backend/session-service/src/test/java/com/underdog/session/SessionApplicationTests.java delete mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/controller/UserController.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/email/controller/EmailController.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailRequestDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailResponseDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/email/repository/EmailRepository.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/email/service/EmailService.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/controller/MemberController.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/LoginRequestDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/SignupRequestDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/CheckEmailResponseDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/DuplicateCheckResponseDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/MemberResponseDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/SignupResponseDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/TokenResponseDto.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Member.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Role.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/State.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberEmailException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberUsernameException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/EmailNotVerifiedException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/MemberStateException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotFoundMemberException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchLogoutException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchPasswordException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/repository/MemberRepository.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/member/service/MemberService.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/entity/RefreshToken.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/repository/RefreshTokenRepository.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorCode.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorResponse.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/error/GlobalExceptionHandler.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/error/common/BusinessException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/jwt/config/JwtSecurityConfig.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/jwt/exception/NotFoundTokenException.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/jwt/filter/JwtFilter.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAccessDeniedHandler.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAuthenticationEntryPoint.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/jwt/provider/TokenProvider.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/oauth/CustomOAuth2UserService.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2CustomMember.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2MemberSuccessHandler.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuthAttributes.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/p6spy/P6SpySqlFormatter.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/redis/config/RedisConfig.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/security/config/CorsConfig.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/security/config/SecurityConfig.java create mode 100644 backend/user-service/src/main/java/com/tadak/userservice/global/security/userdetail/CustomUserDetailsService.java create mode 100644 backend/webrtc-service/.gitignore create mode 100644 backend/webrtc-service/build.gradle create mode 100644 backend/webrtc-service/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/webrtc-service/gradle/wrapper/gradle-wrapper.properties create mode 100644 backend/webrtc-service/gradlew create mode 100644 backend/webrtc-service/gradlew.bat create mode 100644 backend/webrtc-service/settings.gradle create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/ServerApplication.java create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/StompConfig.java create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/WebRtcConfig.java create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/controller/WebRtcController.java create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/domain/WebSocketMessage.java create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/dto/ChatroomDto.java create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/handler/SignalHandler.java create mode 100644 backend/webrtc-service/src/main/java/com/tadak/signaling/server/service/RtcService.java create mode 100644 backend/webrtc-service/src/main/resources/application.properties create mode 100644 backend/webrtc-service/src/test/java/com/tadak/signaling/server/ServerApplicationTests.java create mode 100644 frontend/src/assets/Bookmark.svg create mode 100644 frontend/src/assets/Close.svg create mode 100644 frontend/src/assets/Create.svg create mode 100644 frontend/src/assets/DefaultUser.svg create mode 100644 frontend/src/assets/Email.svg create mode 100644 frontend/src/assets/Home.svg create mode 100644 frontend/src/assets/Logo.svg create mode 100644 frontend/src/assets/Logout.svg create mode 100644 frontend/src/assets/Password.svg create mode 100644 frontend/src/assets/Search.svg create mode 100644 frontend/src/assets/Star.svg create mode 100644 frontend/src/assets/TadakTadak.svg create mode 100644 frontend/src/assets/User.svg delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/auth/InputForm.tsx create mode 100644 frontend/src/components/auth/NaverLoginButton.tsx create mode 100644 frontend/src/components/chat/ChatForm.tsx create mode 100644 frontend/src/components/chat/ChatMessage.tsx create mode 100644 frontend/src/components/chat/ChatRoom.tsx create mode 100644 frontend/src/components/chatRoomInfo/ChangeBtn.tsx create mode 100644 frontend/src/components/chatRoomInfo/ChatRoomInfo.tsx create mode 100644 frontend/src/components/chatRoomInfo/KickBtn.tsx create mode 100644 frontend/src/components/chatRoomInfo/Member.tsx create mode 100644 frontend/src/components/chatRoomInfo/RoomMember.tsx create mode 100644 frontend/src/components/chatRoomInfo/RoomName.tsx create mode 100644 frontend/src/components/common/Button.tsx create mode 100644 frontend/src/components/common/Toast.tsx create mode 100644 frontend/src/components/roomPreview/CreateRoomPreview.tsx create mode 100644 frontend/src/components/roomPreview/RoomPreview.tsx create mode 100644 frontend/src/components/roomPreview/RoomPreviewList.tsx create mode 100644 frontend/src/components/roomPreview/RoomPreviewSkeleton.tsx create mode 100644 frontend/src/components/user/List.tsx create mode 100644 frontend/src/components/user/UserList.tsx create mode 100644 frontend/src/components/user/UserProfile.tsx create mode 100644 frontend/src/components/video/AudioBtn.tsx create mode 100644 frontend/src/components/video/VideoBox.tsx create mode 100644 frontend/src/components/video/VideoBtn.tsx create mode 100644 frontend/src/components/video/VideoCall.tsx create mode 100644 frontend/src/components/welcome/Favorite.tsx create mode 100644 frontend/src/components/welcome/Search.tsx create mode 100644 frontend/src/components/welcome/Sidebar.tsx create mode 100644 frontend/src/hooks/custom-hook/useConnectChatRoom.ts create mode 100644 frontend/src/hooks/custom-hook/useLoginWebSocket.ts create mode 100644 frontend/src/hooks/react-query/useAuthQuery.ts create mode 100644 frontend/src/hooks/react-query/useGetAllRoom.ts create mode 100644 frontend/src/hooks/react-query/useGetRoomNames.ts create mode 100644 frontend/src/hooks/react-query/useUserData.ts create mode 100644 frontend/src/interface/ChatInterface.ts create mode 100644 frontend/src/interface/CommonInterface.ts create mode 100644 frontend/src/interface/RoomInterface.ts create mode 100644 frontend/src/interface/UserListInterface.ts create mode 100644 frontend/src/pages/Signupnaver.tsx create mode 100644 frontend/src/pages/VideoCallPage.tsx create mode 100644 frontend/src/stores/UserInfoStore.ts create mode 100644 frontend/src/stores/useChatStore.ts create mode 100644 frontend/src/stores/useRoomInfoStore.ts create mode 100644 frontend/src/stores/useRoomStore.ts create mode 100644 frontend/src/stores/useUserListStore.ts create mode 100644 frontend/src/stores/useVideoCallStore.ts create mode 100644 frontend/src/styles/ComponentLayout.ts create mode 100644 frontend/src/styles/GlobalStyle.ts create mode 100644 frontend/src/styles/Layout.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69c93ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,373 @@ +# Created by https://www.toptal.com/developers/gitignore/api/react,java,intellij,visualstudiocode,macos,windows,node +# Edit at https://www.toptal.com/developers/gitignore?templates=react,java,intellij,visualstudiocode,macos,windows,node + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### react ### +.DS_* +**/*.backup.* +**/*.back.* + +node_modules + +*.sublime* + +psd +thumb +sketch + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/react,java,intellij,visualstudiocode,macos,windows,node \ No newline at end of file diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/backend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/backend/.idea/backend.iml b/backend/.idea/backend.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/backend/.idea/backend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/misc.xml b/backend/.idea/misc.xml new file mode 100644 index 0000000..639900d --- /dev/null +++ b/backend/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml new file mode 100644 index 0000000..e066844 --- /dev/null +++ b/backend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/backend/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/apigateway-service/.gitignore b/backend/apigateway-service/.gitignore index c2065bc..8506e5b 100644 --- a/backend/apigateway-service/.gitignore +++ b/backend/apigateway-service/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +application-secret.yml \ No newline at end of file diff --git a/backend/apigateway-service/build.gradle b/backend/apigateway-service/build.gradle index 8cb89a8..81a05f2 100644 --- a/backend/apigateway-service/build.gradle +++ b/backend/apigateway-service/build.gradle @@ -28,6 +28,12 @@ ext { dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-gateway' implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/config/CORSConfig.java b/backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/config/CORSConfig.java new file mode 100644 index 0000000..f6eff94 --- /dev/null +++ b/backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/config/CORSConfig.java @@ -0,0 +1,20 @@ +package com.tadak.apigatewayservice.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.config.CorsRegistry; +import org.springframework.web.reactive.config.WebFluxConfigurer; + +@Configuration +public class CORSConfig implements WebFluxConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry){ + registry.addMapping("/**") + .allowCredentials(true) + .allowedOrigins("http://127.0.0.1:5173") + .allowedHeaders("*") + .allowedMethods("*") + .allowedOriginPatterns("*") + .exposedHeaders("Accesstoken, Refreshtoken"); + } +} diff --git a/backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/filter/AuthFilter.java b/backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/filter/AuthFilter.java new file mode 100644 index 0000000..92262d6 --- /dev/null +++ b/backend/apigateway-service/src/main/java/com/tadak/apigatewayservice/filter/AuthFilter.java @@ -0,0 +1,103 @@ +package com.tadak.apigatewayservice.filter; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.security.Key; + +@Slf4j +@Component +public class AuthFilter extends AbstractGatewayFilterFactory { + + private final String secret; + public static final String ACCESS_AUTHORIZATION_HEADER = "Accesstoken"; + public static final String REFRESH_AUTHORIZATION_HEADER = "Refreshtoken"; + private Key key; + + + public AuthFilter(@Value("${jwt.secret}") String secret){ + super(Config.class); + this.secret = secret; + } + + @PostConstruct + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> { + ServerHttpRequest request = exchange.getRequest(); + + String accessToken = request.getHeaders().get(ACCESS_AUTHORIZATION_HEADER).get(0); + log.info("accessToken = {}", accessToken); + + if (StringUtils.hasText(accessToken) && isJwtValid(accessToken)) { + return chain.filter(exchange); + } + + String refreshToken = request.getHeaders().get(REFRESH_AUTHORIZATION_HEADER).get(0); + + if (refreshToken != null && isJwtValid(refreshToken)) { + return chain.filter(exchange); + } + + return onError(exchange, "토큰이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED); + }; + } + + private Mono onError(ServerWebExchange exchange, String error, HttpStatus httpStatus) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(httpStatus); + + log.error(error); + return response.setComplete(); + } + + private boolean isJwtValid(String token) { + boolean returnValue = true; + + if (StringUtils.hasText(token) && token.startsWith("Bearer ")){ + token = token.substring(7); + } + + log.info("token = {}", token); + + String subject = null; + + try{ + subject = Jwts.parserBuilder() + .setSigningKey(key) + .build().parseClaimsJws(token) + .getBody() + .getSubject(); + }catch (Exception e){ + returnValue = false; + } + + if (subject == null || subject.isEmpty()){ + returnValue = false; + } + + return returnValue; + } + + public static class Config { + + } +} diff --git a/backend/apigateway-service/src/main/resources/application.yml b/backend/apigateway-service/src/main/resources/application.yml index efebd35..3f4ef0d 100644 --- a/backend/apigateway-service/src/main/resources/application.yml +++ b/backend/apigateway-service/src/main/resources/application.yml @@ -9,5 +9,41 @@ eureka: defaultZone: http://localhost:8761/eureka spring: + profiles: + include: secret application: - name: apigateway-service \ No newline at end of file + name: apigateway-service + cloud: + gateway: + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials + routes: + - id: user-service # 회원가입 + uri: lb://USER-SERVICE + predicates: + - Path=/user-service/signup/** + - Method=POST, GET + - id: user-service # 로그인 + uri: lb://USER-SERVICE + predicates: + - Path=/user-service/login + - Method=POST + - id: user-service # 이메일 인증 + uri: lb://USER-SERVICE + predicates: + - Path=/user-service/authcode/** + - Method=POST, GET + - id: user-service # 권한 인증 테스트 + uri: lb://USER-SERVICE + predicates: + - Path=/user-service/hello + - Method=GET + filters: + - AuthFilter + - id: chatroom-service # chatroom-service + uri: lb://CHATROOM-SERVICE + predicates: + - Path=/chatroom-service/** + - Method=POST, GET + filters: + - AuthFilter \ No newline at end of file diff --git a/backend/chatroom-service/.gitignore b/backend/chatroom-service/.gitignore new file mode 100644 index 0000000..2b03244 --- /dev/null +++ b/backend/chatroom-service/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +application-local.properties +application-local.yml \ No newline at end of file diff --git a/backend/chatroom-service/build.gradle b/backend/chatroom-service/build.gradle new file mode 100644 index 0000000..6ae64c7 --- /dev/null +++ b/backend/chatroom-service/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.1' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.tadak' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', "2023.0.0") +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + implementation 'org.springframework.kafka:spring-kafka' + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.kafka:spring-kafka-test' +// implementation("org.springframework.cloud:spring-cloud-starter-zookeeper-discovery") + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/chatroom-service/gradle/wrapper/gradle-wrapper.jar b/backend/chatroom-service/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/backend/chatroom-service/gradle/wrapper/gradle-wrapper.properties b/backend/chatroom-service/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/backend/chatroom-service/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/chatroom-service/gradlew b/backend/chatroom-service/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/backend/chatroom-service/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/chatroom-service/gradlew.bat b/backend/chatroom-service/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/backend/chatroom-service/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/chatroom-service/settings.gradle b/backend/chatroom-service/settings.gradle new file mode 100644 index 0000000..2774af8 --- /dev/null +++ b/backend/chatroom-service/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'chatroom-service' diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/ChatroomServiceApplication.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/ChatroomServiceApplication.java new file mode 100644 index 0000000..167262e --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/ChatroomServiceApplication.java @@ -0,0 +1,18 @@ +package com.tadak.chatroomservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +//import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableDiscoveryClient +@EnableJpaAuditing +public class ChatroomServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ChatroomServiceApplication.class, args); + } + +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/ChatMemberResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/ChatMemberResponse.java new file mode 100644 index 0000000..74f0bda --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/ChatMemberResponse.java @@ -0,0 +1,22 @@ +package com.tadak.chatroomservice.domain.chatmember.dto.response; + +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMember; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatMemberResponse { + + private String username; + + public static ChatMemberResponse from(ChatMember chatMember){ + return ChatMemberResponse.builder() + .username(chatMember.getUsername()) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/EnterChatMemberResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/EnterChatMemberResponse.java new file mode 100644 index 0000000..821f6db --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/dto/response/EnterChatMemberResponse.java @@ -0,0 +1,28 @@ +package com.tadak.chatroomservice.domain.chatmember.dto.response; + +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMember; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EnterChatMemberResponse { + + private Long chatMemberId; + private Long chatRoomId; + private String username; + private Integer participation; + + public static EnterChatMemberResponse of(ChatMember chatMember, Integer participation){ + return EnterChatMemberResponse.builder() + .chatMemberId(chatMember.getId()) + .chatRoomId(chatMember.getChatRoom().getId()) + .username(chatMember.getUsername()) + .participation(participation) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMember.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMember.java new file mode 100644 index 0000000..e5123ab --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMember.java @@ -0,0 +1,39 @@ +package com.tadak.chatroomservice.domain.chatmember.entity; + +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import static com.tadak.chatroomservice.domain.chatmember.entity.ChatMemberType.*; +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@SuperBuilder +@EntityListeners(AuditingEntityListener.class) +public class ChatMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + private ChatRoom chatRoom; + + private String username; + + @Builder.Default + @Enumerated(STRING) + private ChatMemberType type = IN_ROOM; + + public void updateState() { + this.type = KICKED; + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMemberType.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMemberType.java new file mode 100644 index 0000000..91d319e --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/entity/ChatMemberType.java @@ -0,0 +1,5 @@ +package com.tadak.chatroomservice.domain.chatmember.entity; + +public enum ChatMemberType { + IN_ROOM, KICKED, EXIT +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/repository/ChatMemberRepository.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/repository/ChatMemberRepository.java new file mode 100644 index 0000000..c387a7d --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/repository/ChatMemberRepository.java @@ -0,0 +1,15 @@ +package com.tadak.chatroomservice.domain.chatmember.repository; + +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMember; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ChatMemberRepository extends JpaRepository { + Optional findByChatRoomAndUsername(ChatRoom chatRoom, String username); + + boolean existsByChatRoomAndUsername(ChatRoom chatRoom, String username); +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/service/ChatMemberService.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/service/ChatMemberService.java new file mode 100644 index 0000000..17b8a2b --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatmember/service/ChatMemberService.java @@ -0,0 +1,88 @@ +package com.tadak.chatroomservice.domain.chatmember.service; + +import com.tadak.chatroomservice.domain.chatmember.dto.response.EnterChatMemberResponse; +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMember; +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMemberType; +import com.tadak.chatroomservice.domain.chatmember.repository.ChatMemberRepository; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import com.tadak.chatroomservice.domain.chatroom.exception.AlreadyKickedException; +import com.tadak.chatroomservice.domain.chatroom.exception.CannotTransferOwnershipException; +import com.tadak.chatroomservice.domain.chatroom.exception.NotFoundChatMemberException; +import com.tadak.chatroomservice.domain.chatroom.exception.OverParticipationException; +import com.tadak.chatroomservice.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatMemberService { + + private final ChatMemberRepository chatMemberRepository; + + @Transactional + public EnterChatMemberResponse enterMember(ChatRoom chatRoom, String username) { + ChatMember chatMember = ChatMember.builder() + .chatRoom(chatRoom) + .username(username) + .build(); + + if (Objects.equals(chatRoom.getCapacity(), chatRoom.getParticipation())){ + throw new OverParticipationException(ErrorCode.OVER_CHATROOM_PARTICIPATION_ERROR); + } + + // 채팅방 인원 증가 + chatRoom.increaseParticipation(); + + chatMemberRepository.save(chatMember); + + return EnterChatMemberResponse.of(chatMember, chatRoom.getParticipation()); + } + + public ChatMember findByChatMember(Long chatMemberId) { + return chatMemberRepository.findById(chatMemberId) + .orElseThrow(() -> new NotFoundChatMemberException(ErrorCode.NOT_FOUND_CHAT_MEMBER_ERROR)); + } + + public boolean validEnterChatMember(ChatRoom chatRoom, String username) { + + ChatMember chatMember = chatMemberRepository.findByChatRoomAndUsername(chatRoom, username) + .orElse(null); + + if (chatMember == null){ + return false; + } + + log.info("chatMember type = {}", chatMember.getType()); + + return chatMember.getType() == ChatMemberType.KICKED; + } + + // 존재하면 true, 존재하지 않으면 false + public boolean existsChatRoomAndUsername(ChatRoom chatRoom, String username) { + return chatMemberRepository.existsByChatRoomAndUsername(chatRoom, username); + } + + // ChatMember 가지고 오기 + public ChatMember getChatMemberByChatRoomAndUsername(ChatRoom chatRoom, String username) { + ChatMember chatMember = chatMemberRepository.findByChatRoomAndUsername(chatRoom, username) + .orElseThrow(() -> new NotFoundChatMemberException(ErrorCode.NOT_FOUND_CHAT_MEMBER_ERROR)); + + // 방장 위임 할 경우 exception + if (chatMember.getType() == ChatMemberType.KICKED){ + throw new CannotTransferOwnershipException(ErrorCode.CANNOT_TRANSFER_OWNER_ERROR); + } + + return chatMember; + } + + public ChatMember findByChatRoomAndChatUsername(ChatRoom chatRoom, String kickedUsername) { + return chatMemberRepository.findByChatRoomAndUsername(chatRoom, kickedUsername) + .orElseThrow(() -> new NotFoundChatMemberException(ErrorCode.NOT_FOUND_CHAT_MEMBER_ERROR)); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/controller/ChatRoomController.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/controller/ChatRoomController.java new file mode 100644 index 0000000..6fe8c7f --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/controller/ChatRoomController.java @@ -0,0 +1,98 @@ +package com.tadak.chatroomservice.domain.chatroom.controller; + +import com.tadak.chatroomservice.domain.chatmember.dto.response.EnterChatMemberResponse; +import com.tadak.chatroomservice.domain.chatroom.dto.request.ChatRoomRequest; +import com.tadak.chatroomservice.domain.chatroom.dto.request.CreateChatroomRequest; +import com.tadak.chatroomservice.domain.chatroom.dto.response.*; +import com.tadak.chatroomservice.domain.chatroom.service.ChatRoomService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/chatroom-service") +@Slf4j +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + + /** + * 방 생성 + */ + @PostMapping("/create") + public ResponseEntity create(@RequestBody CreateChatroomRequest chatroomRequest){ + CreateChatroomResponse createChatroomResponse = chatRoomService.create(chatroomRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(createChatroomResponse); + } + + /** + * 방 입장 + */ + @PostMapping("/room-in/{roomId}") + public ResponseEntity enter(@PathVariable("roomId") Long roomId, @RequestBody ChatRoomRequest chatRoomRequest){ + EnterChatMemberResponse enter = chatRoomService.enter(roomId, chatRoomRequest); + return ResponseEntity.status(HttpStatus.OK).body(enter); + } + + /** + * 방 삭제 + */ + @DeleteMapping("/delete/{roomId}") + public ResponseEntity deleteChatRoom(@PathVariable("roomId") Long roomId, @RequestBody ChatRoomRequest chatRoomRequest) { + chatRoomService.deleteChatRoom(roomId, chatRoomRequest.getUsername()); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + + /** + * 방 전체 리스트 조회 + */ + @GetMapping("/rooms") + public ResponseEntity> getAllChatRoom() { + List chatRooms = chatRoomService.findAll(); + return ResponseEntity.status(HttpStatus.OK).body(chatRooms); + } + + /** + * 강퇴 + */ + @PostMapping("/rooms/{roomId}/kicked/{username}") + public ResponseEntity kickedMember(@PathVariable("roomId") Long roomId, @PathVariable("username") String username, + @RequestBody ChatRoomRequest chatRoomRequest){ + KickMemberResponse kickMemberResponse = chatRoomService.kickMember(roomId, username, chatRoomRequest.getUsername()); + return ResponseEntity.status(HttpStatus.OK).body(kickMemberResponse); + } + + /** + * 방장 위임 + */ + @PatchMapping("/rooms/{roomId}/change-owner/{username}") + public ResponseEntity changeOwner(@PathVariable("roomId") Long roomId, @PathVariable("username") String username, + @RequestBody ChatRoomRequest chatRoomRequest){ + ChangeOwnerResponse changeOwnerResponse = chatRoomService.changeOwner(roomId, username, chatRoomRequest.getUsername()); + return ResponseEntity.status(HttpStatus.OK).body(changeOwnerResponse); + } + + /** + * 개별 방 조회 (채팅방 제목) + */ + @GetMapping("/rooms/{roomId}/roomName") + public ResponseEntity getChatRoomName(@PathVariable("roomId") Long roomId){ + ChatRoomNameResponse getChatRoom = chatRoomService.findChatRoom(roomId); + return ResponseEntity.status(HttpStatus.OK).body(getChatRoom); + } + + /** + * 개별 방 조회 (참여자 수, 방장, 참여 멤버) + */ + @GetMapping("/rooms/{roomId}/roomInformation") + public ResponseEntity getChatRoomInfo(@PathVariable("roomId") Long roomId){ + ChatRoomInfoResponse getChatRoomInfo = chatRoomService.findChatRoomInfo(roomId); + return ResponseEntity.status(HttpStatus.OK).body(getChatRoomInfo); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/ChatRoomRequest.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/ChatRoomRequest.java new file mode 100644 index 0000000..e411992 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/ChatRoomRequest.java @@ -0,0 +1,15 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ChatRoomRequest { + + private String username; +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/CreateChatroomRequest.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/CreateChatroomRequest.java new file mode 100644 index 0000000..fae24d1 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/request/CreateChatroomRequest.java @@ -0,0 +1,19 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CreateChatroomRequest { + + private String roomName; + private String description; + private String owner; + private String hashtag; + private Integer capacity; +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChangeOwnerResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChangeOwnerResponse.java new file mode 100644 index 0000000..693d0b4 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChangeOwnerResponse.java @@ -0,0 +1,24 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.response; + +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChangeOwnerResponse { + + private Long chatRoomId; + private String newOwner; + + public static ChangeOwnerResponse from(ChatRoom chatRoom){ + return ChangeOwnerResponse.builder() + .chatRoomId(chatRoom.getId()) + .newOwner(chatRoom.getOwner()) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomInfoResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomInfoResponse.java new file mode 100644 index 0000000..1249ef7 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomInfoResponse.java @@ -0,0 +1,29 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.response; + +import com.tadak.chatroomservice.domain.chatmember.dto.response.ChatMemberResponse; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatRoomInfoResponse { + + private Integer participation; + private String owner; + private List chatMemberResponses; + + public static ChatRoomInfoResponse of(ChatRoom chatRoom, List chatMemberResponses){ + return ChatRoomInfoResponse.builder() + .participation(chatRoom.getParticipation()) + .owner(chatRoom.getOwner()) + .chatMemberResponses(chatMemberResponses) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomNameResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomNameResponse.java new file mode 100644 index 0000000..d6e9951 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomNameResponse.java @@ -0,0 +1,25 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.response; + +import com.tadak.chatroomservice.domain.chatmember.dto.response.ChatMemberResponse; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatRoomNameResponse { + + private String roomName; + + public static ChatRoomNameResponse from(ChatRoom chatRoom){ + return ChatRoomNameResponse.builder() + .roomName(chatRoom.getRoomName()) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomResponse.java new file mode 100644 index 0000000..e72aae3 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/ChatRoomResponse.java @@ -0,0 +1,34 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.response; + +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatRoomResponse { + + private Long roomId; + private String roomName; + private String description; + private String hashtag; + private Integer participation; + private Integer capacity; + private String owner; + + public static ChatRoomResponse from(ChatRoom chatRoom) { + return ChatRoomResponse.builder() + .roomId(chatRoom.getId()) + .roomName(chatRoom.getRoomName()) + .description(chatRoom.getDescription()) + .hashtag(chatRoom.getHashtag()) + .participation(chatRoom.getParticipation()) + .capacity(chatRoom.getCapacity()) + .owner(chatRoom.getOwner()) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/CreateChatroomResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/CreateChatroomResponse.java new file mode 100644 index 0000000..7bdbc53 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/CreateChatroomResponse.java @@ -0,0 +1,36 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.response; + +import com.tadak.chatroomservice.domain.chatmember.dto.response.EnterChatMemberResponse; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class CreateChatroomResponse { + + private Long id; + private String roomName; + private String description; + private String owner; + private String hashtag; + private Integer participation; + private Integer capacity; + private EnterChatMemberResponse chatMemberResponse; + + public static CreateChatroomResponse of(ChatRoom chatRoom, EnterChatMemberResponse chatMemberResponse) { + + return CreateChatroomResponse.builder() + .id(chatRoom.getId()) + .roomName(chatRoom.getRoomName()) + .description(chatRoom.getDescription()) + .owner(chatRoom.getOwner()) + .hashtag(chatRoom.getHashtag()) + .participation(chatRoom.getParticipation()) + .capacity(chatRoom.getCapacity()) + .chatMemberResponse(chatMemberResponse) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/KickMemberResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/KickMemberResponse.java new file mode 100644 index 0000000..7028592 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/dto/response/KickMemberResponse.java @@ -0,0 +1,30 @@ +package com.tadak.chatroomservice.domain.chatroom.dto.response; + +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMember; +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMemberType; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KickMemberResponse { + + private String username; + private ChatMemberType type; + private Long chatRoomId; + private Integer participation; + + public static KickMemberResponse of(ChatMember chatMember, ChatRoom chatRoom){ + return KickMemberResponse.builder() + .username(chatMember.getUsername()) + .type(chatMember.getType()) + .chatRoomId(chatRoom.getId()) + .participation(chatRoom.getParticipation()) + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/entity/ChatRoom.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/entity/ChatRoom.java new file mode 100644 index 0000000..9dd88b1 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/entity/ChatRoom.java @@ -0,0 +1,78 @@ +package com.tadak.chatroomservice.domain.chatroom.entity; + +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMember; +import com.tadak.chatroomservice.domain.chatroom.dto.request.CreateChatroomRequest; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ChatRoom { + + private static Integer DEFAULT_PARTICIPATION = 0; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @NotNull + @Size(max = 30, message = "방 제목은 30자를 초과할 수 없습니다.") + private String roomName; + @NotNull + @Size(max = 255, message = "방 설명은 255자를 넘길 수 없습니다.") + private String description; + @NotNull + private String owner; + @NotNull + @Size(max = 10, message = "카테고리 글자는 10자를 넘길 수 없습니다.") + private String hashtag; + @NotNull + private Integer participation; + @NotNull + private Integer capacity; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List chatMembers = new ArrayList<>(); + + @CreatedDate + private LocalDateTime createdAt; + @LastModifiedDate + private LocalDateTime modifiedAt; + + + public static ChatRoom toEntity(CreateChatroomRequest chatroomRequest){ + return ChatRoom.builder() + .roomName(chatroomRequest.getRoomName()) + .description(chatroomRequest.getDescription()) + .owner(chatroomRequest.getOwner()) + .hashtag(chatroomRequest.getHashtag()) + .participation(DEFAULT_PARTICIPATION) + .capacity(chatroomRequest.getCapacity()) + .build(); + } + + public void increaseParticipation() { + this.participation++; + } + + public void decreaseParticipation() { + this.participation--; + } + + public void updateOwner(String username) { + this.owner = username; + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/AlreadyKickedException.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/AlreadyKickedException.java new file mode 100644 index 0000000..6d1c595 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/AlreadyKickedException.java @@ -0,0 +1,11 @@ +package com.tadak.chatroomservice.domain.chatroom.exception; + +import com.tadak.chatroomservice.global.error.ErrorCode; +import com.tadak.chatroomservice.global.error.common.BusinessException; + +public class AlreadyKickedException extends BusinessException { + + public AlreadyKickedException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/CannotTransferOwnershipException.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/CannotTransferOwnershipException.java new file mode 100644 index 0000000..a6929ca --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/CannotTransferOwnershipException.java @@ -0,0 +1,10 @@ +package com.tadak.chatroomservice.domain.chatroom.exception; + +import com.tadak.chatroomservice.global.error.ErrorCode; +import com.tadak.chatroomservice.global.error.common.BusinessException; + +public class CannotTransferOwnershipException extends BusinessException { + public CannotTransferOwnershipException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatMemberException.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatMemberException.java new file mode 100644 index 0000000..636837d --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatMemberException.java @@ -0,0 +1,10 @@ +package com.tadak.chatroomservice.domain.chatroom.exception; + +import com.tadak.chatroomservice.global.error.ErrorCode; +import com.tadak.chatroomservice.global.error.common.BusinessException; + +public class NotFoundChatMemberException extends BusinessException { + public NotFoundChatMemberException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatRoomException.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatRoomException.java new file mode 100644 index 0000000..4a5a4ee --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotFoundChatRoomException.java @@ -0,0 +1,11 @@ +package com.tadak.chatroomservice.domain.chatroom.exception; + +import com.tadak.chatroomservice.global.error.ErrorCode; +import com.tadak.chatroomservice.global.error.common.BusinessException; + +public class NotFoundChatRoomException extends BusinessException { + + public NotFoundChatRoomException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotRoomOwnerException.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotRoomOwnerException.java new file mode 100644 index 0000000..aaa8d48 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/NotRoomOwnerException.java @@ -0,0 +1,11 @@ +package com.tadak.chatroomservice.domain.chatroom.exception; + +import com.tadak.chatroomservice.global.error.ErrorCode; +import com.tadak.chatroomservice.global.error.common.BusinessException; + +public class NotRoomOwnerException extends BusinessException { + + public NotRoomOwnerException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/OverParticipationException.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/OverParticipationException.java new file mode 100644 index 0000000..6f34af2 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/exception/OverParticipationException.java @@ -0,0 +1,11 @@ +package com.tadak.chatroomservice.domain.chatroom.exception; + +import com.tadak.chatroomservice.global.error.ErrorCode; +import com.tadak.chatroomservice.global.error.common.BusinessException; + +public class OverParticipationException extends BusinessException { + + public OverParticipationException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/KafkaProducer.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/KafkaProducer.java new file mode 100644 index 0000000..e8f2589 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/KafkaProducer.java @@ -0,0 +1,39 @@ +package com.tadak.chatroomservice.domain.chatroom.mq; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tadak.chatroomservice.domain.chatroom.mq.dto.EnterKafkaRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class KafkaProducer { + + private final KafkaTemplate kafkaTemplate; + public static final String STATUS_TOPIC_NAME ="status-change"; + + public EnterKafkaRequest send(String topic, EnterKafkaRequest enterKafkaRequest){ + String stringToJson = StringToJson(enterKafkaRequest); + kafkaTemplate.send(topic, stringToJson); + return enterKafkaRequest; + } + public void sendWithUsernameByKeyToSessionServer(String topic,EnterKafkaRequest enterKafkaRequest){ + String stringToJson = StringToJson(enterKafkaRequest); + kafkaTemplate.send(topic, enterKafkaRequest.getUsername(),stringToJson); + } + + private String StringToJson(EnterKafkaRequest enterKafkaRequest) { + ObjectMapper mapper = new ObjectMapper(); + String jsonToString = ""; + try { + jsonToString = mapper.writeValueAsString(enterKafkaRequest); + } catch (JsonProcessingException ex){ + ex.printStackTrace(); + } + return jsonToString; + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/dto/EnterKafkaRequest.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/dto/EnterKafkaRequest.java new file mode 100644 index 0000000..6c1b918 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/mq/dto/EnterKafkaRequest.java @@ -0,0 +1,27 @@ +package com.tadak.chatroomservice.domain.chatroom.mq.dto; + +import com.tadak.chatroomservice.domain.chatmember.dto.response.EnterChatMemberResponse; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EnterKafkaRequest { + + private String roomName; + private String username; + private String status; + + public static EnterKafkaRequest from(ChatRoom chatRoom, String username){ + return EnterKafkaRequest.builder() + .roomName(chatRoom.getRoomName()) + .username(username) + .status("enter") + .build(); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/repository/ChatRoomRepository.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/repository/ChatRoomRepository.java new file mode 100644 index 0000000..3fb59ac --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/repository/ChatRoomRepository.java @@ -0,0 +1,10 @@ +package com.tadak.chatroomservice.domain.chatroom.repository; + +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/service/ChatRoomService.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/service/ChatRoomService.java new file mode 100644 index 0000000..6645952 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/domain/chatroom/service/ChatRoomService.java @@ -0,0 +1,158 @@ +package com.tadak.chatroomservice.domain.chatroom.service; + +import com.tadak.chatroomservice.domain.chatmember.dto.response.ChatMemberResponse; +import com.tadak.chatroomservice.domain.chatmember.dto.response.EnterChatMemberResponse; +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMember; +import com.tadak.chatroomservice.domain.chatmember.entity.ChatMemberType; +import com.tadak.chatroomservice.domain.chatmember.service.ChatMemberService; +import com.tadak.chatroomservice.domain.chatroom.dto.request.ChatRoomRequest; +import com.tadak.chatroomservice.domain.chatroom.dto.response.*; +import com.tadak.chatroomservice.domain.chatroom.exception.AlreadyKickedException; +import com.tadak.chatroomservice.domain.chatroom.exception.NotFoundChatRoomException; +import com.tadak.chatroomservice.domain.chatroom.exception.NotRoomOwnerException; +import com.tadak.chatroomservice.domain.chatroom.mq.KafkaProducer; +import com.tadak.chatroomservice.domain.chatroom.mq.dto.EnterKafkaRequest; +import com.tadak.chatroomservice.domain.chatroom.repository.ChatRoomRepository; +import com.tadak.chatroomservice.domain.chatroom.dto.request.CreateChatroomRequest; +import com.tadak.chatroomservice.domain.chatroom.entity.ChatRoom; +import com.tadak.chatroomservice.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class ChatRoomService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMemberService chatMemberService; + private final KafkaProducer kafkaProducer; + private static final String REFRESH_LIST_TOPIC_NAME = "refresh"; + + @Transactional + public CreateChatroomResponse create(CreateChatroomRequest chatroomRequest) { + ChatRoom chatRoom = ChatRoom.toEntity(chatroomRequest); + // 방 생성 + chatRoomRepository.save(chatRoom); + // 방 입장 + EnterChatMemberResponse enterChatMemberResponse = chatMemberService.enterMember(chatRoom, chatRoom.getOwner()); + + return CreateChatroomResponse.of(chatRoom, enterChatMemberResponse); + + } + + @Transactional + public EnterChatMemberResponse enter(Long roomId, ChatRoomRequest chatRoomRequest) { + ChatRoom chatRoom = findByChatRoom(roomId); + + if (chatMemberService.validEnterChatMember(chatRoom, chatRoomRequest.getUsername())){ + throw new AlreadyKickedException(ErrorCode.KICKED_MEMBER_ERROR); + } + + // 채팅방에 없는 member 일 경우 save 로직 + if (!chatMemberService.existsChatRoomAndUsername(chatRoom, chatRoomRequest.getUsername())) { + EnterKafkaRequest enterKafkaRequest = EnterKafkaRequest.from(chatRoom, chatRoomRequest.getUsername()); + kafkaProducer.sendWithUsernameByKeyToSessionServer(KafkaProducer.STATUS_TOPIC_NAME, enterKafkaRequest); + return chatMemberService.enterMember(chatRoom, chatRoomRequest.getUsername()); + } + + // 채팅방에 member가 있을 경우 get으로 가져와서 전달 + ChatMember existingChatMember = chatMemberService.getChatMemberByChatRoomAndUsername(chatRoom, chatRoomRequest.getUsername()); + + // kafka send + EnterKafkaRequest enterKafkaRequest = EnterKafkaRequest.from(chatRoom, chatRoomRequest.getUsername()); + kafkaProducer.sendWithUsernameByKeyToSessionServer(KafkaProducer.STATUS_TOPIC_NAME, enterKafkaRequest); + + return EnterChatMemberResponse.of(existingChatMember, chatRoom.getParticipation()); + } + + public List findAll() { + List chatRooms = chatRoomRepository.findAll(); + + return chatRooms.stream() + .map(ChatRoomResponse::from) + .collect(Collectors.toList()); + } + + @Transactional + public void deleteChatRoom(Long roomId, String username) { + ChatRoom chatRoom = findByChatRoom(roomId); + + validOwner(username, chatRoom.getOwner()); + + chatRoomRepository.delete(chatRoom); + } + + @Transactional + public KickMemberResponse kickMember(Long roomId, String kickedUsername, String owner) { + ChatRoom chatRoom = findByChatRoom(roomId); + ChatMember chatMember = chatMemberService.findByChatRoomAndChatUsername(chatRoom, kickedUsername); + // 방장 검증 + validOwner(owner, chatRoom.getOwner()); + + // 상태를 KICKED로 변경 + chatMember.updateState(); + // 채팅방 인원 감소 + chatRoom.decreaseParticipation(); + + return KickMemberResponse.of(chatMember, chatRoom); + } + + /** + * 방장 검증 + */ + private void validOwner(String owner, String chatRoomOwner) { + if (!owner.equals(chatRoomOwner)){ + throw new NotRoomOwnerException(ErrorCode.NOT_OWNER_ERROR); + } + } + + /** + * 방 찾기 + */ + private ChatRoom findByChatRoom(Long roomId) { + return chatRoomRepository.findById(roomId) + .orElseThrow(() -> new NotFoundChatRoomException(ErrorCode.NOT_FOUND_CHATROOM_ERROR)); + } + + /** + * @param roomId : 방 ID + * @param username : Owner가 될 대상 + * @param owner : 현재 Owner + */ + @Transactional + public ChangeOwnerResponse changeOwner(Long roomId, String username, String owner) { + ChatRoom chatRoom = findByChatRoom(roomId); + validOwner(owner, chatRoom.getOwner()); + + chatMemberService.getChatMemberByChatRoomAndUsername(chatRoom, username); + + chatRoom.updateOwner(username); + + return ChangeOwnerResponse.from(chatRoom); + } + + // 방 제목 엔드포인트 + public ChatRoomNameResponse findChatRoom(Long roomId) { + ChatRoom chatRoom = findByChatRoom(roomId); + + return ChatRoomNameResponse.from(chatRoom); + } + + // 방장, 참여자 수, 참여 멤버 엔드포인트 + public ChatRoomInfoResponse findChatRoomInfo(Long roomId) { + ChatRoom chatRoom = findByChatRoom(roomId); + + List chatMemberResponses = chatRoom.getChatMembers().stream() + .filter(chatMember -> chatMember.getType() == ChatMemberType.IN_ROOM) + .map(ChatMemberResponse::from).toList(); + + return ChatRoomInfoResponse.of(chatRoom, chatMemberResponses); + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/CorsConfig.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/CorsConfig.java new file mode 100644 index 0000000..28824c2 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/CorsConfig.java @@ -0,0 +1,2 @@ +package com.tadak.chatroomservice.global;public class CorsConfig { +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/CorsConfig.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/CorsConfig.java new file mode 100644 index 0000000..c43bd7d --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.tadak.chatroomservice.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.addExposedHeader("Accesstoken, Refreshtoken"); + + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} + diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/KafkaProducerConfig.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/KafkaProducerConfig.java new file mode 100644 index 0000000..29dadc1 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/config/KafkaProducerConfig.java @@ -0,0 +1,36 @@ +package com.tadak.chatroomservice.global.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +import java.util.HashMap; +import java.util.Map; + +@EnableKafka +@Configuration +public class KafkaProducerConfig { + + + @Bean + public ProducerFactory producerFactory() { + Map properties = new HashMap<>(); + properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return new DefaultKafkaProducerFactory<>(properties); + } + + + @Bean + public KafkaTemplate kafkaObjectTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorCode.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorCode.java new file mode 100644 index 0000000..4e1a564 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorCode.java @@ -0,0 +1,44 @@ +package com.tadak.chatroomservice.global.error; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + + BAD_REQUEST_ERROR(400, "G001", "Bad Request Exception"), + REQUEST_BODY_MISSING_ERROR(400, "G002", "Required request body is missing"), + MISSING_REQUEST_PARAMETER_ERROR(400, "G004", "Missing Servlet RequestParameter Exception"), + NOT_FOUND_ERROR(404, "G009", "Not Found Exception"), + NULL_POINT_ERROR(404, "G010", "Null Point Exception"), + NOT_VALID_ERROR(404, "G011", "handle Validation Exception"), + INTERNAL_SERVER_ERROR(500, "G999", "Internal Server Error Exception"), + + /** + * 1300 ~ 1399 (ChatRoom error) + */ + NOT_FOUND_CHATROOM_ERROR(1300, "G1300", "현재 존재하지 않는 채팅 방 입니다."), + NOT_OWNER_ERROR(1301, "G1300", "방장 권한이 없습니다."), + KICKED_MEMBER_ERROR(1302, "G1300", "현재 강퇴당한 채팅 방 입니다."), + CANNOT_TRANSFER_OWNER_ERROR(1303, "G1300", "해당 유저는 강퇴당한 유저이기 떄문에 방장 위임을 할 수 없습니다."), + OVER_CHATROOM_PARTICIPATION_ERROR(1304, "G1300", "해당 채팅 방은 인원이 가득차 있습니다."), + + /** + * 1400 ~ 1499 + */ + INVALID_INPUT_VALUE(1400, "G1400", "잘못된 입력 값 입니다."), + + /** + * 1500 ~ 1599 (ChatMember error) + */ + NOT_FOUND_CHAT_MEMBER_ERROR(1500, "G1500", "현재 채팅방에 해당 member가 존재하지 않습니다."); + + private final int status; + private final String code; + private final String message; + + ErrorCode(final int status, final String code, final String message) { + this.status = status; + this.code = code; + this.message = message; + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorResponse.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorResponse.java new file mode 100644 index 0000000..d50ab3f --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/ErrorResponse.java @@ -0,0 +1,86 @@ +package com.tadak.chatroomservice.global.error; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.validation.BindingResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ErrorResponse { + + private int status; // 에러 상태 코드 + private String code; // 에러 구분 코드 + private String message; // 에러 메시지 + + @Builder + protected ErrorResponse(final ErrorCode code) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + @Builder + protected ErrorResponse(final ErrorCode code, final String reason) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + @Builder + protected ErrorResponse(final ErrorCode code, final List errors) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + public static ErrorResponse of(final ErrorCode code, final BindingResult bindingResult) { + return new ErrorResponse(code, FieldError.of(bindingResult)); + } + + public static ErrorResponse of(final ErrorCode code) { + return new ErrorResponse(code); + } + + public static ErrorResponse of(final ErrorCode code, final String reason) { + return new ErrorResponse(code, reason); + } + + /** + * e.getBindingResult() 형태로 전달받는 error 처리하여 변경 + */ + @Getter + public static class FieldError { + private final String field; + private final String value; + private final String reason; + + public static List of(final String field, final String value, final String reason) { + List fieldErrors = new ArrayList<>(); + fieldErrors.add(new FieldError(field, value, reason)); + return fieldErrors; + } + + private static List of(final BindingResult bindingResult) { + final List fieldErrors = bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + + @Builder + FieldError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + } +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/GlobalExceptionHandler.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..099c0d4 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/GlobalExceptionHandler.java @@ -0,0 +1,122 @@ +package com.tadak.chatroomservice.global.error; + +import com.tadak.chatroomservice.global.error.common.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.servlet.NoHandlerFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final HttpStatus HTTP_STATUS_OK = HttpStatus.OK; + + /** + * API 호출 시 객체 혹은 파라미터의 값이 제대로 되지 않을 경우 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + log.error("handleMethodArgumentNotValidException", ex); + BindingResult bindingResult = ex.getBindingResult(); + StringBuilder stringBuilder = new StringBuilder(); + for (FieldError fieldError : bindingResult.getFieldErrors()) { + stringBuilder.append(fieldError.getField()).append(":"); + stringBuilder.append(fieldError.getDefaultMessage()); + stringBuilder.append(", "); + } + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_VALID_ERROR, String.valueOf(stringBuilder)); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * Body 부분에 객체 데이터가 넘어 오지 않았을 경우 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + log.error("HttpMessageNotReadableException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.REQUEST_BODY_MISSING_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * 클라이언트에서 body로 객체 데이터가 넘어오지 않을 경우 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity handleMissingRequestHeaderExceptionException(MissingServletRequestParameterException ex) { + log.error("handleMissingServletRequestParameterException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.MISSING_REQUEST_PARAMETER_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * 잘못된 서버 요청일 경우 + */ + @ExceptionHandler(HttpClientErrorException.BadRequest.class) + protected ResponseEntity handleBadRequestException(HttpClientErrorException e) { + log.error("HttpClientErrorException.BadRequest", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.BAD_REQUEST_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * 잘못된 주소로 요청한 경우 + */ + @ExceptionHandler(NoHandlerFoundException.class) + protected ResponseEntity handleNoHandlerFoundExceptionException(NoHandlerFoundException e) { + log.error("handleNoHandlerFoundExceptionException", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_FOUND_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * null 값이 발생한 경우 + */ + @ExceptionHandler(NullPointerException.class) + protected ResponseEntity handleNullPointerException(NullPointerException e) { + log.error("handleNullPointerException", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NULL_POINT_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * exception이 발생한 경우 + */ + @ExceptionHandler(Exception.class) + protected final ResponseEntity handleAllExceptions(Exception ex) { + log.error("Exception", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * Custom Exception + */ + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleBusinessException(final BusinessException ex) { + log.error("handleBusinessException", ex); + final ErrorCode errorCode = ex.getErrorCode(); + final ErrorResponse response = ErrorResponse.of(errorCode); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * binding Exception + */ + @ExceptionHandler(BindException.class) + protected ResponseEntity handleBindException(BindException ex) { + log.error("handleBindException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, ex.getBindingResult()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + +} diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/common/BusinessException.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/common/BusinessException.java new file mode 100644 index 0000000..0a82230 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/error/common/BusinessException.java @@ -0,0 +1,22 @@ +package com.tadak.chatroomservice.global.error.common; + +import com.tadak.chatroomservice.global.error.ErrorCode; + +public class BusinessException extends RuntimeException{ + + private ErrorCode errorCode; + + public BusinessException(String message, ErrorCode errorCode) { + super(message); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/p6spy/P6SpySqlFormatter.java b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/p6spy/P6SpySqlFormatter.java new file mode 100644 index 0000000..6180e82 --- /dev/null +++ b/backend/chatroom-service/src/main/java/com/tadak/chatroomservice/global/p6spy/P6SpySqlFormatter.java @@ -0,0 +1,42 @@ +package com.tadak.chatroomservice.global.p6spy; + +import com.p6spy.engine.logging.Category; +import com.p6spy.engine.spy.P6SpyOptions; +import com.p6spy.engine.spy.appender.MessageFormattingStrategy; +import jakarta.annotation.PostConstruct; +import org.hibernate.engine.jdbc.internal.FormatStyle; +import org.springframework.context.annotation.Configuration; + +import java.util.Locale; + +@Configuration +public class P6SpySqlFormatter implements MessageFormattingStrategy { + + @PostConstruct + public void setLogMessageFormat() { + P6SpyOptions.getActiveInstance().setLogMessageFormat(this.getClass().getName()); + } + + @Override + public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared, String sql, String url) { + sql = formatSql(category, sql); + return String.format("[%s] | %d ms | %s", category, elapsed, highlight(formatSql(category, sql))); + } + + private String formatSql(String category, String sql) { + if (sql != null && !sql.trim().isEmpty() && Category.STATEMENT.getName().equals(category)) { + String trimmedSQL = sql.trim().toLowerCase(Locale.ROOT); + if (trimmedSQL.startsWith("create") || trimmedSQL.startsWith("alter") || trimmedSQL.startsWith("comment")) { + sql = FormatStyle.DDL.getFormatter().format(sql); + } else { + sql = FormatStyle.BASIC.getFormatter().format(sql); + } + return sql; + } + return sql; + } + + private String highlight(String sql) { + return FormatStyle.HIGHLIGHT.getFormatter().format(sql); + } +} diff --git a/backend/chatroom-service/src/main/resources/application.yml b/backend/chatroom-service/src/main/resources/application.yml new file mode 100644 index 0000000..0d408df --- /dev/null +++ b/backend/chatroom-service/src/main/resources/application.yml @@ -0,0 +1,24 @@ +server: + port: 8002 + +spring: + application: + name: chatroom-service + profiles: + active: local + cloud: + zookeeper: + connect-string: localhost:2181 + discovery: + enabled: true + + + +eureka: + instance: + instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} + client: + register-with-eureka: true + fetch-registry: true + service-url: + defaultZone: http://127.0.0.1:8761/eureka \ No newline at end of file diff --git a/backend/chatroom-service/src/test/java/com/tadak/chatroomservice/ChatroomServiceApplicationTests.java b/backend/chatroom-service/src/test/java/com/tadak/chatroomservice/ChatroomServiceApplicationTests.java new file mode 100644 index 0000000..9f8b419 --- /dev/null +++ b/backend/chatroom-service/src/test/java/com/tadak/chatroomservice/ChatroomServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.tadak.chatroomservice; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ChatroomServiceApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/chatting-service/.gitignore b/backend/chatting-service/.gitignore new file mode 100644 index 0000000..2ee0dff --- /dev/null +++ b/backend/chatting-service/.gitignore @@ -0,0 +1,44 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### macOS ### +.DS_store + +/src/main/resources/application-local.yml +application-local.properties \ No newline at end of file diff --git a/backend/chatting-service/build.gradle b/backend/chatting-service/build.gradle new file mode 100644 index 0000000..2f05c40 --- /dev/null +++ b/backend/chatting-service/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.1' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', "2023.0.0") +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.kafka:spring-kafka' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + runtimeOnly 'com.mysql:mysql-connector-j' +// implementation("org.springframework.cloud:spring-cloud-starter-zookeeper-discovery") + + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/chatting-service/gradle/wrapper/gradle-wrapper.jar b/backend/chatting-service/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/backend/chatting-service/gradle/wrapper/gradle-wrapper.properties b/backend/chatting-service/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/backend/chatting-service/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/chatting-service/gradlew b/backend/chatting-service/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/backend/chatting-service/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/chatting-service/gradlew.bat b/backend/chatting-service/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/backend/chatting-service/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/chatting-service/settings.gradle b/backend/chatting-service/settings.gradle new file mode 100644 index 0000000..cc8d0d9 --- /dev/null +++ b/backend/chatting-service/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'chatting-service' diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/ChattingServiceApplication.java b/backend/chatting-service/src/main/java/com/example/chattingservice/ChattingServiceApplication.java new file mode 100644 index 0000000..03c107f --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/ChattingServiceApplication.java @@ -0,0 +1,14 @@ +package com.example.chattingservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class ChattingServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ChattingServiceApplication.class, args); + } + +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/ChatController.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/ChatController.java new file mode 100644 index 0000000..c0937b9 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/ChatController.java @@ -0,0 +1,64 @@ +package com.example.chattingservice.domain.chat.controller; + +import com.example.chattingservice.domain.chat.dto.request.ChatRequest; +import com.example.chattingservice.domain.chat.dto.response.ChatEnterLeaveResponse; +import com.example.chattingservice.domain.chat.dto.response.ChatListResponse; +import com.example.chattingservice.domain.chat.service.ChatService; +import com.example.chattingservice.domain.chat.service.UserService; +import jakarta.validation.Valid; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ResponseBody; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + private final UserService userService; + + @MessageMapping("/chat/{roomId}/send-message") + public void sendMessage( + @Payload @Valid ChatRequest chatRequest, + @DestinationVariable("roomId") Long roomId + ) { + chatService.sendChat(chatRequest, roomId); + } + +// @MessageMapping("/chat/{roomId}/enter") +// @SendTo({"/topic/public/{roomId}"}) +// public ChatEnterLeaveResponse enter( +// @Payload @Valid ChatRequest chatRequest, +// @DestinationVariable("roomId") Long roomId, +// SimpMessageHeaderAccessor headerAccessor +// ) { +// +// headerAccessor.getSessionAttributes().put("username", chatRequest.getUsername()); +// headerAccessor.getSessionAttributes().put("roomId", roomId); +// userService.moveUser(chatRequest.getUsername(),roomId.toString()); +// return ChatEnterLeaveResponse.of(chatRequest, LocalDateTime.now()); +// } + + + + @GetMapping("/chat/{roomId}/messages") + @ResponseBody + public ResponseEntity getPrevChats(@PathVariable("roomId") Long roomId) { + + ChatListResponse chats = chatService.getChatsByRoomId(roomId); + + return ResponseEntity.status(HttpStatus.OK).body(chats); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/KafkaController.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/KafkaController.java new file mode 100644 index 0000000..83f3916 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/KafkaController.java @@ -0,0 +1,33 @@ +package com.example.chattingservice.domain.chat.controller; + +import com.example.chattingservice.domain.chat.service.ChatService; +import com.example.chattingservice.domain.chat.service.UserService; +import com.example.chattingservice.global.config.KafkaConsumerConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Controller; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class KafkaController { + + + private final UserService userService; + private final ChatService chatService; + public static final String REFRESH_LIST_TOPIC_NAME = "refresh"; + public static final String STATUS_TOPIC_NAME ="status-change"; + + @KafkaListener(topics=REFRESH_LIST_TOPIC_NAME,groupId = KafkaConsumerConfig.SOCKET_CONSUMER_ID) + public void refreshListener(String data) { + userService.refreshList(data); + } + + @KafkaListener(topics=ChatService.CHATTING_TOPIC_NAME,groupId = KafkaConsumerConfig.SOCKET_CONSUMER_ID) + public void chatListener(String data){ + System.out.println(data); + chatService.receiveChat(data); + } + +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/OnlineController.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/OnlineController.java new file mode 100644 index 0000000..9bd9f58 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/controller/OnlineController.java @@ -0,0 +1,24 @@ +package com.example.chattingservice.domain.chat.controller; + +import com.example.chattingservice.domain.chat.dto.request.UserInformationRequest; +import com.example.chattingservice.domain.chat.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.stereotype.Controller; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class OnlineController { + private final UserService userService; + + + @MessageMapping("/users") + public void showUsers(UserInformationRequest userInformationRequest, + SimpMessageHeaderAccessor headerAccessor) { + headerAccessor.getSessionAttributes().put("username",userInformationRequest.getUsername()); + userService.newUserLogin(userInformationRequest); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/ChatRequest.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/ChatRequest.java new file mode 100644 index 0000000..12ceac2 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/ChatRequest.java @@ -0,0 +1,19 @@ +package com.example.chattingservice.domain.chat.dto.request; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ChatRequest { + private String content; + private String username; + private Long userId; + private MessageType type; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/KafkaEnterChatRoomDto.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/KafkaEnterChatRoomDto.java new file mode 100644 index 0000000..5ff4618 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/KafkaEnterChatRoomDto.java @@ -0,0 +1,10 @@ +package com.example.chattingservice.domain.chat.dto.request; + + +import lombok.Getter; + +@Getter +public class KafkaEnterChatRoomDto { + private String roomName; + private String username; +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/MessageType.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/MessageType.java new file mode 100644 index 0000000..9512a78 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/MessageType.java @@ -0,0 +1,7 @@ +package com.example.chattingservice.domain.chat.dto.request; + +public enum MessageType { + CHAT, + JOIN, + LEAVE +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/UserInformationRequest.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/UserInformationRequest.java new file mode 100644 index 0000000..78144a8 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/request/UserInformationRequest.java @@ -0,0 +1,16 @@ +package com.example.chattingservice.domain.chat.dto.request; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class UserInformationRequest { + private String username; + private String roomName; +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatEnterLeaveResponse.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatEnterLeaveResponse.java new file mode 100644 index 0000000..94739a2 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatEnterLeaveResponse.java @@ -0,0 +1,27 @@ +package com.example.chattingservice.domain.chat.dto.response; + +import com.example.chattingservice.domain.chat.dto.request.ChatRequest; +import com.example.chattingservice.domain.chat.dto.request.MessageType; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ChatEnterLeaveResponse { + private String sender; + private MessageType type; + private LocalDateTime createdAt; + + public static ChatEnterLeaveResponse of(ChatRequest chatRequest, LocalDateTime createdAt) { + return ChatEnterLeaveResponse.builder() + .sender(chatRequest.getUsername()) + .type(chatRequest.getType()) + .createdAt(createdAt) + .build(); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatHistoryResponse.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatHistoryResponse.java new file mode 100644 index 0000000..d159cee --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatHistoryResponse.java @@ -0,0 +1,29 @@ +package com.example.chattingservice.domain.chat.dto.response; + +import com.example.chattingservice.domain.chat.entity.Chat; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ChatHistoryResponse { + private String id; + private String content; + private String sender; + private LocalDateTime createdAt; + + public static ChatHistoryResponse from(Chat chat) { + + return ChatHistoryResponse.builder() + .id(chat.getId()) + .content(chat.getContent()) + .sender(chat.getSender()) + .createdAt(chat.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatListResponse.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatListResponse.java new file mode 100644 index 0000000..5efa330 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatListResponse.java @@ -0,0 +1,23 @@ +package com.example.chattingservice.domain.chat.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ChatListResponse { + private List chatList; + private boolean hasNext; + + public static ChatListResponse of(List chats, boolean hasNext) { + return ChatListResponse.builder() + .chatList(chats) + .hasNext(hasNext) + .build(); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatResponse.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatResponse.java new file mode 100644 index 0000000..7fb1d35 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/ChatResponse.java @@ -0,0 +1,43 @@ +package com.example.chattingservice.domain.chat.dto.response; + +import com.example.chattingservice.domain.chat.dto.request.MessageType; +import com.example.chattingservice.domain.chat.entity.Chat; +import java.time.LocalDateTime; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ChatResponse { + private String content; + private String username; + private MessageType type; + private LocalDateTime createdAt; + private Long roomId; + + public static ChatResponse from(Chat chatEntity) { + MessageType type = MessageType.CHAT; + + return ChatResponse.builder() + .content(chatEntity.getContent()) + .username(chatEntity.getSender()) + .type(type) + .roomId(chatEntity.getRoomId()) + .createdAt(chatEntity.getCreatedAt()) + .build(); + } + public static ChatResponse fromObject(JsonNode object){ + return ChatResponse.builder() + .roomId(object.get("roomId").asLong()) + .username(object.get("username").asText()) + .type(MessageType.CHAT) + .content(object.get("content").asText()) + .createdAt(LocalDateTime.parse(object.get("createdAt").asText())) + .build(); + } +} \ No newline at end of file diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/UserParticipation.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/UserParticipation.java new file mode 100644 index 0000000..8879220 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/dto/response/UserParticipation.java @@ -0,0 +1,21 @@ +package com.example.chattingservice.domain.chat.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class UserParticipation { + private String username; + private String roomName; + private Long userId; + private String status; + + public void changeRoomName(String newRoomName){ + this.roomName = newRoomName; + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/Chat.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/Chat.java new file mode 100644 index 0000000..e536cb0 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/Chat.java @@ -0,0 +1,35 @@ +package com.example.chattingservice.domain.chat.entity; + +import com.example.chattingservice.domain.chat.dto.request.ChatRequest; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Document(collection = "ChatMessage") +public class Chat { + @Id + private String id; + private String content; + private String sender; + private Long roomId; + + private LocalDateTime createdAt; + + public static Chat toEntity(ChatRequest chatRequest, Long roomId) { + return Chat.builder() + .content(chatRequest.getContent()) + .sender(chatRequest.getUsername()) + .roomId(roomId) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/OnlineUser.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/OnlineUser.java new file mode 100644 index 0000000..9e5e7dd --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/entity/OnlineUser.java @@ -0,0 +1,15 @@ +package com.example.chattingservice.domain.chat.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.Getter; + +@Entity +@Getter +public class OnlineUser { + + @Id + private Long id; + private String username; + private String roomName; +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotFoundChatListException.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotFoundChatListException.java new file mode 100644 index 0000000..25259e1 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotFoundChatListException.java @@ -0,0 +1,10 @@ +package com.example.chattingservice.domain.chat.exception; + +import com.example.chattingservice.global.error.ErrorCode; +import com.example.chattingservice.global.error.common.BusinessException; + +public class NotFoundChatListException extends BusinessException { + public NotFoundChatListException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotSaveChatException.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotSaveChatException.java new file mode 100644 index 0000000..6ece892 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/NotSaveChatException.java @@ -0,0 +1,10 @@ +package com.example.chattingservice.domain.chat.exception; + +import com.example.chattingservice.global.error.ErrorCode; +import com.example.chattingservice.global.error.common.BusinessException; + +public class NotSaveChatException extends BusinessException { + public NotSaveChatException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/validation/Validation.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/validation/Validation.java new file mode 100644 index 0000000..6e1e626 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/exception/validation/Validation.java @@ -0,0 +1,22 @@ +package com.example.chattingservice.domain.chat.exception.validation; + +import com.example.chattingservice.domain.chat.entity.Chat; +import com.example.chattingservice.domain.chat.exception.NotFoundChatListException; +import com.example.chattingservice.domain.chat.exception.NotSaveChatException; +import com.example.chattingservice.global.error.ErrorCode; +import java.util.List; + +public class Validation { + + public static void isExistChatList(List chatList) { + if (chatList == null) { + throw new NotFoundChatListException(ErrorCode.NOT_FOUND_CHAT_LIST); + } + } + + public static void isSuccessSaveChat(Chat savedEntity) throws NotSaveChatException { + if (savedEntity == null) { + throw new NotSaveChatException(ErrorCode.FAIL_TO_SAVE_CHAT); + } + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/ChatRepository.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/ChatRepository.java new file mode 100644 index 0000000..a254b82 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/ChatRepository.java @@ -0,0 +1,10 @@ +package com.example.chattingservice.domain.chat.repository; + +import com.example.chattingservice.domain.chat.entity.Chat; +import java.util.List; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ChatRepository extends MongoRepository { + + List findByRoomId(Long roomId); +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/OnlineUserRepository.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/OnlineUserRepository.java new file mode 100644 index 0000000..df2d0b5 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/repository/OnlineUserRepository.java @@ -0,0 +1,7 @@ +package com.example.chattingservice.domain.chat.repository; + +import com.example.chattingservice.domain.chat.entity.OnlineUser; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OnlineUserRepository extends JpaRepository { +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/ChatService.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/ChatService.java new file mode 100644 index 0000000..3f40d7c --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/ChatService.java @@ -0,0 +1,69 @@ +package com.example.chattingservice.domain.chat.service; + + +import com.example.chattingservice.domain.chat.dto.request.ChatRequest; +import com.example.chattingservice.domain.chat.dto.response.ChatHistoryResponse; +import com.example.chattingservice.domain.chat.dto.response.ChatListResponse; +import com.example.chattingservice.domain.chat.dto.response.ChatResponse; +import com.example.chattingservice.domain.chat.entity.Chat; +import com.example.chattingservice.domain.chat.exception.validation.Validation; +import com.example.chattingservice.domain.chat.repository.ChatRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ChatService { + private final KafkaTemplate kafkaTemplate; + private final SimpMessagingTemplate messagingTemplate; + private final ObjectMapper objectMapper; + private final ChatRepository chatRepository; + private final KafkaService kafkaService; + public static final String CHATTING_TOPIC_NAME = "chatting"; + + public void sendChat(ChatRequest chatRequest, Long roomId){ + Chat chatEntity = Chat.toEntity(chatRequest, roomId); +// Chat savedEntity = chatRepository.save(chatEntity); +// Validation.isSuccessSaveChat(savedEntity); + try{ + String objectToString = objectMapper.writeValueAsString(ChatResponse.from(chatEntity)); + kafkaService.sendMessageToAllPartitionsWithoutKey(CHATTING_TOPIC_NAME,objectToString); + }catch (Exception e){ + throw new IllegalArgumentException(); + } + + } + public void receiveChat(String data){ + try{ + JsonNode object = objectMapper.readTree(data); + ChatResponse chatResponse = ChatResponse.fromObject(object); + messagingTemplate.convertAndSend("/topic/public/"+chatResponse.getRoomId(),chatResponse); + } + catch (Exception e){ + e.printStackTrace(); + throw new IllegalArgumentException(); + } + } + public ChatListResponse getChatsByRoomId(Long roomId) { + List chatList = chatRepository.findByRoomId(roomId); + + Validation.isExistChatList(chatList); + + List chats = chatList.stream() + .map(ChatHistoryResponse::from) + .collect(Collectors.toList()); + + boolean hasNext = false; + + return ChatListResponse.of(chats, hasNext); + } + + +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/KafkaService.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/KafkaService.java new file mode 100644 index 0000000..a00940b --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/KafkaService.java @@ -0,0 +1,30 @@ +package com.example.chattingservice.domain.chat.service; + +import com.example.chattingservice.domain.chat.dto.response.UserParticipation; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.kafka.common.PartitionInfo; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class KafkaService { + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + public void sendMessageToAllPartitionsWithoutKey(String topic, String message){ + List partitionInfos = kafkaTemplate.partitionsFor(topic); + for(PartitionInfo partition : partitionInfos) kafkaTemplate.send(topic,partition.partition(),null,message); + } + public void sendWithUsernameByKeyToSessionServerWhenLogin(String topic, UserParticipation userParticipation){ + try{ + String stringToJson = objectMapper.writeValueAsString(userParticipation); + kafkaTemplate.send(topic, userParticipation.getUsername(),stringToJson); + }catch (Exception e){ + e.printStackTrace(); + } + + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/UserService.java b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/UserService.java new file mode 100644 index 0000000..449cd0d --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/domain/chat/service/UserService.java @@ -0,0 +1,61 @@ +package com.example.chattingservice.domain.chat.service; + +import com.example.chattingservice.domain.chat.controller.KafkaController; +import com.example.chattingservice.domain.chat.dto.request.UserInformationRequest; +import com.example.chattingservice.domain.chat.dto.response.UserParticipation; +import com.example.chattingservice.domain.chat.entity.OnlineUser; +import com.example.chattingservice.domain.chat.repository.OnlineUserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UserService { + + private final SimpMessagingTemplate template; + private final KafkaService kafkaService; + private final ObjectMapper objectMapper; + private final OnlineUserRepository onlineUserRepository; + + public void newUserLogin(UserInformationRequest userInformationRequest){ + + UserParticipation newUser = UserParticipation.builder() + .userId(0l) + .username(userInformationRequest.getUsername()) + .roomName(userInformationRequest.getRoomName()) + .status("login") + .build(); + try{ + System.out.println("로그인 완료"); + kafkaService.sendWithUsernameByKeyToSessionServerWhenLogin(KafkaController.STATUS_TOPIC_NAME,newUser); + }catch (Exception e){ + throw new IllegalArgumentException(); + } + } + public void removeUserWhenSocketDisabled(String username){ + UserParticipation newUser = UserParticipation.builder() + .userId(0l) + .username(username) + .roomName(null) + .status("exit") + .build(); + try{ + kafkaService.sendWithUsernameByKeyToSessionServerWhenLogin(KafkaController.STATUS_TOPIC_NAME,newUser); + }catch (Exception e){ + throw new IllegalArgumentException(); + } + } + public void refreshList(String data) { + List onlineUsers = onlineUserRepository.findAll(); + for(OnlineUser onlineUser: onlineUsers){ + System.out.println(onlineUser.getUsername()+"/"+onlineUser.getRoomName()); + } + template.convertAndSend("/topic/users",onlineUsers); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaConsumerConfig.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..38519a8 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaConsumerConfig.java @@ -0,0 +1,38 @@ +package com.example.chattingservice.global.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConsumerConfig { + public static final String SOCKET_CONSUMER_ID = "chatting-consumer"; + + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); //kafka 돌아가는 포트 + config.put(ConsumerConfig.GROUP_ID_CONFIG, SOCKET_CONSUMER_ID); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + + return factory; + } + +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaProducerConfig.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaProducerConfig.java new file mode 100644 index 0000000..32e97eb --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/KafkaProducerConfig.java @@ -0,0 +1,29 @@ +package com.example.chattingservice.global.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaProducerConfig { + @Bean + public ProducerFactory producerFactory() { + Map properties = new HashMap<>(); + properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:9092"); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return new DefaultKafkaProducerFactory<>(properties); + } + @Bean + public KafkaTemplate kafkaObjectTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketConfig.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketConfig.java new file mode 100644 index 0000000..aeb6173 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketConfig.java @@ -0,0 +1,24 @@ +package com.example.chattingservice.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOrigins("http://localhost:5173"); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); + registry.enableSimpleBroker("/topic"); + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketEventListener.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketEventListener.java new file mode 100644 index 0000000..dcf6064 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/config/WebSocketEventListener.java @@ -0,0 +1,29 @@ +package com.example.chattingservice.global.config; + +import com.example.chattingservice.domain.chat.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketEventListener { + + private final SimpMessageSendingOperations messageTemplate; + private final UserService userService; + + @EventListener + public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + String username = (String) headerAccessor.getSessionAttributes().get("username"); + + if (username != null) { + userService.removeUserWhenSocketDisabled(username); + } + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorCode.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorCode.java new file mode 100644 index 0000000..b8592ce --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorCode.java @@ -0,0 +1,54 @@ +package com.example.chattingservice.global.error; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + + BAD_REQUEST_ERROR(400, "G001", "Bad Request Exception"), + REQUEST_BODY_MISSING_ERROR(400, "G002", "Required request body is missing"), + MISSING_REQUEST_PARAMETER_ERROR(400, "G004", "Missing Servlet RequestParameter Exception"), + NOT_FOUND_ERROR(404, "G009", "Not Found Exception"), + NULL_POINT_ERROR(404, "G010", "Null Point Exception"), + NOT_VALID_ERROR(404, "G011", "handle Validation Exception"), + INTERNAL_SERVER_ERROR(500, "G999", "Internal Server Error Exception"), + + /** + * Token Error 1000 ~ 1099 + */ + NOT_FOUND_TOKEN_ERROR(1000, "G1000", "현재 유효한 Token 권한 정보가 존재하지 않습니다."), + NOT_VALID_ACCESS_TOKEN_ERROR(1001, "G1000", "만료된 Token 값 입니다."), + NOT_VALID_REFRESH_TOKEN_ERROR(1002, "G1000", "현재 최신 refreshToken을 소지하고 있지 않습니다."), + + /** + * Member Error 1100 ~ 1199 + */ + NOT_FOUND_MEMBER_ERROR(1100, "G1100", "현재 해당 member가 존재하지 않습니다."), + + /** + * login error 1200 ~ 1299 + */ + NOT_MATCH_LOGIN_ERROR(1200, "G1200", "로그인 정보가 일치하지 않습니다."), + + /** + * Binding Error 1400 ~ 1499 + */ + INVALID_INPUT_VALUE(1400, "G1400", "잘못된 입력 값 입니다."), + + /** + * Chatting Error 2000 ~ 2099 + */ + + FAIL_TO_SAVE_CHAT(2000, "G2000", "채팅 정보를 저장하는데 실패하였습니다."), + NOT_FOUND_CHAT_LIST(2001, "G2001", "채팅 메시지를 불러오는데 실패했습니다."); + + private final int status; + private final String code; + private final String message; + + ErrorCode(final int status, final String code, final String message) { + this.status = status; + this.code = code; + this.message = message; + } +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorResponse.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorResponse.java new file mode 100644 index 0000000..82fc459 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/ErrorResponse.java @@ -0,0 +1,85 @@ +package com.example.chattingservice.global.error; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.validation.BindingResult; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ErrorResponse { + + private int status; // 에러 상태 코드 + private String code; // 에러 구분 코드 + private String message; // 에러 메시지 + + @Builder + protected ErrorResponse(final ErrorCode code) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + @Builder + protected ErrorResponse(final ErrorCode code, final String reason) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + @Builder + protected ErrorResponse(final ErrorCode code, final List errors) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + public static ErrorResponse of(final ErrorCode code, final BindingResult bindingResult) { + return new ErrorResponse(code, FieldError.of(bindingResult)); + } + + public static ErrorResponse of(final ErrorCode code) { + return new ErrorResponse(code); + } + + public static ErrorResponse of(final ErrorCode code, final String reason) { + return new ErrorResponse(code, reason); + } + + /** + * e.getBindingResult() 형태로 전달받는 error 처리하여 변경 + */ + @Getter + public static class FieldError { + private final String field; + private final String value; + private final String reason; + + public static List of(final String field, final String value, final String reason) { + List fieldErrors = new ArrayList<>(); + fieldErrors.add(new FieldError(field, value, reason)); + return fieldErrors; + } + + private static List of(final BindingResult bindingResult) { + final List fieldErrors = bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + + @Builder + FieldError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + } +} \ No newline at end of file diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/GlobalExceptionHandler.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..2364057 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/GlobalExceptionHandler.java @@ -0,0 +1,111 @@ +package com.example.chattingservice.global.error; + +import com.example.chattingservice.global.error.common.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.servlet.NoHandlerFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * API 호출 시 객체 혹은 파라미터의 값이 제대로 되지 않을 경우 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException: {}", e); + BindingResult bindingResult = e.getBindingResult(); + StringBuilder stringBuilder = new StringBuilder(); + for (FieldError fieldError : bindingResult.getFieldErrors()) { + stringBuilder.append(fieldError.getField()).append(":"); + stringBuilder.append(fieldError.getDefaultMessage()); + stringBuilder.append(", "); + } + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_VALID_ERROR, String.valueOf(stringBuilder)); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + /** + * Body 부분에 객체 데이터가 넘어 오지 않았을 경우 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("HttpMessageNotReadableException: {}", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.REQUEST_BODY_MISSING_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * 클라이언트에서 body로 객체 데이터가 넘어오지 않을 경우 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity handleMissingRequestHeaderExceptionException( + MissingServletRequestParameterException e) { + log.error("handleMissingServletRequestParameterException: {}", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.MISSING_REQUEST_PARAMETER_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * 잘못된 서버 요청일 경우 + */ + @ExceptionHandler(HttpClientErrorException.BadRequest.class) + protected ResponseEntity handleBadRequestException(HttpClientErrorException e) { + log.error("HttpClientErrorException.BadRequest: {}", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.BAD_REQUEST_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + /** + * 잘못된 주소로 요청한 경우 + */ + @ExceptionHandler(NoHandlerFoundException.class) + protected ResponseEntity handleNoHandlerFoundExceptionException(NoHandlerFoundException e) { + log.error("handleNoHandlerFoundExceptionException: {}", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_FOUND_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + /** + * null 값이 발생한 경우 + */ + @ExceptionHandler(NullPointerException.class) + protected ResponseEntity handleNullPointerException(NullPointerException e) { + log.error("handleNullPointerException: {}", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NULL_POINT_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + /** + * Custom Exception + */ + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleBusinessException(final BusinessException ex) { + log.error("handleBusinessException", ex); + final ErrorCode errorCode = ex.getErrorCode(); + final ErrorResponse response = ErrorResponse.of(errorCode); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + /** + * binding Exception + */ + @ExceptionHandler(BindException.class) + protected ResponseEntity handleBindException(BindException ex) { + log.error("handleBindException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, ex.getBindingResult()); + return new ResponseEntity<>(response, HttpStatus.OK); + } + +} diff --git a/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/common/BusinessException.java b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/common/BusinessException.java new file mode 100644 index 0000000..f760328 --- /dev/null +++ b/backend/chatting-service/src/main/java/com/example/chattingservice/global/error/common/BusinessException.java @@ -0,0 +1,22 @@ +package com.example.chattingservice.global.error.common; + +import com.example.chattingservice.global.error.ErrorCode; + +public class BusinessException extends RuntimeException{ + + private ErrorCode errorCode; + + public BusinessException(String message, ErrorCode errorCode) { + super(message); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/backend/chatting-service/src/main/resources/application.yml b/backend/chatting-service/src/main/resources/application.yml new file mode 100644 index 0000000..d7225fa --- /dev/null +++ b/backend/chatting-service/src/main/resources/application.yml @@ -0,0 +1,34 @@ +server: + port: 8010 +spring: + data: + redis: + port: 6379 + host: localhost + profiles: + active: local + application: + name: chatting + cloud: + zookeeper: + connect-string: localhost:22181 + discovery: + enabled: true +logging: + level: + org.apache.zookeeper.ClientCnxn: WARN + +#spring: +# application: +# name: chatting-service +# profiles: +# active: local +# +#eureka: +## instance: +## instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}} +# client:ㅎ +# register-with-eureka: true +# fetch-registry: true +# service-url: +# defaultZone: http://127.0.0.1:8761/eureka \ No newline at end of file diff --git a/backend/chatting-service/src/main/resources/docker-compose-kafka-ui.yml b/backend/chatting-service/src/main/resources/docker-compose-kafka-ui.yml new file mode 100644 index 0000000..5579dd9 --- /dev/null +++ b/backend/chatting-service/src/main/resources/docker-compose-kafka-ui.yml @@ -0,0 +1,12 @@ +version: '2' +services: + kafka-ui: + image: provectuslabs/kafka-ui + container_name: kafka-ui + ports: + - "8989:8081" + restart: always + environment: + - KAFKA_CLUSTERS_0_NAME=local + - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka:9092 + - KAFKA_CLUSTERS_0_ZOOKEEPER=zookeeper-1:22181 \ No newline at end of file diff --git a/backend/chatting-service/src/main/resources/docker-compose-single.yml b/backend/chatting-service/src/main/resources/docker-compose-single.yml new file mode 100644 index 0000000..588cec2 --- /dev/null +++ b/backend/chatting-service/src/main/resources/docker-compose-single.yml @@ -0,0 +1,41 @@ +version: '2' + +services: + zookeeper: + image: confluentinc/cp-zookeeper:latest + environment: + ZOOKEEPER_SERVER_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2181 #내부에서 2181포트에서 돌아감 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_INIT_LIMIT: 5 + ZOOKEEPER_SYNC_LIMIT: 2 + ports: + - "2181:2181" + + kafka: + image: confluentinc/cp-kafka:latest + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' # 22181:2181과 같음 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 #OFFSET 시작 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_NUM_PARTITIONS: 2 + + + kafka-ui: + image: provectuslabs/kafka-ui + container_name: kafka-ui + ports: + - "8989:8081" + restart: always + environment: + - KAFKA_CLUSTERS_0_NAME=local + - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS=kafka:29092 + - KAFKA_CLUSTERS_0_ZOOKEEPER=zookeeper:2181 diff --git a/backend/chatting-service/src/main/resources/docker-compose.yaml b/backend/chatting-service/src/main/resources/docker-compose.yaml new file mode 100644 index 0000000..22e314a --- /dev/null +++ b/backend/chatting-service/src/main/resources/docker-compose.yaml @@ -0,0 +1,57 @@ +version: '3.8' +services: + zookeeper-1: + image: wurstmeister/zookeeper + ports: + - '32181:32181' + environment: + ZOOKEEPER_CLIENT_PORT: 32181 + ZOOKEEPER_TICK_TIME: 2000 + + + kafka-1: + image: wurstmeister/kafka + ports: + - '9092:9092' + depends_on: + - zookeeper-1 + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper-1:32181 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka-1:29092,EXTERNAL://localhost:9092 + KAFKA_DEFAULT_REPLICATION_FACTOR: 3 + KAFKA_NUM_PARTITIONS: 3 + + + kafka-2: + image: wurstmeister/kafka + ports: + - '9093:9093' + depends_on: + - zookeeper-1 + environment: + KAFKA_BROKER_ID: 2 + KAFKA_ZOOKEEPER_CONNECT: zookeeper-1:32181 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka-2:29093,EXTERNAL://localhost:9093 + KAFKA_DEFAULT_REPLICATION_FACTOR: 3 + KAFKA_NUM_PARTITIONS: 3 + + + kafka-3: + image: wurstmeister/kafka + ports: + - '9094:9094' + depends_on: + - zookeeper-1 + environment: + KAFKA_BROKER_ID: 3 + KAFKA_ZOOKEEPER_CONNECT: zookeeper-1:32181 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka-3:29094,EXTERNAL://localhost:9094 + KAFKA_DEFAULT_REPLICATION_FACTOR: 3 + KAFKA_NUM_PARTITIONS: 3 \ No newline at end of file diff --git a/backend/chatting-service/src/main/resources/static/css/main.css b/backend/chatting-service/src/main/resources/static/css/main.css new file mode 100644 index 0000000..83461b6 --- /dev/null +++ b/backend/chatting-service/src/main/resources/static/css/main.css @@ -0,0 +1,287 @@ +* { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +html,body { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + padding: 0; + font-weight: 400; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1rem; + line-height: 1.58; + color: #333; + background-color: #f4f4f4; + height: 100%; +} + +.clearfix:after { + display: block; + content: ""; + clear: both; +} + +.hidden { + display: none; +} + +.form-control { + width: 100%; + min-height: 38px; + font-size: 15px; + border: 1px solid #c8c8c8; +} + +.form-group { + margin-bottom: 15px; +} + +input { + padding-left: 10px; + outline: none; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 20px; + margin-bottom: 20px; +} + +h1 { + font-size: 1.7em; +} + +a { + color: #6db33f; +} + +button { + box-shadow: none; + border: 1px solid transparent; + font-size: 14px; + outline: none; + line-height: 100%; + white-space: nowrap; + vertical-align: middle; + padding: 0.6rem 1rem; + border-radius: 2px; + transition: all 0.2s ease-in-out; + cursor: pointer; + min-height: 38px; +} + +button.default { + background-color: #e8e8e8; + color: #333; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); +} + +button.primary { + background-color: #6db33f; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); + color: #fff; +} + +button.accent { + background-color: #6db33f; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); + color: #fff; +} + +#username-page { + text-align: center; +} + +.username-page-container { + background: #fff; + box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); + border-radius: 2px; + width: 100%; + max-width: 500px; + display: inline-block; + margin-top: 42px; + vertical-align: middle; + position: relative; + padding: 35px 55px 35px; + min-height: 250px; + position: absolute; + top: 50%; + left: 0; + right: 0; + margin: 0 auto; + margin-top: -160px; +} + +.username-page-container .username-submit { + margin-top: 10px; +} + + +#chat-page { + position: relative; + height: 100%; +} + +.chat-container { + max-width: 700px; + margin-left: auto; + margin-right: auto; + background-color: #fff; + box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); + margin-top: 30px; + height: calc(100% - 60px); + max-height: 600px; + position: relative; +} + +#chat-page ul { + list-style-type: none; + background-color: #FFF; + margin: 0; + overflow: auto; + overflow-y: scroll; + padding: 0 20px 0px 20px; + height: calc(100% - 150px); +} + +#chat-page #messageForm { + padding: 20px; +} + +#chat-page ul li { + line-height: 1.5rem; + padding: 10px 20px; + margin: 0; + border-bottom: 1px solid #f4f4f4; +} + +#chat-page ul li p { + margin: 0; +} + +#chat-page .event-message { + width: 100%; + text-align: center; + clear: both; +} + +#chat-page .event-message p { + color: #777; + font-size: 14px; + word-wrap: break-word; +} + +#chat-page .chat-message { + padding-left: 68px; + position: relative; +} + +#chat-page .chat-message i { + position: absolute; + width: 42px; + height: 42px; + overflow: hidden; + left: 10px; + display: inline-block; + vertical-align: middle; + font-size: 18px; + line-height: 42px; + color: #fff; + text-align: center; + border-radius: 50%; + font-style: normal; + text-transform: uppercase; +} + +#chat-page .chat-message span { + color: #333; + font-weight: 600; +} + +#chat-page .chat-message p { + color: #43464b; +} + +#messageForm .input-group input { + float: left; + width: calc(100% - 85px); +} + +#messageForm .input-group button { + float: left; + width: 80px; + height: 38px; + margin-left: 5px; +} + +.chat-header { + text-align: center; + padding: 15px; + border-bottom: 1px solid #ececec; +} + +.chat-header h2 { + margin: 0; + font-weight: 500; +} + +.connecting { + padding-top: 5px; + text-align: center; + color: #777; + position: absolute; + top: 65px; + width: 100%; +} + + +@media screen and (max-width: 730px) { + + .chat-container { + margin-left: 10px; + margin-right: 10px; + margin-top: 10px; + } +} + +@media screen and (max-width: 480px) { + .chat-container { + height: calc(100% - 30px); + } + + .username-page-container { + width: auto; + margin-left: 15px; + margin-right: 15px; + padding: 25px; + } + + #chat-page ul { + height: calc(100% - 120px); + } + + #messageForm .input-group button { + width: 65px; + } + + #messageForm .input-group input { + width: calc(100% - 70px); + } + + .chat-header { + padding: 10px; + } + + .connecting { + top: 60px; + } + + .chat-header h2 { + font-size: 1.1em; + } +} \ No newline at end of file diff --git a/backend/chatting-service/src/main/resources/static/index.html b/backend/chatting-service/src/main/resources/static/index.html new file mode 100644 index 0000000..97ad582 --- /dev/null +++ b/backend/chatting-service/src/main/resources/static/index.html @@ -0,0 +1,53 @@ + + + + + Spring Boot WebSocket Chat Application + + + +

Sorry! Your browser doesn't support Javascript

+ + +
+
+

Type your username to enter the Chatroom

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/chatting-service/src/main/resources/static/js/main.js b/backend/chatting-service/src/main/resources/static/js/main.js new file mode 100644 index 0000000..fff3bb7 --- /dev/null +++ b/backend/chatting-service/src/main/resources/static/js/main.js @@ -0,0 +1,118 @@ +'use strict'; + +var usernamePage = document.querySelector('#username-page'); +var chatPage = document.querySelector('#chat-page'); +var usernameForm = document.querySelector('#usernameForm'); +var messageForm = document.querySelector('#messageForm'); +var messageInput = document.querySelector('#message'); +var messageArea = document.querySelector('#messageArea'); +var connectingElement = document.querySelector('.connecting'); + +var stompClient = null; +var username = null; + +var colors = [ + '#2196F3', '#32c787', '#00BCD4', '#ff5652', + '#ffc107', '#ff85af', '#FF9800', '#39bbb0' +]; + +function connect(event) { + username = document.querySelector('#name').value.trim(); + + if(username) { + usernamePage.classList.add('hidden'); + chatPage.classList.remove('hidden'); + + var socket = new SockJS('/ws'); + stompClient = Stomp.over(socket); + + stompClient.connect({}, onConnected, onError); + } + event.preventDefault(); +} + + +function onConnected() { + // Subscribe to the Public Topic + stompClient.subscribe('/topic/public/5', onMessageReceived); + + // Tell your username to the server + stompClient.send("/app/chat/5/enter", + {}, + JSON.stringify({sender: username, type: 'JOIN'}) + ) + + connectingElement.classList.add('hidden'); +} + + +function onError(error) { + connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!'; + connectingElement.style.color = 'red'; +} + + +function sendMessage(event) { + var messageContent = messageInput.value.trim(); + if(messageContent && stompClient) { + var chatMessage = { + sender: username, + content: messageInput.value, + type: 'CHAT' + }; + stompClient.send("/app/chat/5/send-message", {}, JSON.stringify(chatMessage)); + messageInput.value = ''; + } + event.preventDefault(); +} + + +function onMessageReceived(payload) { + var message = JSON.parse(payload.body); + + var messageElement = document.createElement('li'); + + if(message.type === 'JOIN') { + messageElement.classList.add('event-message'); + message.content = message.sender + ' joined!'; + } else if (message.type === 'LEAVE') { + messageElement.classList.add('event-message'); + message.content = message.sender + ' left!'; + } else { + messageElement.classList.add('chat-message'); + + var avatarElement = document.createElement('i'); + var avatarText = document.createTextNode(message.sender[0]); + avatarElement.appendChild(avatarText); + avatarElement.style['background-color'] = getAvatarColor(message.sender); + + messageElement.appendChild(avatarElement); + + var usernameElement = document.createElement('span'); + var usernameText = document.createTextNode(message.sender); + usernameElement.appendChild(usernameText); + messageElement.appendChild(usernameElement); + } + + var textElement = document.createElement('p'); + var messageText = document.createTextNode(message.content); + textElement.appendChild(messageText); + + messageElement.appendChild(textElement); + + messageArea.appendChild(messageElement); + messageArea.scrollTop = messageArea.scrollHeight; +} + + +function getAvatarColor(messageSender) { + var hash = 0; + for (var i = 0; i < messageSender.length; i++) { + hash = 31 * hash + messageSender.charCodeAt(i); + } + var index = Math.abs(hash % colors.length); + return colors[index]; +} + +usernameForm.addEventListener('submit', connect, true) +messageForm.addEventListener('submit', sendMessage, true) \ No newline at end of file diff --git a/backend/chatting-service/src/test/java/com/example/chattingservice/ChattingServiceApplicationTests.java b/backend/chatting-service/src/test/java/com/example/chattingservice/ChattingServiceApplicationTests.java new file mode 100644 index 0000000..71a11e0 --- /dev/null +++ b/backend/chatting-service/src/test/java/com/example/chattingservice/ChattingServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.chattingservice; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ChattingServiceApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/session-service/.gitignore b/backend/session-service/.gitignore new file mode 100644 index 0000000..950854b --- /dev/null +++ b/backend/session-service/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +application-local.yml +application-local.properties \ No newline at end of file diff --git a/backend/session-service/build.gradle b/backend/session-service/build.gradle new file mode 100644 index 0000000..b547030 --- /dev/null +++ b/backend/session-service/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.2' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.underdog' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} +ext { + set('springCloudVersion', "2023.0.0") +} + + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.kafka:spring-kafka' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.kafka:spring-kafka-test' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation "org.springframework.boot:spring-boot-starter-jdbc" + implementation("org.springframework.cloud:spring-cloud-starter-zookeeper-discovery") + +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/session-service/gradle/wrapper/gradle-wrapper.jar b/backend/session-service/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/backend/session-service/gradle/wrapper/gradle-wrapper.properties b/backend/session-service/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/backend/session-service/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/session-service/gradlew b/backend/session-service/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/backend/session-service/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/session-service/gradlew.bat b/backend/session-service/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/backend/session-service/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/session-service/settings.gradle b/backend/session-service/settings.gradle new file mode 100644 index 0000000..ade3347 --- /dev/null +++ b/backend/session-service/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'session' diff --git a/backend/session-service/src/main/java/com/underdog/session/SessionApplication.java b/backend/session-service/src/main/java/com/underdog/session/SessionApplication.java new file mode 100644 index 0000000..75e7b2d --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/SessionApplication.java @@ -0,0 +1,13 @@ +package com.underdog.session; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SessionApplication { + + public static void main(String[] args) { + SpringApplication.run(SessionApplication.class, args); + } + +} diff --git a/backend/session-service/src/main/java/com/underdog/session/config/KafkaConsumerConfig.java b/backend/session-service/src/main/java/com/underdog/session/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..514bec8 --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/config/KafkaConsumerConfig.java @@ -0,0 +1,34 @@ +package com.underdog.session.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConsumerConfig { + public static final String STATUS_CONSUMER_ID = "status-consumer"; + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); //kafka 돌아가는 포트 + config.put(ConsumerConfig.GROUP_ID_CONFIG, STATUS_CONSUMER_ID); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + + return factory; + } +} diff --git a/backend/session-service/src/main/java/com/underdog/session/config/KafkaProducerConfig.java b/backend/session-service/src/main/java/com/underdog/session/config/KafkaProducerConfig.java new file mode 100644 index 0000000..8c31ec8 --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/config/KafkaProducerConfig.java @@ -0,0 +1,31 @@ +package com.underdog.session.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaProducerConfig { + @Bean + public ProducerFactory producerFactory() { + Map properties = new HashMap<>(); + properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:9092"); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + + return new DefaultKafkaProducerFactory<>(properties); + } + + + @Bean + public KafkaTemplate kafkaObjectTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/backend/session-service/src/main/java/com/underdog/session/controller/StatusListener.java b/backend/session-service/src/main/java/com/underdog/session/controller/StatusListener.java new file mode 100644 index 0000000..8f7548e --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/controller/StatusListener.java @@ -0,0 +1,19 @@ +package com.underdog.session.controller; + +import com.underdog.session.config.KafkaConsumerConfig; +import com.underdog.session.service.StatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class StatusListener { + private final StatusService statusService; + public static final String STATUS_TOPIC_NAME ="status-change"; + @KafkaListener(topics =STATUS_TOPIC_NAME, groupId = KafkaConsumerConfig.STATUS_CONSUMER_ID) + public void changeStatus(String data){ + statusService.changeStatus(data); + } + +} diff --git a/backend/session-service/src/main/java/com/underdog/session/controller/ZookeeperController.java b/backend/session-service/src/main/java/com/underdog/session/controller/ZookeeperController.java new file mode 100644 index 0000000..6709a67 --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/controller/ZookeeperController.java @@ -0,0 +1,23 @@ +package com.underdog.session.controller; + +import com.underdog.session.dto.ServerInfoDto; +import com.underdog.session.service.ZookeeperService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class ZookeeperController { + + private final ZookeeperService zookeeperService; + + @GetMapping("get-chatting-port") + public ServerInfoDto getChattingPort(){ + return zookeeperService.getPort(); + } + +} diff --git a/backend/session-service/src/main/java/com/underdog/session/domain/OnlineUser.java b/backend/session-service/src/main/java/com/underdog/session/domain/OnlineUser.java new file mode 100644 index 0000000..b9fbb02 --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/domain/OnlineUser.java @@ -0,0 +1,25 @@ +package com.underdog.session.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.*; + +import javax.annotation.processing.Generated; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Entity +public class OnlineUser { + @Id @GeneratedValue + private Long id; + + private String username; + private String roomName; + + public void setRoomName(String newRoomName){ + this.roomName = newRoomName; + } +} diff --git a/backend/session-service/src/main/java/com/underdog/session/dto/ServerInfoDto.java b/backend/session-service/src/main/java/com/underdog/session/dto/ServerInfoDto.java new file mode 100644 index 0000000..645ed7e --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/dto/ServerInfoDto.java @@ -0,0 +1,10 @@ +package com.underdog.session.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ServerInfoDto { + private int port; +} diff --git a/backend/session-service/src/main/java/com/underdog/session/repository/StatusRepository.java b/backend/session-service/src/main/java/com/underdog/session/repository/StatusRepository.java new file mode 100644 index 0000000..264beb2 --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/repository/StatusRepository.java @@ -0,0 +1,14 @@ +package com.underdog.session.repository; + +import com.underdog.session.domain.OnlineUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface StatusRepository extends JpaRepository { + Optional findOnlineUserByUsername(@Param("username") String username); +} diff --git a/backend/session-service/src/main/java/com/underdog/session/service/KafkaService.java b/backend/session-service/src/main/java/com/underdog/session/service/KafkaService.java new file mode 100644 index 0000000..0729e2c --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/service/KafkaService.java @@ -0,0 +1,19 @@ +package com.underdog.session.service; + +import lombok.RequiredArgsConstructor; +import org.apache.kafka.common.PartitionInfo; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class KafkaService { + private final KafkaTemplate kafkaTemplate; + + public void sendMessageToAllPartitionsWithoutKey(String topic, String message){ + List partitionInfos = kafkaTemplate.partitionsFor(topic); + for(PartitionInfo partition : partitionInfos) kafkaTemplate.send(topic,partition.partition(),null,message); + } +} diff --git a/backend/session-service/src/main/java/com/underdog/session/service/StatusService.java b/backend/session-service/src/main/java/com/underdog/session/service/StatusService.java new file mode 100644 index 0000000..df3d828 --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/service/StatusService.java @@ -0,0 +1,59 @@ +package com.underdog.session.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.underdog.session.domain.OnlineUser; +import com.underdog.session.repository.StatusRepository; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class StatusService { + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + private final StatusRepository statusRepository; + private final KafkaService kafkaService; + private static final String REFRESH_LIST_TOPIC_NAME = "refresh"; + + public void changeStatus(String data) { + JsonNode object; + try{ + object = objectMapper.readTree(data); + } + catch (Exception e){ + e.printStackTrace(); + throw new IllegalArgumentException(); + } + System.out.println(object); + String username = object.get("username").asText(); + String roomName = object.get("roomName").asText(); + String status = object.get("status").asText(); + Optional onlineUser = statusRepository.findOnlineUserByUsername(username); + if(onlineUser.isPresent()){ //db에 존재하는 User라면 + OnlineUser onlineUser1 = onlineUser.get(); + if(status.equals("exit")) statusRepository.delete(onlineUser1); + if(status.equals("login")) onlineUser1.setRoomName(roomName); + if(status.equals("enter")) onlineUser1.setRoomName(roomName); + statusRepository.save(onlineUser1); + } + if(!onlineUser.isPresent()){ + if(status.equals("exit")) return; + OnlineUser newOnlineUser = OnlineUser.builder() + .roomName(roomName) + .username(username) + .build(); + if(status.equals("enter")) newOnlineUser.setRoomName(roomName); + statusRepository.save(newOnlineUser); + } + kafkaService.sendMessageToAllPartitionsWithoutKey(REFRESH_LIST_TOPIC_NAME,"change"); + // 모든 파티션에 리스트 새로 발행하라고 전송 + } +} diff --git a/backend/session-service/src/main/java/com/underdog/session/service/ZookeeperService.java b/backend/session-service/src/main/java/com/underdog/session/service/ZookeeperService.java new file mode 100644 index 0000000..1545938 --- /dev/null +++ b/backend/session-service/src/main/java/com/underdog/session/service/ZookeeperService.java @@ -0,0 +1,27 @@ +package com.underdog.session.service; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.underdog.session.dto.ServerInfoDto; +import lombok.RequiredArgsConstructor; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ZookeeperService { + + private final DiscoveryClient discoveryClient; + private final LoadBalancerClient loadBalancerClient; + private final String CHATTING_SERVICE_NAME = "chatting"; + public ServerInfoDto getPort(){ + ServiceInstance selectedInstance = loadBalancerClient.choose(CHATTING_SERVICE_NAME); + ServerInfoDto serverInfoDto = ServerInfoDto.builder().port(selectedInstance.getPort()).build(); + return serverInfoDto; + } +} diff --git a/backend/session-service/src/main/resources/application.yml b/backend/session-service/src/main/resources/application.yml new file mode 100644 index 0000000..f12c332 --- /dev/null +++ b/backend/session-service/src/main/resources/application.yml @@ -0,0 +1,10 @@ +server: + port: 8009 +spring: + profiles: + active: local + cloud: + zookeeper: + connect-string: localhost:2181 + discovery: + enabled: true \ No newline at end of file diff --git a/backend/session-service/src/test/java/com/underdog/session/SessionApplicationTests.java b/backend/session-service/src/test/java/com/underdog/session/SessionApplicationTests.java new file mode 100644 index 0000000..e2169f6 --- /dev/null +++ b/backend/session-service/src/test/java/com/underdog/session/SessionApplicationTests.java @@ -0,0 +1,13 @@ +package com.underdog.session; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SessionApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/backend/user-service/.gitignore b/backend/user-service/.gitignore index 8696920..8471b7a 100644 --- a/backend/user-service/.gitignore +++ b/backend/user-service/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ ### local.yml 설정 -application-local.yml \ No newline at end of file +application-local.yml + +### secret.yml 설정 +application-secret.yml \ No newline at end of file diff --git a/backend/user-service/build.gradle b/backend/user-service/build.gradle index 5c7b009..c41d17d 100644 --- a/backend/user-service/build.gradle +++ b/backend/user-service/build.gradle @@ -30,9 +30,32 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + + // p6spy 추가 + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // redis 설정 + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // oauth 2.0 (추후) + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // smtp naver 이메일 + implementation 'org.springframework.boot:spring-boot-starter-mail' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/backend/user-service/src/main/java/com/tadak/userservice/UserServiceApplication.java b/backend/user-service/src/main/java/com/tadak/userservice/UserServiceApplication.java index 06145be..59b242a 100644 --- a/backend/user-service/src/main/java/com/tadak/userservice/UserServiceApplication.java +++ b/backend/user-service/src/main/java/com/tadak/userservice/UserServiceApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication @EnableDiscoveryClient // eureka 서버에 지정 +@EnableJpaAuditing public class UserServiceApplication { public static void main(String[] args) { diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/controller/UserController.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/controller/UserController.java deleted file mode 100644 index ef65ad0..0000000 --- a/backend/user-service/src/main/java/com/tadak/userservice/domain/controller/UserController.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.tadak.userservice.domain.controller; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/user-service") -@Slf4j -public class UserController { - - @GetMapping("/hello") - public String hello() { - return "hello"; - } -} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/email/controller/EmailController.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/controller/EmailController.java new file mode 100644 index 0000000..8e6c0d6 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/controller/EmailController.java @@ -0,0 +1,31 @@ +package com.tadak.userservice.domain.email.controller; + +import com.tadak.userservice.domain.email.dto.EmailRequestDto; +import com.tadak.userservice.domain.email.dto.EmailResponseDto; +import com.tadak.userservice.domain.email.service.EmailService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/user-service") +public class EmailController { + + private final EmailService emailService; + + /** + * 이메일 인증 + */ + @PostMapping("/authcode/{email}") + public ResponseEntity verifyEmailCode(@PathVariable("email") String email, @RequestBody EmailRequestDto dto) { + EmailResponseDto emailResponseDto = emailService.verifyEmailCode(email, dto.getCode()); + + if (emailResponseDto.isEmailVerified()){ + return ResponseEntity.status(HttpStatus.OK).body(emailResponseDto); + } + + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(emailResponseDto); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailRequestDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailRequestDto.java new file mode 100644 index 0000000..4e46bec --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailRequestDto.java @@ -0,0 +1,10 @@ +package com.tadak.userservice.domain.email.dto; + +import lombok.Getter; + +@Getter +public class EmailRequestDto { + + private String code; +} + diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailResponseDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailResponseDto.java new file mode 100644 index 0000000..11f8e6e --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/dto/EmailResponseDto.java @@ -0,0 +1,22 @@ +package com.tadak.userservice.domain.email.dto; + +import com.tadak.userservice.domain.member.dto.response.DuplicateCheckResponseDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EmailResponseDto { + + private boolean emailVerified; + + public static EmailResponseDto of(boolean emailVerified){ + return EmailResponseDto.builder() + .emailVerified(emailVerified) + .build(); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/email/repository/EmailRepository.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/repository/EmailRepository.java new file mode 100644 index 0000000..5f8fd9a --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/repository/EmailRepository.java @@ -0,0 +1,26 @@ +package com.tadak.userservice.domain.email.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +import java.time.Duration; + +@Repository +@RequiredArgsConstructor +public class EmailRepository { + + private final StringRedisTemplate redisTemplate; + + public String getValue(String key) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + return valueOperations.get(key); + } + + public void saveAuth(String key, String value, long duration) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + Duration expireDuration = Duration.ofSeconds(duration); + valueOperations.set(key, value, expireDuration); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/email/service/EmailService.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/service/EmailService.java new file mode 100644 index 0000000..17ba753 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/email/service/EmailService.java @@ -0,0 +1,98 @@ +package com.tadak.userservice.domain.email.service; + +import com.tadak.userservice.domain.email.dto.EmailResponseDto; +import com.tadak.userservice.domain.email.repository.EmailRepository; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +import java.util.Random; + +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + private final EmailRepository emailRepository; + @Value("${spring.mail.username}") + private String configEmail; + + public void sendEmail(String email) throws MessagingException { + MimeMessage emailForm = createEmail(email); + + mailSender.send(emailForm); + } + + private MimeMessage createEmail(String email) throws MessagingException { + String authCode = createdCode(); + + MimeMessage message = mailSender.createMimeMessage(); + message.addRecipients(MimeMessage.RecipientType.TO, email); + message.setSubject("[TadakTadak] 인증안내"); + message.setFrom(configEmail); + message.setText(setContext(authCode), "utf-8", "html"); + + emailRepository.saveAuth(email, authCode, 60 * 2L); + + return message; + } + + private String createdCode() { + int leftLimit = 48; + int rightLimit = 122; + int targetStringLength = 10; + + Random random = new Random(); + + return random.ints(leftLimit, rightLimit + 1) + .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)) + .limit(targetStringLength) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + } + + private String setContext(String code) { + return "" + + "" + + "" + + "" + + "" + + "

Tadak Tadak 회원가입 인증코드: " + code + "

" + + "" + + ""; + } + + public EmailResponseDto verifyEmailCode(String email, String code) { + String codeFoundByEmail = getValue(email); + + if (codeFoundByEmail == null) { + return EmailResponseDto.of(false); + } + + boolean validCode = codeFoundByEmail.equals(code); + return EmailResponseDto.of(validCode); + } + + public String getValue(String email) { + return emailRepository.getValue(email); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/controller/MemberController.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/controller/MemberController.java new file mode 100644 index 0000000..f4273a9 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/controller/MemberController.java @@ -0,0 +1,88 @@ +package com.tadak.userservice.domain.member.controller; + +import com.tadak.userservice.domain.member.dto.request.LoginRequestDto; +import com.tadak.userservice.domain.member.dto.request.SignupRequestDto; +import com.tadak.userservice.domain.member.dto.response.*; +import com.tadak.userservice.domain.member.service.MemberService; +import com.tadak.userservice.global.jwt.filter.JwtFilter; +import jakarta.mail.MessagingException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/user-service") +@Slf4j +public class MemberController { + + private final MemberService memberService; + + /** + * 권한 체크 + */ + @GetMapping("/hello") + @PreAuthorize("isAuthenticated()") + public String hello() { + return "hello"; + } + + /** + * 회원 가입 + */ + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequestDto signupRequestDto) { + SignupResponseDto result = memberService.signup(signupRequestDto); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + } + + /** + * 로그인 + */ + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequestDto loginRequestDto) { + return memberService.login(loginRequestDto); + } + + /** + * 료그아웃 + */ + @PostMapping("/logout/{email}") + public ResponseEntity logout(@PathVariable("email") String email, Authentication authentication){ + memberService.logout(email, authentication.getName()); + log.info("loginEmail = {}", authentication.getName()); + return ResponseEntity.status(HttpStatus.OK).build(); + } + + /** + * username 검증 + */ + @GetMapping("/signup/exists-username/{username}") + public ResponseEntity checkUsername(@PathVariable("username") String username){ + DuplicateCheckResponseDto duplicateCheckResponseDto = memberService.existsUsername(username); + return ResponseEntity.status(HttpStatus.OK).body(duplicateCheckResponseDto); + } + + /** + * email 인증 & 검증 + */ + @GetMapping("/signup/exists-email/{email}") + public ResponseEntity checkEmail(@PathVariable("email") String email) throws MessagingException { + CheckEmailResponseDto checkEmailResponseDto = memberService.existsEmail(email); + return ResponseEntity.status(HttpStatus.OK).body(checkEmailResponseDto); + } + + /** + * member 정보 조회 + */ + @GetMapping("/user-info") + public ResponseEntity userInfo(@RequestHeader(JwtFilter.ACCESS_AUTHORIZATION_HEADER) String token){ + MemberResponseDto member = memberService.findByMember(token); + return ResponseEntity.status(HttpStatus.OK).body(member); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/LoginRequestDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/LoginRequestDto.java new file mode 100644 index 0000000..0a8b24a --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/LoginRequestDto.java @@ -0,0 +1,24 @@ +package com.tadak.userservice.domain.member.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class LoginRequestDto { + + @NotNull + @Email + private String email; + + @NotNull + @Size(min = 3, max = 100) + private String password; +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/SignupRequestDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/SignupRequestDto.java new file mode 100644 index 0000000..5df4691 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/request/SignupRequestDto.java @@ -0,0 +1,44 @@ +package com.tadak.userservice.domain.member.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.tadak.userservice.domain.member.entity.State; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SignupRequestDto { + + @NotNull + @Size(min = 3, max = 50) + private String username; + + @NotNull + @Email + private String email; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @NotNull + @Size(min = 3, max = 100) + private String password; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @NotNull + @Size(min = 3, max = 100) + private String passwordConfirm; + + @Enumerated(EnumType.STRING) + private State state; + + @NotNull + private String authCode; +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/CheckEmailResponseDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/CheckEmailResponseDto.java new file mode 100644 index 0000000..7f84ef1 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/CheckEmailResponseDto.java @@ -0,0 +1,25 @@ +package com.tadak.userservice.domain.member.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CheckEmailResponseDto { + + private boolean isValid; + private LocalDateTime expireAt; + + public static CheckEmailResponseDto of(boolean isValid, LocalDateTime expireAt){ + return CheckEmailResponseDto.builder() + .isValid(isValid) + .expireAt(expireAt) + .build(); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/DuplicateCheckResponseDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/DuplicateCheckResponseDto.java new file mode 100644 index 0000000..fb0733d --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/DuplicateCheckResponseDto.java @@ -0,0 +1,21 @@ +package com.tadak.userservice.domain.member.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class DuplicateCheckResponseDto { + + private boolean isValid; + + public static DuplicateCheckResponseDto of(boolean isValid){ + return DuplicateCheckResponseDto.builder() + .isValid(isValid) + .build(); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/MemberResponseDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/MemberResponseDto.java new file mode 100644 index 0000000..1cab041 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/MemberResponseDto.java @@ -0,0 +1,24 @@ +package com.tadak.userservice.domain.member.dto.response; + +import com.tadak.userservice.domain.member.entity.Member; +import com.tadak.userservice.domain.member.entity.Role; +import com.tadak.userservice.domain.member.entity.State; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MemberResponseDto { + + private Long memberId; + private String email; + private String username; + + public static MemberResponseDto from(Member member){ + return MemberResponseDto.builder() + .memberId(member.getId()) + .email(member.getEmail()) + .username(member.getUsername()) + .build(); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/SignupResponseDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/SignupResponseDto.java new file mode 100644 index 0000000..680d788 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/SignupResponseDto.java @@ -0,0 +1,34 @@ +package com.tadak.userservice.domain.member.dto.response; + +import com.tadak.userservice.domain.member.entity.Member; +import com.tadak.userservice.domain.member.entity.Role; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SignupResponseDto { + + private Long id; + private String email; + private String username; + private Role role; + + // Member -> SignupResponse + public static SignupResponseDto from(Member member) { + if (member == null){ + return null; + } + + return SignupResponseDto.builder() + .id(member.getId()) + .username(member.getUsername()) + .email(member.getEmail()) + .role(member.getRole()) + .build(); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/TokenResponseDto.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/TokenResponseDto.java new file mode 100644 index 0000000..95a0f2f --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/dto/response/TokenResponseDto.java @@ -0,0 +1,24 @@ +package com.tadak.userservice.domain.member.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class TokenResponseDto { + + @JsonIgnore + private String grantType; // bearer 사용 + @JsonIgnore + private String accessToken; + @JsonIgnore + private String refreshToken; +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Member.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Member.java new file mode 100644 index 0000000..d618d9c --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Member.java @@ -0,0 +1,45 @@ +package com.tadak.userservice.domain.member.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private State state; + + @Enumerated(EnumType.STRING) + private Role role; + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Role.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Role.java new file mode 100644 index 0000000..1731a72 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/Role.java @@ -0,0 +1,5 @@ +package com.tadak.userservice.domain.member.entity; + +public enum Role { + ROLE_USER, ROLE_ADMIN +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/State.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/State.java new file mode 100644 index 0000000..893a26f --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/entity/State.java @@ -0,0 +1,5 @@ +package com.tadak.userservice.domain.member.entity; + +public enum State { + ACTIVE, DELETE +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberEmailException.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberEmailException.java new file mode 100644 index 0000000..2f25f37 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberEmailException.java @@ -0,0 +1,12 @@ +package com.tadak.userservice.domain.member.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class DuplicateMemberEmailException extends BusinessException { + + public DuplicateMemberEmailException(ErrorCode errorCode) { + super(errorCode); + } +} + diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberUsernameException.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberUsernameException.java new file mode 100644 index 0000000..2ee67e0 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/DuplicateMemberUsernameException.java @@ -0,0 +1,11 @@ +package com.tadak.userservice.domain.member.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class DuplicateMemberUsernameException extends BusinessException { + + public DuplicateMemberUsernameException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/EmailNotVerifiedException.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/EmailNotVerifiedException.java new file mode 100644 index 0000000..62b76d5 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/EmailNotVerifiedException.java @@ -0,0 +1,10 @@ +package com.tadak.userservice.domain.member.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class EmailNotVerifiedException extends BusinessException { + public EmailNotVerifiedException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/MemberStateException.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/MemberStateException.java new file mode 100644 index 0000000..febf962 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/MemberStateException.java @@ -0,0 +1,11 @@ +package com.tadak.userservice.domain.member.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class MemberStateException extends BusinessException { + + public MemberStateException(ErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotFoundMemberException.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotFoundMemberException.java new file mode 100644 index 0000000..49e1e3a --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotFoundMemberException.java @@ -0,0 +1,11 @@ +package com.tadak.userservice.domain.member.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class NotFoundMemberException extends BusinessException { + + public NotFoundMemberException(ErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchLogoutException.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchLogoutException.java new file mode 100644 index 0000000..c20ab9d --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchLogoutException.java @@ -0,0 +1,11 @@ +package com.tadak.userservice.domain.member.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class NotMatchLogoutException extends BusinessException { + + public NotMatchLogoutException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchPasswordException.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchPasswordException.java new file mode 100644 index 0000000..8b415ce --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/exception/NotMatchPasswordException.java @@ -0,0 +1,11 @@ +package com.tadak.userservice.domain.member.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class NotMatchPasswordException extends BusinessException { + + public NotMatchPasswordException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/repository/MemberRepository.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..6127c1d --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/repository/MemberRepository.java @@ -0,0 +1,17 @@ +package com.tadak.userservice.domain.member.repository; + +import com.tadak.userservice.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + Optional findByEmail(String email); +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/member/service/MemberService.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/service/MemberService.java new file mode 100644 index 0000000..9a0f334 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/member/service/MemberService.java @@ -0,0 +1,204 @@ +package com.tadak.userservice.domain.member.service; + +import com.tadak.userservice.domain.email.dto.EmailResponseDto; +import com.tadak.userservice.domain.email.service.EmailService; +import com.tadak.userservice.domain.member.dto.request.LoginRequestDto; +import com.tadak.userservice.domain.member.dto.request.SignupRequestDto; +import com.tadak.userservice.domain.member.dto.response.*; +import com.tadak.userservice.domain.member.entity.Member; +import com.tadak.userservice.domain.member.entity.Role; +import com.tadak.userservice.domain.member.entity.State; +import com.tadak.userservice.domain.member.exception.*; +import com.tadak.userservice.domain.member.repository.MemberRepository; +import com.tadak.userservice.domain.refresh.entity.RefreshToken; +import com.tadak.userservice.domain.refresh.repository.RefreshTokenRepository; +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.jwt.filter.JwtFilter; +import com.tadak.userservice.global.jwt.provider.TokenProvider; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final EmailService emailService; + + @Transactional + public SignupResponseDto signup(SignupRequestDto signupRequestDto) { + // 추후 Exception custom + if (memberRepository.existsByEmail(signupRequestDto.getEmail())){ + throw new DuplicateMemberEmailException(ErrorCode.DUPLICATE_MEMBER_EMAIL_ERROR); + } + + if (memberRepository.existsByUsername(signupRequestDto.getUsername())){ + throw new DuplicateMemberUsernameException(ErrorCode.DUPLICATE_MEMBER_USERNAME_ERROR); + } + + String authCode = signupRequestDto.getAuthCode(); + EmailResponseDto emailResponseDto = emailService.verifyEmailCode(signupRequestDto.getEmail(), authCode); + if (!emailResponseDto.isEmailVerified()) { + throw new EmailNotVerifiedException(ErrorCode.EMAIL_NOT_VERIFIED_ERROR); + } + + passwordConfirm(signupRequestDto.getPassword(), signupRequestDto.getPasswordConfirm()); + + Member member = saveMember(signupRequestDto); + memberRepository.save(member); + + return SignupResponseDto.from(member); + } + + public ResponseEntity login(LoginRequestDto loginRequestDto) { + Authentication authentication = getAuthentication(loginRequestDto); + + String accessToken = tokenProvider.createAccessToken(authentication); + String refreshToken; + + if (!refreshTokenRepository.existsByEmail(authentication.getName()) + || refreshTokenRepository.getValues(authentication.getName()).length() < 11) { + refreshToken = tokenProvider.createRefreshToken(authentication); + + RefreshToken resultRefreshToken = getRefreshToken(loginRequestDto.getEmail(), refreshToken); + refreshTokenRepository.save(resultRefreshToken); + } else { + refreshToken = refreshTokenRepository.getValues(authentication.getName()); + } + + HttpHeaders httpHeaders = createResponseHeaders(accessToken, refreshToken); // header 정보 넣어주기! + // 현재 사용하지 않음 (body로 토큰을 전달해주지 않기때문) + TokenResponseDto tokenResponseDto = tokenProvider.createTokenResponseDto(accessToken, refreshToken); + return new ResponseEntity<>(httpHeaders, HttpStatus.OK); + } + + // login Member 정보 받아오기 + private Authentication getAuthentication(LoginRequestDto loginRequestDto) { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(loginRequestDto.getEmail(), loginRequestDto.getPassword()); + + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + return authentication; + } + + @Transactional + public void logout(String email, String loginEmail) { + if (!validLoginMember(email, loginEmail)){ + throw new NotMatchLogoutException(ErrorCode.NOT_MATCH_LOGOUT_EXCEPTION); + } + + refreshTokenRepository.deleteEmail(email); + } + + public DuplicateCheckResponseDto existsUsername(String username) { + if (memberRepository.existsByUsername(username)){ + throw new DuplicateMemberUsernameException(ErrorCode.DUPLICATE_MEMBER_USERNAME_ERROR); + } + + return DuplicateCheckResponseDto.of(true); + } + + public CheckEmailResponseDto existsEmail(String email) throws MessagingException { + if (memberRepository.existsByEmail(email)){ + throw new DuplicateMemberEmailException(ErrorCode.DUPLICATE_MEMBER_EMAIL_ERROR); + } + + // 이메일 인증 보내기! + emailService.sendEmail(email); + + return CheckEmailResponseDto.of(true, LocalDateTime.now().plusMinutes(2)); + } + + /** + * Header 부분에 token 넣어주기 + * @param accessToken + * @param refreshToken + */ + private HttpHeaders createResponseHeaders(String accessToken, String refreshToken) { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add(JwtFilter.ACCESS_AUTHORIZATION_HEADER, "Bearer " + accessToken); + httpHeaders.add(JwtFilter.REFRESH_AUTHORIZATION_HEADER, "Bearer " + refreshToken); + return httpHeaders; + } + + /** + * 비밀번호 검증 + * @param password // 비밀번호 + * @param passwordConfirm // 비밀번호 확인 + */ + private void passwordConfirm(String password, String passwordConfirm) { + if (!password.equals(passwordConfirm)){ + throw new NotMatchPasswordException(ErrorCode.NOT_MATCH_PASSWORD_ERROR); + } + } + + + /** + * loginMember 검증 + */ + private boolean validLoginMember(String email, String loginEmail) { + return email.equals(loginEmail); + } + + /** + * 멤버 저장 + * @param memberDto + */ + public Member saveMember(SignupRequestDto memberDto){ + return Member.builder() + .email(memberDto.getEmail()) + .username(memberDto.getUsername()) + .password(passwordEncoder.encode(memberDto.getPassword())) + .state(State.ACTIVE) + .role(Role.ROLE_USER) + .build(); + } + + /** + * reFresh token 추출 + */ + private static RefreshToken getRefreshToken(String email, String refreshToken) { + return RefreshToken.builder() + .refreshToken(refreshToken) + .email(email) + .build(); + } + + public MemberResponseDto findByMember(String token) { + + if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { + token = token.substring(7); + } + + Authentication authentication = tokenProvider.getAuthentication(token); + Member member = memberRepository.findByEmail(authentication.getName()) + .orElseThrow(() -> new NotFoundMemberException(ErrorCode.NOT_FOUND_MEMBER_ERROR)); + + log.info("member id = {}", member.getId()); + log.info("member email = {}", member.getEmail()); + log.info("member username = {}", member.getUsername()); + + return MemberResponseDto.from(member); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/entity/RefreshToken.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/entity/RefreshToken.java new file mode 100644 index 0000000..df432db --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/entity/RefreshToken.java @@ -0,0 +1,19 @@ +package com.tadak.userservice.domain.refresh.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RefreshToken { + + @Id + private String email; + private String refreshToken; +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/repository/RefreshTokenRepository.java b/backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..61ab7c8 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/domain/refresh/repository/RefreshTokenRepository.java @@ -0,0 +1,39 @@ +package com.tadak.userservice.domain.refresh.repository; + +import com.tadak.userservice.domain.refresh.entity.RefreshToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class RefreshTokenRepository { + + private final RedisTemplate redisTemplate; + + public void save(final RefreshToken refreshToken) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + valueOperations.set(refreshToken.getEmail(), refreshToken.getRefreshToken()); + redisTemplate.expire(refreshToken.getEmail(), 60L * 60L * 24 * 7L, TimeUnit.SECONDS); + } + + public String getValues(String email){ + ValueOperations valueOperations = redisTemplate.opsForValue(); + return valueOperations.get(email); + } + + public boolean existsByEmail(String email){ + ValueOperations valueOperations = redisTemplate.opsForValue(); + String refreshToken = valueOperations.get(email); + return refreshToken != null && !refreshToken.isEmpty(); + } + + public void deleteEmail(String email) { + redisTemplate.delete(email); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorCode.java b/backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorCode.java new file mode 100644 index 0000000..7f378b3 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorCode.java @@ -0,0 +1,53 @@ +package com.tadak.userservice.global.error; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + + BAD_REQUEST_ERROR(400, "G001", "Bad Request Exception"), + REQUEST_BODY_MISSING_ERROR(400, "G002", "Required request body is missing"), + MISSING_REQUEST_PARAMETER_ERROR(400, "G004", "Missing Servlet RequestParameter Exception"), + NOT_FOUND_ERROR(404, "G009", "Not Found Exception"), + NULL_POINT_ERROR(404, "G010", "Null Point Exception"), + NOT_VALID_ERROR(404, "G011", "handle Validation Exception"), + INTERNAL_SERVER_ERROR(500, "G999", "Internal Server Error Exception"), + + /** + * Token Error 1000 ~ 1099 + */ + NOT_FOUND_TOKEN_ERROR(1000, "G1000", "현재 유효한 Token 권한 정보가 존재하지 않습니다."), + NOT_VALID_ACCESS_TOKEN_ERROR(1001, "G1000", "만료된 Token 값 입니다."), + NOT_VALID_REFRESH_TOKEN_ERROR(1002, "G1000", "현재 최신 refreshToken을 소지하고 있지 않습니다."), + + /** + * Member Error 1100 ~ 1199 + */ + NOT_FOUND_MEMBER_ERROR(1100, "G1100", "현재 해당 member가 존재하지 않습니다."), + DUPLICATE_MEMBER_USERNAME_ERROR(1101, "G1100", "현재 해당 username이 존재합니다."), + DUPLICATE_MEMBER_EMAIL_ERROR(1102, "G1100", "현재 해당 email은 존재합니다."), + NOT_MATCH_PASSWORD_ERROR(1103, "G1100", "비밀번호가 일치하지 않습니다."), + NOT_VALID_MEMBER_STATE_ERROR(1104, "G1100", "회원탈퇴한 계정입니다."), + NOT_MATCH_LOGOUT_EXCEPTION(1105, "G1100", "로그아웃 정보가 일치하지 않습니다."), + EMAIL_NOT_VERIFIED_ERROR(1106, "G1100", "이메일 인증이 되지 않은 사용자입니다."), + + /** + * login error 1200 ~ 1299 + */ + NOT_MATCH_LOGIN_ERROR(1200, "G1200", "로그인 정보가 일치하지 않습니다."), + + /** + * Binding Error 1400 ~ 1499 + */ + INVALID_INPUT_VALUE(1400, "G1400", "잘못된 입력 값 입니다."); + + private final int status; + private final String code; + private final String message; + + ErrorCode(final int status, final String code, final String message) { + this.status = status; + this.code = code; + this.message = message; + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorResponse.java b/backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorResponse.java new file mode 100644 index 0000000..dfe7c67 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/error/ErrorResponse.java @@ -0,0 +1,86 @@ +package com.tadak.userservice.global.error; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.validation.BindingResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ErrorResponse { + + private int status; // 에러 상태 코드 + private String code; // 에러 구분 코드 + private String message; // 에러 메시지 + + @Builder + protected ErrorResponse(final ErrorCode code) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + @Builder + protected ErrorResponse(final ErrorCode code, final String reason) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + @Builder + protected ErrorResponse(final ErrorCode code, final List errors) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + } + + public static ErrorResponse of(final ErrorCode code, final BindingResult bindingResult) { + return new ErrorResponse(code, FieldError.of(bindingResult)); + } + + public static ErrorResponse of(final ErrorCode code) { + return new ErrorResponse(code); + } + + public static ErrorResponse of(final ErrorCode code, final String reason) { + return new ErrorResponse(code, reason); + } + + /** + * e.getBindingResult() 형태로 전달받는 error 처리하여 변경 + */ + @Getter + public static class FieldError { + private final String field; + private final String value; + private final String reason; + + public static List of(final String field, final String value, final String reason) { + List fieldErrors = new ArrayList<>(); + fieldErrors.add(new FieldError(field, value, reason)); + return fieldErrors; + } + + private static List of(final BindingResult bindingResult) { + final List fieldErrors = bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + + @Builder + FieldError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/error/GlobalExceptionHandler.java b/backend/user-service/src/main/java/com/tadak/userservice/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..d34e5ab --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/error/GlobalExceptionHandler.java @@ -0,0 +1,131 @@ +package com.tadak.userservice.global.error; + +import com.tadak.userservice.global.error.common.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.servlet.NoHandlerFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final HttpStatus HTTP_STATUS_OK = HttpStatus.OK; + + /** + * API 호출 시 객체 혹은 파라미터의 값이 제대로 되지 않을 경우 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + log.error("handleMethodArgumentNotValidException", ex); + BindingResult bindingResult = ex.getBindingResult(); + StringBuilder stringBuilder = new StringBuilder(); + for (FieldError fieldError : bindingResult.getFieldErrors()) { + stringBuilder.append(fieldError.getField()).append(":"); + stringBuilder.append(fieldError.getDefaultMessage()); + stringBuilder.append(", "); + } + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_VALID_ERROR, String.valueOf(stringBuilder)); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * Body 부분에 객체 데이터가 넘어 오지 않았을 경우 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + log.error("HttpMessageNotReadableException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.REQUEST_BODY_MISSING_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * 클라이언트에서 body로 객체 데이터가 넘어오지 않을 경우 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity handleMissingRequestHeaderExceptionException(MissingServletRequestParameterException ex) { + log.error("handleMissingServletRequestParameterException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.MISSING_REQUEST_PARAMETER_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * 잘못된 서버 요청일 경우 + */ + @ExceptionHandler(HttpClientErrorException.BadRequest.class) + protected ResponseEntity handleBadRequestException(HttpClientErrorException e) { + log.error("HttpClientErrorException.BadRequest", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.BAD_REQUEST_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * 잘못된 주소로 요청한 경우 + */ + @ExceptionHandler(NoHandlerFoundException.class) + protected ResponseEntity handleNoHandlerFoundExceptionException(NoHandlerFoundException e) { + log.error("handleNoHandlerFoundExceptionException", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_FOUND_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * null 값이 발생한 경우 + */ + @ExceptionHandler(NullPointerException.class) + protected ResponseEntity handleNullPointerException(NullPointerException e) { + log.error("handleNullPointerException", e); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NULL_POINT_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * exception이 발생한 경우 + */ + @ExceptionHandler(Exception.class) + protected final ResponseEntity handleAllExceptions(Exception ex) { + log.error("Exception", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, ex.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + /** + * 로그인 정보가 일치하지 않을 경우 + */ + @ExceptionHandler(BadCredentialsException.class) + protected final ResponseEntity handleBadCredentialsExceptions(BadCredentialsException e){ + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_MATCH_LOGIN_ERROR, e.getMessage()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * Custom Exception + */ + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleBusinessException(final BusinessException ex) { + log.error("handleBusinessException", ex); + final ErrorCode errorCode = ex.getErrorCode(); + final ErrorResponse response = ErrorResponse.of(errorCode); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + + /** + * binding Exception + */ + @ExceptionHandler(BindException.class) + protected ResponseEntity handleBindException(BindException ex) { + log.error("handleBindException", ex); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, ex.getBindingResult()); + return new ResponseEntity<>(response, HTTP_STATUS_OK); + } + +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/error/common/BusinessException.java b/backend/user-service/src/main/java/com/tadak/userservice/global/error/common/BusinessException.java new file mode 100644 index 0000000..5d9b0db --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/error/common/BusinessException.java @@ -0,0 +1,22 @@ +package com.tadak.userservice.global.error.common; + +import com.tadak.userservice.global.error.ErrorCode; + +public class BusinessException extends RuntimeException{ + + private ErrorCode errorCode; + + public BusinessException(String message, ErrorCode errorCode) { + super(message); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/config/JwtSecurityConfig.java b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/config/JwtSecurityConfig.java new file mode 100644 index 0000000..6c57b76 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/config/JwtSecurityConfig.java @@ -0,0 +1,22 @@ +package com.tadak.userservice.global.jwt.config; + +import com.tadak.userservice.global.jwt.filter.JwtFilter; +import com.tadak.userservice.global.jwt.provider.TokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + private final TokenProvider tokenProvider; + + @Override + public void configure(HttpSecurity http) { + http.addFilterBefore( + new JwtFilter(tokenProvider), + UsernamePasswordAuthenticationFilter.class + ); + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/exception/NotFoundTokenException.java b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/exception/NotFoundTokenException.java new file mode 100644 index 0000000..8842c24 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/exception/NotFoundTokenException.java @@ -0,0 +1,11 @@ +package com.tadak.userservice.global.jwt.exception; + +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.error.common.BusinessException; + +public class NotFoundTokenException extends BusinessException { + + public NotFoundTokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/filter/JwtFilter.java b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/filter/JwtFilter.java new file mode 100644 index 0000000..18cb31b --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/filter/JwtFilter.java @@ -0,0 +1,86 @@ +package com.tadak.userservice.global.jwt.filter; + +import com.tadak.userservice.global.jwt.provider.TokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends GenericFilterBean { + + // public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String ACCESS_AUTHORIZATION_HEADER = "Accesstoken"; + public static final String REFRESH_AUTHORIZATION_HEADER = "Refreshtoken"; + private final TokenProvider tokenProvider; + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; + String accessToken = resolveAccessToken(httpServletRequest); + String refreshToken = resolveRefreshToken(httpServletRequest); + + String requestURI = httpServletRequest.getRequestURI(); + + // accessToken이 만료되지 않았을 경우 + if (StringUtils.hasText(accessToken) && tokenProvider.validateToken(accessToken)) { + Authentication authentication = tokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI); + } + // accessToken이 만료되었거나 accessToken값이 없을 경우 refresh Token으로 재발급 받기 + else if (!tokenProvider.validateToken(accessToken) && refreshToken != null){ + boolean refreshTokenValid = tokenProvider.validateToken(refreshToken); + if (refreshTokenValid){ + Authentication authMember = tokenProvider.getAuthentication(refreshToken); // authentication으로 Member email가져오기 + String valueRefreshToken = tokenProvider.getAuthoritiesKey(authMember.getName()); // redis에 저장된 refreshToken 값 + if (valueRefreshToken.equals(refreshToken)){ // 현재 받은 refreshToken이랑 redis에 저장된 refreshToken 값 비교 + Authentication authentication = tokenProvider.getAuthentication(refreshToken); + String reAccessToken = tokenProvider.createAccessToken(authentication); + String reRefreshToken = tokenProvider.createRefreshToken(authentication); + HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse; + httpServletResponse.addHeader(ACCESS_AUTHORIZATION_HEADER, "Bearer " + reAccessToken); + httpServletResponse.addHeader(REFRESH_AUTHORIZATION_HEADER, "Bearer " + reRefreshToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + + tokenProvider.saveRefreshToken(reRefreshToken, authentication.getName()); // 새로 발급받은 refreshToken 저장 + log.info("ReAccessToken Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI); + } + } + } + else { + log.info("유효한 JWT 토큰이 없습니다, uri: {}", requestURI); + } + + filterChain.doFilter(servletRequest, servletResponse); + } + private String resolveAccessToken(HttpServletRequest request) { + String bearerToken = request.getHeader(ACCESS_AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } + + private String resolveRefreshToken(HttpServletRequest request) { + String bearerToken = request.getHeader(REFRESH_AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + + return null; + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAccessDeniedHandler.java b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..6f77e32 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,20 @@ +package com.tadak.userservice.global.jwt.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 권한이 존재하지 않는 유저가 접근하려고 할 경우 403 Forbidden + */ +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAuthenticationEntryPoint.java b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..22c358f --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,23 @@ +package com.tadak.userservice.global.jwt.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 인가받지 않고 접근하려 할 경우 401 Unauthorized return 클래스 + */ +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/provider/TokenProvider.java b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/provider/TokenProvider.java new file mode 100644 index 0000000..cf85639 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/jwt/provider/TokenProvider.java @@ -0,0 +1,165 @@ +package com.tadak.userservice.global.jwt.provider; + +import com.tadak.userservice.domain.member.dto.response.TokenResponseDto; +import com.tadak.userservice.domain.refresh.entity.RefreshToken; +import com.tadak.userservice.domain.refresh.repository.RefreshTokenRepository; +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.jwt.exception.NotFoundTokenException; +import com.tadak.userservice.global.security.userdetail.CustomUserDetailsService; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +@Slf4j +public class TokenProvider implements InitializingBean { + private static final String AUTHORITIES_KEY = "auth"; + private final String secret; + private final long accessTokenValidationTime; + private final long refreshTokenValidationTime; + private Key key; + private final CustomUserDetailsService customUserDetailsService; + private final RefreshTokenRepository refreshTokenRepository; + + public TokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-validity-in-seconds}") long accessTokenValidationTime, + @Value("${jwt.refresh-validity-in-seconds}") long refreshTokenValidationTime, + CustomUserDetailsService customUserDetailsService, + RefreshTokenRepository refreshTokenRepository) { + this.secret = secret; + this.accessTokenValidationTime = accessTokenValidationTime; + this.refreshTokenValidationTime = refreshTokenValidationTime; + this.customUserDetailsService = customUserDetailsService; + this.refreshTokenRepository = refreshTokenRepository; + } + + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + // AccessToken 생성 메서드 + public String createAccessToken(Authentication authentication) { + long now = System.currentTimeMillis(); + long validityMillis = this.accessTokenValidationTime * 1000; + + Date validity = new Date(now + validityMillis); + + return generateToken(authentication, validity); + } + + // RefreshToken 생성 메서드 + public String createRefreshToken(Authentication authentication) { + long now = System.currentTimeMillis(); + long refreshValidityMillis = this.refreshTokenValidationTime * 1000; + + Date refreshValidity = new Date(now + refreshValidityMillis); + + // Refresh 토큰 생성 + return generateToken(authentication, refreshValidity); + } + + // 토큰을 생성하는 공통 메서드 + private String generateToken(Authentication authentication, Date expiration) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + log.info("authorities = {}", authorities); + + Claims claims = Jwts.claims() + .setSubject(authentication.getName()); + + return Jwts.builder() + .setClaims(claims) + .claim(AUTHORITIES_KEY, authorities) + .signWith(key, SignatureAlgorithm.HS512) + .setExpiration(expiration) + .compact(); + } + + public TokenResponseDto createTokenResponseDto(String accessToken, String refreshToken) { + return TokenResponseDto.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public Authentication getAuthentication(String token) { + Claims claims = Jwts + .parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + // claims auth 체크 부분! + if (claims.get(AUTHORITIES_KEY) == null){ + throw new NotFoundTokenException(ErrorCode.NOT_FOUND_TOKEN_ERROR); + } + + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new).toList(); + +// UserDetails userDetails = customUserDetailsService.loadUserByUsername(claims.getSubject()); +// return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities()); + + User principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } + + /** + * 토큰 정보 가져오기 + */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + } + return false; + } + + /** + * redis key email로 value값 찾아오는 메서드 + * @param email + */ + public String getAuthoritiesKey(String email){ + return refreshTokenRepository.getValues(email); + } + + public void saveRefreshToken(String refreshToken, String email){ + RefreshToken result = RefreshToken.builder() + .email(email) + .refreshToken(refreshToken) + .build(); + refreshTokenRepository.save(result); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/CustomOAuth2UserService.java b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..92e3076 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/CustomOAuth2UserService.java @@ -0,0 +1,65 @@ +package com.tadak.userservice.global.oauth; + +import com.tadak.userservice.domain.member.entity.Member; +import com.tadak.userservice.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + log.info("loadUser 실행"); + OAuth2UserService service = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = service.loadUser(userRequest); // OAuth2 정보 가져오기 + + Map originAttributes = oAuth2User.getAttributes(); + + String socialName = userRequest.getClientRegistration().getRegistrationId(); // 소셜 정보 가져오기 + log.info("socialName = {}", socialName); + + OAuthAttributes attributes = OAuthAttributes.of(socialName, originAttributes); + Member member = getOrCreate(attributes); // get Or create + String email = member.getEmail(); + + // 권한 저장 + List authorities = getGrantedAuthorities(member); + + return new OAuth2CustomMember(email, originAttributes, authorities); + } + + private Member getOrCreate(OAuthAttributes attributes){ + Optional existingMember = memberRepository.findByEmail(attributes.getEmail()); + + if (existingMember.isPresent()) { + return existingMember.get(); + } + + Member newMember = attributes.toEntity(); + return memberRepository.save(newMember); + + } + + private List getGrantedAuthorities(Member member) { + return Collections.singletonList( + new SimpleGrantedAuthority(member.getRole().name()) + ); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2CustomMember.java b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2CustomMember.java new file mode 100644 index 0000000..8a62bd0 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2CustomMember.java @@ -0,0 +1,32 @@ +package com.tadak.userservice.global.oauth; + +import lombok.AllArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@AllArgsConstructor +public class OAuth2CustomMember implements OAuth2User, Serializable { + private String email; + private Map attributes; + private List authorities; + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getName() { + return this.email; + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2MemberSuccessHandler.java b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2MemberSuccessHandler.java new file mode 100644 index 0000000..d8c16eb --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuth2MemberSuccessHandler.java @@ -0,0 +1,62 @@ +package com.tadak.userservice.global.oauth; + +import com.tadak.userservice.domain.member.dto.response.TokenResponseDto; +import com.tadak.userservice.domain.member.entity.Member; +import com.tadak.userservice.domain.member.exception.NotFoundMemberException; +import com.tadak.userservice.domain.member.repository.MemberRepository; +import com.tadak.userservice.global.error.ErrorCode; +import com.tadak.userservice.global.jwt.filter.JwtFilter; +import com.tadak.userservice.global.jwt.provider.TokenProvider; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final TokenProvider tokenProvider; + private final MemberRepository memberRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + log.info("onAuthenticationSuccess 실행"); + OAuth2CustomMember oAuth2User = (OAuth2CustomMember) authentication.getPrincipal(); + String email = oAuth2User.getName(); + + memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundMemberException(ErrorCode.NOT_FOUND_MEMBER_ERROR)); + + String accessToken = tokenProvider.createAccessToken(authentication); + String refreshToken = tokenProvider.createRefreshToken(authentication); + + TokenResponseDto tokenResponseDto = tokenProvider.createTokenResponseDto(accessToken, refreshToken); + tokenProvider.saveRefreshToken(refreshToken, email); + + createResponseHeader(response, tokenResponseDto); + + response.sendRedirect("http://localhost:5173"); + } + + private void createResponseHeader(HttpServletResponse response, TokenResponseDto tokenResponseDto) throws IOException { + response.addHeader(JwtFilter.ACCESS_AUTHORIZATION_HEADER, "Bearer " + tokenResponseDto.getAccessToken()); + response.addHeader(JwtFilter.REFRESH_AUTHORIZATION_HEADER, "Bearer " + tokenResponseDto.getRefreshToken()); + } + + private List getGrantedAuthorities(Member member) { + return Collections.singletonList( + new SimpleGrantedAuthority(member.getRole().name()) + ); + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuthAttributes.java b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuthAttributes.java new file mode 100644 index 0000000..9f53e0a --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/oauth/OAuthAttributes.java @@ -0,0 +1,54 @@ +package com.tadak.userservice.global.oauth; + +import com.tadak.userservice.domain.member.entity.Member; +import com.tadak.userservice.domain.member.entity.Role; +import com.tadak.userservice.domain.member.entity.State; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +@Getter +@ToString +@AllArgsConstructor +@Builder +public class OAuthAttributes { + + private Map attributes; + private String nameAttributesKey; + private String name; + private String email; + + public static OAuthAttributes of(String socialName, Map attributes){ + if ("naver".equalsIgnoreCase(socialName)){ + return ofNaver("id", attributes); + } + + throw new IllegalArgumentException("잘못된 소셜 정보입니다."); + } + + private static OAuthAttributes ofNaver(String id, Map attributes) { + Map response = (Map) attributes.get("response"); + + return OAuthAttributes.builder() + .name(String.valueOf(response.get("nickname"))) + .email(String.valueOf(response.get("email"))) + .attributes(response) + .nameAttributesKey(id) + .build(); + } + + public Member toEntity() { + return Member.builder() + .username(name) + .email(email) + .password(UUID.randomUUID().toString()) // 임시 비밀번호 UUID로 생성 + .state(State.ACTIVE) + .role(Role.ROLE_USER) + .build(); + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/p6spy/P6SpySqlFormatter.java b/backend/user-service/src/main/java/com/tadak/userservice/global/p6spy/P6SpySqlFormatter.java new file mode 100644 index 0000000..39608d0 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/p6spy/P6SpySqlFormatter.java @@ -0,0 +1,42 @@ +package com.tadak.userservice.global.p6spy; + +import com.p6spy.engine.logging.Category; +import com.p6spy.engine.spy.P6SpyOptions; +import com.p6spy.engine.spy.appender.MessageFormattingStrategy; +import jakarta.annotation.PostConstruct; +import org.hibernate.engine.jdbc.internal.FormatStyle; +import org.springframework.context.annotation.Configuration; + +import java.util.Locale; + +@Configuration +public class P6SpySqlFormatter implements MessageFormattingStrategy { + + @PostConstruct + public void setLogMessageFormat() { + P6SpyOptions.getActiveInstance().setLogMessageFormat(this.getClass().getName()); + } + + @Override + public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared, String sql, String url) { + sql = formatSql(category, sql); + return String.format("[%s] | %d ms | %s", category, elapsed, highlight(formatSql(category, sql))); + } + + private String formatSql(String category, String sql) { + if (sql != null && !sql.trim().isEmpty() && Category.STATEMENT.getName().equals(category)) { + String trimmedSQL = sql.trim().toLowerCase(Locale.ROOT); + if (trimmedSQL.startsWith("create") || trimmedSQL.startsWith("alter") || trimmedSQL.startsWith("comment")) { + sql = FormatStyle.DDL.getFormatter().format(sql); + } else { + sql = FormatStyle.BASIC.getFormatter().format(sql); + } + return sql; + } + return sql; + } + + private String highlight(String sql) { + return FormatStyle.HIGHLIGHT.getFormatter().format(sql); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/redis/config/RedisConfig.java b/backend/user-service/src/main/java/com/tadak/userservice/global/redis/config/RedisConfig.java new file mode 100644 index 0000000..205ec21 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/redis/config/RedisConfig.java @@ -0,0 +1,34 @@ +package com.tadak.userservice.global.redis.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory); + return redisTemplate; + } +} \ No newline at end of file diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/security/config/CorsConfig.java b/backend/user-service/src/main/java/com/tadak/userservice/global/security/config/CorsConfig.java new file mode 100644 index 0000000..866796f --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/security/config/CorsConfig.java @@ -0,0 +1,24 @@ +package com.tadak.userservice.global.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +public class CorsConfig { + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.addExposedHeader("Accesstoken, Refreshtoken"); + + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/security/config/SecurityConfig.java b/backend/user-service/src/main/java/com/tadak/userservice/global/security/config/SecurityConfig.java new file mode 100644 index 0000000..a1656e6 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/security/config/SecurityConfig.java @@ -0,0 +1,74 @@ +package com.tadak.userservice.global.security.config; + +import com.tadak.userservice.global.jwt.config.JwtSecurityConfig; +import com.tadak.userservice.global.jwt.handler.JwtAccessDeniedHandler; +import com.tadak.userservice.global.jwt.handler.JwtAuthenticationEntryPoint; +import com.tadak.userservice.global.jwt.provider.TokenProvider; +import com.tadak.userservice.global.oauth.CustomOAuth2UserService; +import com.tadak.userservice.global.oauth.OAuth2MemberSuccessHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.CorsFilter; + +@EnableWebSecurity +@EnableMethodSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final TokenProvider tokenProvider; + private final CorsFilter corsFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2MemberSuccessHandler oAuth2MemberSuccessHandler; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + http + .csrf(AbstractHttpConfigurer::disable) // csrf disable + .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exceptionHandling -> exceptionHandling + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/user-service/signup/**").permitAll() // 회원가입 + .requestMatchers("/user-service/login").permitAll() // 로그인 + .requestMatchers("/user-service/authcode/**").permitAll() + .requestMatchers("/oauth2/**").permitAll() // 네이버 로그인 + .anyRequest().authenticated()) + .oauth2Login( + oauth -> oauth + .userInfoEndpoint(config -> + config.userService(customOAuth2UserService)) + .successHandler(oAuth2MemberSuccessHandler)) + + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // session 사용 x + + .headers(headers -> + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) + ) + + .apply(new JwtSecurityConfig(tokenProvider)); + + return http.build(); + } +} diff --git a/backend/user-service/src/main/java/com/tadak/userservice/global/security/userdetail/CustomUserDetailsService.java b/backend/user-service/src/main/java/com/tadak/userservice/global/security/userdetail/CustomUserDetailsService.java new file mode 100644 index 0000000..5274d62 --- /dev/null +++ b/backend/user-service/src/main/java/com/tadak/userservice/global/security/userdetail/CustomUserDetailsService.java @@ -0,0 +1,64 @@ +package com.tadak.userservice.global.security.userdetail; + +import com.tadak.userservice.domain.member.entity.Member; +import com.tadak.userservice.domain.member.entity.State; +import com.tadak.userservice.domain.member.exception.MemberStateException; +import com.tadak.userservice.domain.member.repository.MemberRepository; +import com.tadak.userservice.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Collections; + +@RequiredArgsConstructor +@Component +@Slf4j +@Transactional +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return memberRepository.findByEmail(email) + .map(user -> createMember(email, user)) + .orElseThrow(() -> new UsernameNotFoundException(email + " -> 데이터베이스에서 찾을 수 없습니다.")); + } + + private UserDetails createMember(String username, Member member) { + if (!member.getState().equals(State.ACTIVE)) { + throw new MemberStateException(ErrorCode.NOT_VALID_MEMBER_STATE_ERROR); + } + +// List grantedAuthorities = member.getMemberAuthorities().stream() +// .map(authority -> new SimpleGrantedAuthority(authority.getAuthority().getAuthorityType())) +// .collect(Collectors.toList()); + + Collection authorities = getGrantedAuthorities(member); + + for (GrantedAuthority authority : authorities) { + log.info("authority = {}", authority); + } + + return new User(member.getEmail(), + member.getPassword(), + authorities); + } + + private static Collection getGrantedAuthorities(Member member) { + log.info("member getRole = {}", member.getRole().name()); + return Collections.singleton( +// new SimpleGrantedAuthority("ROLE_" + member.getRole().name()) + new SimpleGrantedAuthority(member.getRole().name()) + ); + } +} diff --git a/backend/user-service/src/main/resources/application.yml b/backend/user-service/src/main/resources/application.yml index b50c063..a83ad80 100644 --- a/backend/user-service/src/main/resources/application.yml +++ b/backend/user-service/src/main/resources/application.yml @@ -1,11 +1,16 @@ server: - port: 0 + port: 8001 spring: application: name: user-service profiles: active: local + include: secret + data: + redis: + host: localhost + port: 6379 eureka: instance: @@ -14,4 +19,15 @@ eureka: register-with-eureka: true fetch-registry: true service-url: - defaultZone: http://127.0.0.1:8761/eureka \ No newline at end of file + defaultZone: http://127.0.0.1:8761/eureka + +jwt: + header: Authorization + secret: WkhJMjVlYXo4V25xdGxaNW12WFdTRTZTamMwekt5V0FFNHVjdmFUOUM0R2tKQTVCTEw0WTlsYWo5OE5DdGpaWg== + access-validity-in-seconds: 3600 + refresh-validity-in-seconds: 604800 + +# logging +logging: + level: + org.springframework.security: trace \ No newline at end of file diff --git a/backend/webrtc-service/.gitignore b/backend/webrtc-service/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/backend/webrtc-service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/backend/webrtc-service/build.gradle b/backend/webrtc-service/build.gradle new file mode 100644 index 0000000..7ee77c1 --- /dev/null +++ b/backend/webrtc-service/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.2' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.tadak.signaling' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3' + + implementation 'org.projectlombok:lombok' + testImplementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/webrtc-service/gradle/wrapper/gradle-wrapper.jar b/backend/webrtc-service/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/backend/webrtc-service/gradle/wrapper/gradle-wrapper.properties b/backend/webrtc-service/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/backend/webrtc-service/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/webrtc-service/gradlew b/backend/webrtc-service/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/backend/webrtc-service/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/webrtc-service/gradlew.bat b/backend/webrtc-service/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/backend/webrtc-service/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/webrtc-service/settings.gradle b/backend/webrtc-service/settings.gradle new file mode 100644 index 0000000..096502d --- /dev/null +++ b/backend/webrtc-service/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'server' diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/ServerApplication.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/ServerApplication.java new file mode 100644 index 0000000..02c6bd8 --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/ServerApplication.java @@ -0,0 +1,13 @@ +package com.tadak.signaling.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ServerApplication { + + public static void main(String[] args) { + SpringApplication.run(ServerApplication.class, args); + } + +} diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/StompConfig.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/StompConfig.java new file mode 100644 index 0000000..5d00b7b --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/StompConfig.java @@ -0,0 +1,25 @@ +//package com.tadak.signaling.server.config; +// +//import org.springframework.context.annotation.Configuration; +//import org.springframework.messaging.simp.config.MessageBrokerRegistry; +//import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +//import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +//import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +// +//@Configuration +//@EnableWebSocketMessageBroker +//public class StompConfig implements WebSocketMessageBrokerConfigurer { +// @Override +// public void registerStompEndpoints(StompEndpointRegistry registry) { +// registry.addEndpoint("/stomp/signal") +// .setAllowedOriginPatterns("*") +// .withSockJS(); +// } +// +// @Override +// public void configureMessageBroker(MessageBrokerRegistry registry) { +// registry.setApplicationDestinationPrefixes("/pub"); +// registry.enableSimpleBroker("/sub"); +// +// } +//} diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/WebRtcConfig.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/WebRtcConfig.java new file mode 100644 index 0000000..7b8955c --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/config/WebRtcConfig.java @@ -0,0 +1,38 @@ +package com.tadak.signaling.server.config; + +import com.tadak.signaling.server.handler.SignalHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Controller; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; + +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class WebRtcConfig implements WebSocketConfigurer { + + private final SignalHandler signalHandler; + //signal로 요청이 왔을 때 아래의 websocketHandler가 동작하도록 설정 + // 요청은 클라가 접속,닫기 등에 대한 특정 메서드를 호출 + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(signalHandler,"/signal/{roomId}") + .setAllowedOrigins("*"); + registry.addHandler(signalHandler,"/socket.io") + .setAllowedOrigins("*"); + } + + // 웹 소켓에서 rtc 통신을 위한 최대 텍스트 버퍼와 바이너리 버퍼 사이즈를 설정함 + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); + container.setMaxTextMessageBufferSize(8192); + container.setMaxBinaryMessageBufferSize(8192); + return container; + } +} diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/controller/WebRtcController.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/controller/WebRtcController.java new file mode 100644 index 0000000..6a28c84 --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/controller/WebRtcController.java @@ -0,0 +1,18 @@ +//package com.tadak.signaling.server.controller; +// +// +//import com.tadak.signaling.server.domain.WebSocketMessage; +//import lombok.RequiredArgsConstructor; +//import org.springframework.messaging.handler.annotation.MessageMapping; +//import org.springframework.messaging.simp.SimpMessageSendingOperations; +//import org.springframework.stereotype.Controller; +// +//@Controller +//@RequiredArgsConstructor +//public class WebRtcController { +// private final SimpMessageSendingOperations template; +// @MessageMapping("join") +// public void join(WebSocketMessage message){ +// template.convertAndSend("/sub/"+message.getRoomId(),message); +// } +//} diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/domain/WebSocketMessage.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/domain/WebSocketMessage.java new file mode 100644 index 0000000..2650eac --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/domain/WebSocketMessage.java @@ -0,0 +1,19 @@ +package com.tadak.signaling.server.domain; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class WebSocketMessage { + private String fromUserId; //보내는 유저의 id + private String type; //메시지 타입 + private String roomId; + private Object candidate; //상태 + private Object sdp; //sdp 정보( sdp란 비디오의 해상도 , 오디오 전송 또는 수신 여부 등등) +} diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/dto/ChatroomDto.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/dto/ChatroomDto.java new file mode 100644 index 0000000..c740757 --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/dto/ChatroomDto.java @@ -0,0 +1,15 @@ +package com.tadak.signaling.server.dto; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.web.socket.WebSocketSession; + +import java.util.Map; + +@Getter +@Builder +public class ChatroomDto { + private String roomId; //채팅방 아이디 + //session = clients로 + private Map clients; +} diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/handler/SignalHandler.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/handler/SignalHandler.java new file mode 100644 index 0000000..21291c3 --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/handler/SignalHandler.java @@ -0,0 +1,142 @@ +package com.tadak.signaling.server.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tadak.signaling.server.domain.WebSocketMessage; +import com.tadak.signaling.server.dto.ChatroomDto; +import com.tadak.signaling.server.service.RtcService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class SignalHandler extends TextWebSocketHandler { + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + private final ObjectMapper objectMapper = new ObjectMapper(); + private Map rooms = new HashMap<>(); + + private final RtcService rtcService; + //messageType 정의 + //SDP 메시지 + private static final String MSG_TYPE_OFFER = "offer"; + private static final String MSG_TYPE_ANSWER = "answer"; + // ICE 메시지 + private static final String MSG_TYPE_ICE = "ice"; + // 데이터 타입 메시지 + private static final String MSG_TYPE_JOIN ="join"; + private static final String MSG_TYPE_LEAVE = "leave"; + + //소켓 연결 되었을 때 + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + try{ + + String roomId = session.getUri().toString().split("/signal/")[1]; + logger.info("[ws] 세션이 연결되었습니다 roomId:"+roomId); + WebSocketMessage webSocketMessage = WebSocketMessage.builder() + .fromUserId("Server") //임시 + .type(MSG_TYPE_JOIN) + .roomId(roomId) + .candidate(null) + .sdp(null) + .build(); + sendMessage(session,webSocketMessage); + //본인에게 websocket 메시지 발송 + }catch (Exception e){ + logger.debug("에러 발생:"+e.getMessage()); + } + } + + private void sendMessage(WebSocketSession session,WebSocketMessage message){ + try{ + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message))); + } + catch (Exception e){ + logger.info(e.getMessage()); + } + } //설정한 상대에게 WebSocketMessage를 전송하는 메소드 + //연결 끊어졌을 때 + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + logger.info("[ws] 세션이 끝났습니다 status :"+status+",session : "+ session); + } + + // 이 메시지를 통하여 ICE, SDP 통신이 일어난다. + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage textMessage) throws Exception { + //TextMessage는 웹소켓으로부터 전달받은 메시지 + try{ + //json을 WebSocketMessage로 변환 + WebSocketMessage message = objectMapper.readValue(textMessage.getPayload(),WebSocketMessage.class); + logger.info(message.getType()); + String userId = message.getFromUserId(); + String roomId = message.getRoomId(); + ChatroomDto findRoom = rooms.get(roomId); + + if(findRoom==null) { + Map clients = new HashMap<>(); + findRoom = ChatroomDto.builder() + .roomId(roomId) + .clients(clients) + .build(); + //임시적으로 방 만들기 + rooms.put(roomId,findRoom); + } + switch (message.getType()){ + case MSG_TYPE_OFFER: + case MSG_TYPE_ANSWER: + case MSG_TYPE_ICE: // 상대방을 찾는 상황 + Object candidate = message.getCandidate(); + Object sdp = message.getSdp(); + if(findRoom!=null){ + Map clients = rtcService.getClients(findRoom); + for( String client : clients.keySet()){ + if(!client.equals(userId)){ //본인이 아니라면 + logger.info("userId" +userId+" to clients:"+client); + sendMessage(clients.get(client) //메시지 재전송 + ,WebSocketMessage.builder() + .fromUserId(userId) + .type(message.getType()) + .roomId(roomId) + .candidate(candidate) + .sdp(sdp) + .build() + ); + } + } + } + break; + case MSG_TYPE_JOIN: + logger.info(userId+"가 방에 참여하였습니다. 방 번호: "+message.getRoomId()); + // room에 user 추가 + rtcService.addClients(findRoom,userId,session); + break; + + case MSG_TYPE_LEAVE: + Optional client = rtcService.getClients(findRoom).keySet().stream() + .filter(clientsKeys -> clientsKeys.equals(userId)) + .findAny(); + + //존재한다면 삭제 + if(client.isPresent()) rtcService.removeClient(findRoom,client.get()); + + logger.debug(userId+"가 방을 나갔습니다"); + break; + } + + + + }catch (Exception e){ + logger.info(e.getMessage()); + } + } +} diff --git a/backend/webrtc-service/src/main/java/com/tadak/signaling/server/service/RtcService.java b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/service/RtcService.java new file mode 100644 index 0000000..3980d5c --- /dev/null +++ b/backend/webrtc-service/src/main/java/com/tadak/signaling/server/service/RtcService.java @@ -0,0 +1,33 @@ +package com.tadak.signaling.server.service; + +import com.tadak.signaling.server.domain.WebSocketMessage; +import com.tadak.signaling.server.dto.ChatroomDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.WebSocketSession; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@Service +public class RtcService { + private final int MINIMUM_USER_COUNT_TO_START = 1; + // 여기에 Repository 설정 가능 + + public Map getClients(ChatroomDto room){ + return room.getClients(); + } + public Map addClients(ChatroomDto room,String userId,WebSocketSession session){ + Map users = room.getClients(); + users.put(userId,session); + return users; + } + public void removeClient(ChatroomDto room,String userId){ + room.getClients().remove(userId); + } + public boolean isUserCountMoreThan(ChatroomDto room){ + return room.getClients().size()>1; + } +} diff --git a/backend/webrtc-service/src/main/resources/application.properties b/backend/webrtc-service/src/main/resources/application.properties new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/webrtc-service/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/backend/webrtc-service/src/test/java/com/tadak/signaling/server/ServerApplicationTests.java b/backend/webrtc-service/src/test/java/com/tadak/signaling/server/ServerApplicationTests.java new file mode 100644 index 0000000..e5f53c8 --- /dev/null +++ b/backend/webrtc-service/src/test/java/com/tadak/signaling/server/ServerApplicationTests.java @@ -0,0 +1,13 @@ +package com.tadak.signaling.server; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ServerApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index f68ff82..1feada8 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -10,6 +10,7 @@ "prettier", "plugin:prettier/recommended", "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/parser", "plugin:react/recommended", "plugin:react-hooks/recommended", "plugin:jsx-a11y/recommended", @@ -19,7 +20,8 @@ "parserOptions": { "ecmaVersion": "latest", "sourceType": "module", - "project": "./tsconfig.json" + "project": "./tsconfig.json", + "createDefaultProgram": true }, "plugins": [ "prettier", diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..69258c8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,9 @@ dist-ssr *.njsproj *.sln *.sw? + +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json index 284db79..165495a 100644 --- a/frontend/.prettierrc.json +++ b/frontend/.prettierrc.json @@ -1,6 +1,6 @@ { "semi": true, - "printWidth": 80, + "printWidth": 120, "singleQuote": true, "useTabs": true, "tabWidth": 2, diff --git a/frontend/index.html b/frontend/index.html index e4b78ea..c82c9b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + @@ -9,5 +9,6 @@

+ diff --git a/frontend/package.json b/frontend/package.json index 51e0544..1b20a55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,42 +1,53 @@ { - "name": "vite-project", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" - }, - "dependencies": { - "@emotion/react": "^11.11.3", - "@tanstack/react-query": "^5.17.15", - "@tanstack/react-query-devtools": "^5.17.18", - "axios": "^1.6.5", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.21.3", - "zustand": "^4.5.0" - }, - "devDependencies": { - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@typescript-eslint/eslint-plugin": "^6.19.0", - "@typescript-eslint/parser": "^6.14.0", - "@vitejs/plugin-react": "^4.2.1", - "eslint": "^8.56.0", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-standard-with-typescript": "^43.0.0", - "eslint-plugin-import": "^2.25.2", - "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", - "eslint-plugin-promise": "^6.0.0", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "prettier": "^3.2.4", - "typescript": "^5.3.3", - "vite": "^5.0.8" - } + "name": "vite-project", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "format": "prettier --check --ignore-path .gitignore .", + "format:fix": "prettier --write --ignore-path .gitignore ." + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@stomp/stompjs": "^7.0.0", + "@tanstack/react-query": "^5.20.5", + "@tanstack/react-query-devtools": "^5.20.5", + "axios": "^1.6.7", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icon": "^1.0.0", + "react-icons": "^5.0.1", + "react-router-dom": "^6.22.0", + "react-toastify": "^10.0.4", + "simple-peer": "^9.11.1", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@types/stompjs": "^2.3.9", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.56.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^9.1.0", + "eslint-config-standard-with-typescript": "^43.0.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-n": "^16.6.2", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "prettier": "^3.2.5", + "typescript": "^5.3.3", + "vite": "^5.1.3" + } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c03c5fd..028df86 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,45 +7,66 @@ settings: dependencies: '@emotion/react': specifier: ^11.11.3 - version: 11.11.3(@types/react@18.2.48)(react@18.2.0) + version: 11.11.3(@types/react@18.2.55)(react@18.2.0) + '@emotion/styled': + specifier: ^11.11.0 + version: 11.11.0(@emotion/react@11.11.3)(@types/react@18.2.55)(react@18.2.0) + '@stomp/stompjs': + specifier: ^7.0.0 + version: 7.0.0 '@tanstack/react-query': - specifier: ^5.17.15 - version: 5.17.15(react@18.2.0) + specifier: ^5.20.5 + version: 5.20.5(react@18.2.0) '@tanstack/react-query-devtools': - specifier: ^5.17.18 - version: 5.17.18(@tanstack/react-query@5.17.15)(react@18.2.0) + specifier: ^5.20.5 + version: 5.20.5(@tanstack/react-query@5.20.5)(react@18.2.0) axios: - specifier: ^1.6.5 - version: 1.6.5 + specifier: ^1.6.7 + version: 1.6.7 react: specifier: ^18.2.0 version: 18.2.0 react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@18.2.0) + react-icons: + specifier: ^5.0.1 + version: 5.0.1(react@18.2.0) react-router-dom: - specifier: ^6.21.3 - version: 6.21.3(react-dom@18.2.0)(react@18.2.0) + specifier: ^6.22.0 + version: 6.22.0(react-dom@18.2.0)(react@18.2.0) + react-toastify: + specifier: ^10.0.4 + version: 10.0.4(react-dom@18.2.0)(react@18.2.0) + simple-peer: + specifier: ^9.11.1 + version: 9.11.1 zustand: specifier: ^4.5.0 - version: 4.5.0(@types/react@18.2.48)(react@18.2.0) + version: 4.5.0(@types/react@18.2.55)(react@18.2.0) devDependencies: '@types/react': - specifier: ^18.2.43 - version: 18.2.48 + specifier: ^18.2.55 + version: 18.2.55 '@types/react-dom': - specifier: ^18.2.17 - version: 18.2.18 + specifier: ^18.2.19 + version: 18.2.19 + '@types/stompjs': + specifier: ^2.3.9 + version: 2.3.9 '@typescript-eslint/eslint-plugin': - specifier: ^6.19.0 - version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3) + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': - specifier: ^6.14.0 - version: 6.19.0(eslint@8.56.0)(typescript@5.3.3) + specifier: ^6.21.0 + version: 6.21.0(eslint@8.56.0)(typescript@5.3.3) '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.2.1(vite@5.0.12) + version: 4.2.1(vite@5.1.3) eslint: specifier: ^8.56.0 version: 8.56.0 @@ -54,18 +75,24 @@ devDependencies: version: 19.0.4(eslint-plugin-import@2.29.1)(eslint-plugin-jsx-a11y@6.8.0)(eslint-plugin-react-hooks@4.6.0)(eslint-plugin-react@7.33.2)(eslint@8.56.0) eslint-config-airbnb-typescript: specifier: ^17.1.0 - version: 17.1.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.19.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0) + version: 17.1.0(@typescript-eslint/eslint-plugin@6.21.0)(@typescript-eslint/parser@6.21.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.56.0) eslint-config-standard-with-typescript: - specifier: ^43.0.0 - version: 43.0.0(@typescript-eslint/eslint-plugin@6.19.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + specifier: ^43.0.1 + version: 43.0.1(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) eslint-plugin-import: - specifier: ^2.25.2 - version: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0) + specifier: ^2.29.1 + version: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0) eslint-plugin-n: - specifier: '^15.0.0 || ^16.0.0 ' + specifier: ^16.6.2 version: 16.6.2(eslint@8.56.0) + eslint-plugin-prettier: + specifier: ^5.1.3 + version: 5.1.3(eslint-config-prettier@9.1.0)(eslint@8.56.0)(prettier@3.2.5) eslint-plugin-promise: - specifier: ^6.0.0 + specifier: ^6.1.1 version: 6.1.1(eslint@8.56.0) eslint-plugin-react: specifier: ^7.33.2 @@ -77,14 +104,14 @@ devDependencies: specifier: ^0.4.5 version: 0.4.5(eslint@8.56.0) prettier: - specifier: ^3.2.4 - version: 3.2.4 + specifier: ^3.2.5 + version: 3.2.5 typescript: specifier: ^5.3.3 version: 5.3.3 vite: - specifier: ^5.0.8 - version: 5.0.12 + specifier: ^5.1.3 + version: 5.1.3 packages: @@ -113,20 +140,20 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/core@7.23.7: - resolution: {integrity: sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==} + /@babel/core@7.23.9: + resolution: {integrity: sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==} engines: {node: '>=6.9.0'} dependencies: '@ampproject/remapping': 2.2.1 '@babel/code-frame': 7.23.5 '@babel/generator': 7.23.6 '@babel/helper-compilation-targets': 7.23.6 - '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.7) - '@babel/helpers': 7.23.8 - '@babel/parser': 7.23.6 - '@babel/template': 7.22.15 - '@babel/traverse': 7.23.7 - '@babel/types': 7.23.6 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.9) + '@babel/helpers': 7.23.9 + '@babel/parser': 7.23.9 + '@babel/template': 7.23.9 + '@babel/traverse': 7.23.9 + '@babel/types': 7.23.9 convert-source-map: 2.0.0 debug: 4.3.4 gensync: 1.0.0-beta.2 @@ -140,7 +167,7 @@ packages: resolution: {integrity: sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 '@jridgewell/gen-mapping': 0.3.3 '@jridgewell/trace-mapping': 0.3.22 jsesc: 2.5.2 @@ -152,7 +179,7 @@ packages: dependencies: '@babel/compat-data': 7.23.5 '@babel/helper-validator-option': 7.23.5 - browserslist: 4.22.2 + browserslist: 4.23.0 lru-cache: 5.1.1 semver: 6.3.1 dev: true @@ -166,30 +193,30 @@ packages: resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/template': 7.22.15 - '@babel/types': 7.23.6 + '@babel/template': 7.23.9 + '@babel/types': 7.23.9 dev: true /@babel/helper-hoist-variables@7.22.5: resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 dev: true /@babel/helper-module-imports@7.22.15: resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 - /@babel/helper-module-transforms@7.23.3(@babel/core@7.23.7): + /@babel/helper-module-transforms@7.23.3(@babel/core@7.23.9): resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 dependencies: - '@babel/core': 7.23.7 + '@babel/core': 7.23.9 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-module-imports': 7.22.15 '@babel/helper-simple-access': 7.22.5 @@ -206,14 +233,14 @@ packages: resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 dev: true /@babel/helper-split-export-declaration@7.22.6: resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 dev: true /@babel/helper-string-parser@7.23.4: @@ -229,13 +256,13 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/helpers@7.23.8: - resolution: {integrity: sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==} + /@babel/helpers@7.23.9: + resolution: {integrity: sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/template': 7.22.15 - '@babel/traverse': 7.23.7 - '@babel/types': 7.23.6 + '@babel/template': 7.23.9 + '@babel/traverse': 7.23.9 + '@babel/types': 7.23.9 transitivePeerDependencies: - supports-color dev: true @@ -248,51 +275,51 @@ packages: chalk: 2.4.2 js-tokens: 4.0.0 - /@babel/parser@7.23.6: - resolution: {integrity: sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==} + /@babel/parser@7.23.9: + resolution: {integrity: sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==} engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 dev: true - /@babel/plugin-transform-react-jsx-self@7.23.3(@babel/core@7.23.7): + /@babel/plugin-transform-react-jsx-self@7.23.3(@babel/core@7.23.9): resolution: {integrity: sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.7 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-transform-react-jsx-source@7.23.3(@babel/core@7.23.7): + /@babel/plugin-transform-react-jsx-source@7.23.3(@babel/core@7.23.9): resolution: {integrity: sha512-91RS0MDnAWDNvGC6Wio5XYkyWI39FMFO+JK9+4AlgaTH+yWwVTsw7/sn6LK0lH7c5F+TFkpv/3LfCJ1Ydwof/g==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.7 + '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/runtime@7.23.8: - resolution: {integrity: sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==} + /@babel/runtime@7.23.9: + resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==} engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.1 - /@babel/template@7.22.15: - resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} + /@babel/template@7.23.9: + resolution: {integrity: sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==} engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.23.5 - '@babel/parser': 7.23.6 - '@babel/types': 7.23.6 + '@babel/parser': 7.23.9 + '@babel/types': 7.23.9 dev: true - /@babel/traverse@7.23.7: - resolution: {integrity: sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==} + /@babel/traverse@7.23.9: + resolution: {integrity: sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==} engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.23.5 @@ -301,16 +328,16 @@ packages: '@babel/helper-function-name': 7.23.0 '@babel/helper-hoist-variables': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - '@babel/parser': 7.23.6 - '@babel/types': 7.23.6 + '@babel/parser': 7.23.9 + '@babel/types': 7.23.9 debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color dev: true - /@babel/types@7.23.6: - resolution: {integrity: sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==} + /@babel/types@7.23.9: + resolution: {integrity: sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==} engines: {node: '>=6.9.0'} dependencies: '@babel/helper-string-parser': 7.23.4 @@ -321,7 +348,7 @@ packages: resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} dependencies: '@babel/helper-module-imports': 7.22.15 - '@babel/runtime': 7.23.8 + '@babel/runtime': 7.23.9 '@emotion/hash': 0.9.1 '@emotion/memoize': 0.8.1 '@emotion/serialize': 1.1.3 @@ -347,11 +374,17 @@ packages: resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} dev: false + /@emotion/is-prop-valid@1.2.1: + resolution: {integrity: sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==} + dependencies: + '@emotion/memoize': 0.8.1 + dev: false + /@emotion/memoize@0.8.1: resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} dev: false - /@emotion/react@11.11.3(@types/react@18.2.48)(react@18.2.0): + /@emotion/react@11.11.3(@types/react@18.2.55)(react@18.2.0): resolution: {integrity: sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==} peerDependencies: '@types/react': '*' @@ -360,14 +393,14 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.23.8 + '@babel/runtime': 7.23.9 '@emotion/babel-plugin': 11.11.0 '@emotion/cache': 11.11.0 '@emotion/serialize': 1.1.3 '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) '@emotion/utils': 1.2.1 '@emotion/weak-memoize': 0.3.1 - '@types/react': 18.2.48 + '@types/react': 18.2.55 hoist-non-react-statics: 3.3.2 react: 18.2.0 dev: false @@ -386,6 +419,27 @@ packages: resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==} dev: false + /@emotion/styled@11.11.0(@emotion/react@11.11.3)(@types/react@18.2.55)(react@18.2.0): + resolution: {integrity: sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@emotion/babel-plugin': 11.11.0 + '@emotion/is-prop-valid': 1.2.1 + '@emotion/react': 11.11.3(@types/react@18.2.55)(react@18.2.0) + '@emotion/serialize': 1.1.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@types/react': 18.2.55 + react: 18.2.0 + dev: false + /@emotion/unitless@0.8.1: resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} dev: false @@ -406,8 +460,8 @@ packages: resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==} dev: false - /@esbuild/aix-ppc64@0.19.11: - resolution: {integrity: sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==} + /@esbuild/aix-ppc64@0.19.12: + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} engines: {node: '>=12'} cpu: [ppc64] os: [aix] @@ -415,8 +469,8 @@ packages: dev: true optional: true - /@esbuild/android-arm64@0.19.11: - resolution: {integrity: sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==} + /@esbuild/android-arm64@0.19.12: + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} engines: {node: '>=12'} cpu: [arm64] os: [android] @@ -424,8 +478,8 @@ packages: dev: true optional: true - /@esbuild/android-arm@0.19.11: - resolution: {integrity: sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==} + /@esbuild/android-arm@0.19.12: + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} engines: {node: '>=12'} cpu: [arm] os: [android] @@ -433,8 +487,8 @@ packages: dev: true optional: true - /@esbuild/android-x64@0.19.11: - resolution: {integrity: sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==} + /@esbuild/android-x64@0.19.12: + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} engines: {node: '>=12'} cpu: [x64] os: [android] @@ -442,8 +496,8 @@ packages: dev: true optional: true - /@esbuild/darwin-arm64@0.19.11: - resolution: {integrity: sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==} + /@esbuild/darwin-arm64@0.19.12: + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] @@ -451,8 +505,8 @@ packages: dev: true optional: true - /@esbuild/darwin-x64@0.19.11: - resolution: {integrity: sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==} + /@esbuild/darwin-x64@0.19.12: + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} engines: {node: '>=12'} cpu: [x64] os: [darwin] @@ -460,8 +514,8 @@ packages: dev: true optional: true - /@esbuild/freebsd-arm64@0.19.11: - resolution: {integrity: sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==} + /@esbuild/freebsd-arm64@0.19.12: + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] @@ -469,8 +523,8 @@ packages: dev: true optional: true - /@esbuild/freebsd-x64@0.19.11: - resolution: {integrity: sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==} + /@esbuild/freebsd-x64@0.19.12: + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] @@ -478,8 +532,8 @@ packages: dev: true optional: true - /@esbuild/linux-arm64@0.19.11: - resolution: {integrity: sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==} + /@esbuild/linux-arm64@0.19.12: + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} engines: {node: '>=12'} cpu: [arm64] os: [linux] @@ -487,8 +541,8 @@ packages: dev: true optional: true - /@esbuild/linux-arm@0.19.11: - resolution: {integrity: sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==} + /@esbuild/linux-arm@0.19.12: + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} engines: {node: '>=12'} cpu: [arm] os: [linux] @@ -496,8 +550,8 @@ packages: dev: true optional: true - /@esbuild/linux-ia32@0.19.11: - resolution: {integrity: sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==} + /@esbuild/linux-ia32@0.19.12: + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} engines: {node: '>=12'} cpu: [ia32] os: [linux] @@ -505,8 +559,8 @@ packages: dev: true optional: true - /@esbuild/linux-loong64@0.19.11: - resolution: {integrity: sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==} + /@esbuild/linux-loong64@0.19.12: + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} engines: {node: '>=12'} cpu: [loong64] os: [linux] @@ -514,8 +568,8 @@ packages: dev: true optional: true - /@esbuild/linux-mips64el@0.19.11: - resolution: {integrity: sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==} + /@esbuild/linux-mips64el@0.19.12: + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] @@ -523,8 +577,8 @@ packages: dev: true optional: true - /@esbuild/linux-ppc64@0.19.11: - resolution: {integrity: sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==} + /@esbuild/linux-ppc64@0.19.12: + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] @@ -532,8 +586,8 @@ packages: dev: true optional: true - /@esbuild/linux-riscv64@0.19.11: - resolution: {integrity: sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==} + /@esbuild/linux-riscv64@0.19.12: + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] @@ -541,8 +595,8 @@ packages: dev: true optional: true - /@esbuild/linux-s390x@0.19.11: - resolution: {integrity: sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==} + /@esbuild/linux-s390x@0.19.12: + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} engines: {node: '>=12'} cpu: [s390x] os: [linux] @@ -550,8 +604,8 @@ packages: dev: true optional: true - /@esbuild/linux-x64@0.19.11: - resolution: {integrity: sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==} + /@esbuild/linux-x64@0.19.12: + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} engines: {node: '>=12'} cpu: [x64] os: [linux] @@ -559,8 +613,8 @@ packages: dev: true optional: true - /@esbuild/netbsd-x64@0.19.11: - resolution: {integrity: sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==} + /@esbuild/netbsd-x64@0.19.12: + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] @@ -568,8 +622,8 @@ packages: dev: true optional: true - /@esbuild/openbsd-x64@0.19.11: - resolution: {integrity: sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==} + /@esbuild/openbsd-x64@0.19.12: + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] @@ -577,8 +631,8 @@ packages: dev: true optional: true - /@esbuild/sunos-x64@0.19.11: - resolution: {integrity: sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==} + /@esbuild/sunos-x64@0.19.12: + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} engines: {node: '>=12'} cpu: [x64] os: [sunos] @@ -586,8 +640,8 @@ packages: dev: true optional: true - /@esbuild/win32-arm64@0.19.11: - resolution: {integrity: sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==} + /@esbuild/win32-arm64@0.19.12: + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] @@ -595,8 +649,8 @@ packages: dev: true optional: true - /@esbuild/win32-ia32@0.19.11: - resolution: {integrity: sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==} + /@esbuild/win32-ia32@0.19.12: + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} engines: {node: '>=12'} cpu: [ia32] os: [win32] @@ -604,8 +658,8 @@ packages: dev: true optional: true - /@esbuild/win32-x64@0.19.11: - resolution: {integrity: sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==} + /@esbuild/win32-x64@0.19.12: + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} engines: {node: '>=12'} cpu: [x64] os: [win32] @@ -636,7 +690,7 @@ packages: debug: 4.3.4 espree: 9.6.1 globals: 13.24.0 - ignore: 5.3.0 + ignore: 5.3.1 import-fresh: 3.3.0 js-yaml: 4.1.0 minimatch: 3.1.2 @@ -679,8 +733,8 @@ packages: '@jridgewell/trace-mapping': 0.3.22 dev: true - /@jridgewell/resolve-uri@3.1.1: - resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} dev: true @@ -696,7 +750,7 @@ packages: /@jridgewell/trace-mapping@0.3.22: resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} dependencies: - '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 dev: true @@ -718,151 +772,160 @@ packages: engines: {node: '>= 8'} dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.16.0 + fastq: 1.17.1 dev: true - /@remix-run/router@1.14.2: - resolution: {integrity: sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==} + /@pkgr/core@0.1.1: + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + dev: true + + /@remix-run/router@1.15.0: + resolution: {integrity: sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ==} engines: {node: '>=14.0.0'} dev: false - /@rollup/rollup-android-arm-eabi@4.9.5: - resolution: {integrity: sha512-idWaG8xeSRCfRq9KpRysDHJ/rEHBEXcHuJ82XY0yYFIWnLMjZv9vF/7DOq8djQ2n3Lk6+3qfSH8AqlmHlmi1MA==} + /@rollup/rollup-android-arm-eabi@4.11.0: + resolution: {integrity: sha512-BV+u2QSfK3i1o6FucqJh5IK9cjAU6icjFFhvknzFgu472jzl0bBojfDAkJLBEsHFMo+YZg6rthBvBBt8z12IBQ==} cpu: [arm] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-android-arm64@4.9.5: - resolution: {integrity: sha512-f14d7uhAMtsCGjAYwZGv6TwuS3IFaM4ZnGMUn3aCBgkcHAYErhV1Ad97WzBvS2o0aaDv4mVz+syiN0ElMyfBPg==} + /@rollup/rollup-android-arm64@4.11.0: + resolution: {integrity: sha512-0ij3iw7sT5jbcdXofWO2NqDNjSVVsf6itcAkV2I6Xsq4+6wjW1A8rViVB67TfBEan7PV2kbLzT8rhOVWLI2YXw==} cpu: [arm64] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-arm64@4.9.5: - resolution: {integrity: sha512-ndoXeLx455FffL68OIUrVr89Xu1WLzAG4n65R8roDlCoYiQcGGg6MALvs2Ap9zs7AHg8mpHtMpwC8jBBjZrT/w==} + /@rollup/rollup-darwin-arm64@4.11.0: + resolution: {integrity: sha512-yPLs6RbbBMupArf6qv1UDk6dzZvlH66z6NLYEwqTU0VHtss1wkI4UYeeMS7TVj5QRVvaNAWYKP0TD/MOeZ76Zg==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-x64@4.9.5: - resolution: {integrity: sha512-UmElV1OY2m/1KEEqTlIjieKfVwRg0Zwg4PLgNf0s3glAHXBN99KLpw5A5lrSYCa1Kp63czTpVll2MAqbZYIHoA==} + /@rollup/rollup-darwin-x64@4.11.0: + resolution: {integrity: sha512-OvqIgwaGAwnASzXaZEeoJY3RltOFg+WUbdkdfoluh2iqatd090UeOG3A/h0wNZmE93dDew9tAtXgm3/+U/B6bw==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.9.5: - resolution: {integrity: sha512-Q0LcU61v92tQB6ae+udZvOyZ0wfpGojtAKrrpAaIqmJ7+psq4cMIhT/9lfV6UQIpeItnq/2QDROhNLo00lOD1g==} + /@rollup/rollup-linux-arm-gnueabihf@4.11.0: + resolution: {integrity: sha512-X17s4hZK3QbRmdAuLd2EE+qwwxL8JxyVupEqAkxKPa/IgX49ZO+vf0ka69gIKsaYeo6c1CuwY3k8trfDtZ9dFg==} cpu: [arm] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-gnu@4.9.5: - resolution: {integrity: sha512-dkRscpM+RrR2Ee3eOQmRWFjmV/payHEOrjyq1VZegRUa5OrZJ2MAxBNs05bZuY0YCtpqETDy1Ix4i/hRqX98cA==} + /@rollup/rollup-linux-arm64-gnu@4.11.0: + resolution: {integrity: sha512-673Lu9EJwxVB9NfYeA4AdNu0FOHz7g9t6N1DmT7bZPn1u6bTF+oZjj+fuxUcrfxWXE0r2jxl5QYMa9cUOj9NFg==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-musl@4.9.5: - resolution: {integrity: sha512-QaKFVOzzST2xzY4MAmiDmURagWLFh+zZtttuEnuNn19AiZ0T3fhPyjPPGwLNdiDT82ZE91hnfJsUiDwF9DClIQ==} + /@rollup/rollup-linux-arm64-musl@4.11.0: + resolution: {integrity: sha512-yFW2msTAQNpPJaMmh2NpRalr1KXI7ZUjlN6dY/FhWlOclMrZezm5GIhy3cP4Ts2rIAC+IPLAjNibjp1BsxCVGg==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-riscv64-gnu@4.9.5: - resolution: {integrity: sha512-HeGqmRJuyVg6/X6MpE2ur7GbymBPS8Np0S/vQFHDmocfORT+Zt76qu+69NUoxXzGqVP1pzaY6QIi0FJWLC3OPA==} + /@rollup/rollup-linux-riscv64-gnu@4.11.0: + resolution: {integrity: sha512-kKT9XIuhbvYgiA3cPAGntvrBgzhWkGpBMzuk1V12Xuoqg7CI41chye4HU0vLJnGf9MiZzfNh4I7StPeOzOWJfA==} cpu: [riscv64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-gnu@4.9.5: - resolution: {integrity: sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==} + /@rollup/rollup-linux-x64-gnu@4.11.0: + resolution: {integrity: sha512-6q4ESWlyTO+erp1PSCmASac+ixaDv11dBk1fqyIuvIUc/CmRAX2Zk+2qK1FGo5q7kyDcjHCFVwgGFCGIZGVwCA==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-musl@4.9.5: - resolution: {integrity: sha512-ezyFUOwldYpj7AbkwyW9AJ203peub81CaAIVvckdkyH8EvhEIoKzaMFJj0G4qYJ5sw3BpqhFrsCc30t54HV8vg==} + /@rollup/rollup-linux-x64-musl@4.11.0: + resolution: {integrity: sha512-vIAQUmXeMLmaDN78HSE4Kh6xqof2e3TJUKr+LPqXWU4NYNON0MDN9h2+t4KHrPAQNmU3w1GxBQ/n01PaWFwa5w==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-arm64-msvc@4.9.5: - resolution: {integrity: sha512-aHSsMnUw+0UETB0Hlv7B/ZHOGY5bQdwMKJSzGfDfvyhnpmVxLMGnQPGNE9wgqkLUs3+gbG1Qx02S2LLfJ5GaRQ==} + /@rollup/rollup-win32-arm64-msvc@4.11.0: + resolution: {integrity: sha512-LVXo9dDTGPr0nezMdqa1hK4JeoMZ02nstUxGYY/sMIDtTYlli1ZxTXBYAz3vzuuvKO4X6NBETciIh7N9+abT1g==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-ia32-msvc@4.9.5: - resolution: {integrity: sha512-AiqiLkb9KSf7Lj/o1U3SEP9Zn+5NuVKgFdRIZkvd4N0+bYrTOovVd0+LmYCPQGbocT4kvFyK+LXCDiXPBF3fyA==} + /@rollup/rollup-win32-ia32-msvc@4.11.0: + resolution: {integrity: sha512-xZVt6K70Gr3I7nUhug2dN6VRR1ibot3rXqXS3wo+8JP64t7djc3lBFyqO4GiVrhNaAIhUCJtwQ/20dr0h0thmQ==} cpu: [ia32] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-x64-msvc@4.9.5: - resolution: {integrity: sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ==} + /@rollup/rollup-win32-x64-msvc@4.11.0: + resolution: {integrity: sha512-f3I7h9oTg79UitEco9/2bzwdciYkWr8pITs3meSDSlr1TdvQ7IxkQaaYN2YqZXX5uZhiYL+VuYDmHwNzhx+HOg==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /@tanstack/query-core@5.17.15: - resolution: {integrity: sha512-QURxpu77/ICA4d61aPvV7EcJ2MwmksxUejKBaq/xLcO2TUJAlXf4PFKHC/WxnVFI/7F1jeLx85AO3Vpk0+uBXw==} + /@stomp/stompjs@7.0.0: + resolution: {integrity: sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==} + dev: false + + /@tanstack/query-core@5.20.5: + resolution: {integrity: sha512-T1W28gGgWn0A++tH3lxj3ZuUVZZorsiKcv+R50RwmPYz62YoDEkG4/aXHZELGkRp4DfrW07dyq2K5dvJ4Wl1aA==} dev: false - /@tanstack/query-devtools@5.17.7: - resolution: {integrity: sha512-TfgvOqza5K7Sk6slxqkRIvXlEJoUoPSsGGwpuYSrpqgSwLSSvPPpZhq7hv7hcY5IvRoTNGoq6+MT01C/jILqoQ==} + /@tanstack/query-devtools@5.20.2: + resolution: {integrity: sha512-BZfSjhk/NGPbqte5E3Vc1Zbj28uWt///4I0DgzAdWrOtMVvdl0WlUXK23K2daLsbcyfoDR4jRI4f2Z5z/mMzuw==} dev: false - /@tanstack/react-query-devtools@5.17.18(@tanstack/react-query@5.17.15)(react@18.2.0): - resolution: {integrity: sha512-La1+8aKacGBZ4qvEdXx6ugVbHhm48fC/aBKfaHIkVB6lWKGiYq0pJm+kmzcRKsHmVz5BwfFoz6HCCnR5kByPnw==} + /@tanstack/react-query-devtools@5.20.5(@tanstack/react-query@5.20.5)(react@18.2.0): + resolution: {integrity: sha512-Wl7IzNuKCb4h41a5iH/YXNwalHItqJPCAr4r8+0iUYOLHNOf3E9P0G4kzZ9sqDoWKxY04qst6Vrij9bwPzLQRQ==} peerDependencies: - '@tanstack/react-query': ^5.17.15 + '@tanstack/react-query': ^5.20.5 react: ^18.0.0 dependencies: - '@tanstack/query-devtools': 5.17.7 - '@tanstack/react-query': 5.17.15(react@18.2.0) + '@tanstack/query-devtools': 5.20.2 + '@tanstack/react-query': 5.20.5(react@18.2.0) react: 18.2.0 dev: false - /@tanstack/react-query@5.17.15(react@18.2.0): - resolution: {integrity: sha512-9qur91mOihaUN7pXm6ioDtS+4qgkBcCiIaZyvi3lZNcQZsrMGCYZ+eP3hiFrV4khoJyJrFUX1W0NcCVlgwNZxQ==} + /@tanstack/react-query@5.20.5(react@18.2.0): + resolution: {integrity: sha512-6MHwJ8G9cnOC/XKrwt56QMc91vN7hLlAQNUA0ubP7h9Jj3a/CmkUwT6ALdFbnVP+PsYdhW3WONa8WQ4VcTaSLQ==} peerDependencies: react: ^18.0.0 dependencies: - '@tanstack/query-core': 5.17.15 + '@tanstack/query-core': 5.20.5 react: 18.2.0 dev: false /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: - '@babel/parser': 7.23.6 - '@babel/types': 7.23.6 + '@babel/parser': 7.23.9 + '@babel/types': 7.23.9 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.5 @@ -871,20 +934,20 @@ packages: /@types/babel__generator@7.6.8: resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 dev: true /@types/babel__template@7.4.4: resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} dependencies: - '@babel/parser': 7.23.6 - '@babel/types': 7.23.6 + '@babel/parser': 7.23.9 + '@babel/types': 7.23.9 dev: true /@types/babel__traverse@7.20.5: resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} dependencies: - '@babel/types': 7.23.6 + '@babel/types': 7.23.9 dev: true /@types/estree@1.0.5: @@ -899,6 +962,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/node@20.11.19: + resolution: {integrity: sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==} + dependencies: + undici-types: 5.26.5 + dev: true + /@types/parse-json@4.0.2: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} dev: false @@ -906,14 +975,14 @@ packages: /@types/prop-types@15.7.11: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} - /@types/react-dom@18.2.18: - resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==} + /@types/react-dom@18.2.19: + resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==} dependencies: - '@types/react': 18.2.48 + '@types/react': 18.2.55 dev: true - /@types/react@18.2.48: - resolution: {integrity: sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==} + /@types/react@18.2.55: + resolution: {integrity: sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==} dependencies: '@types/prop-types': 15.7.11 '@types/scheduler': 0.16.8 @@ -922,12 +991,18 @@ packages: /@types/scheduler@0.16.8: resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} - /@types/semver@7.5.6: - resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} + /@types/semver@7.5.7: + resolution: {integrity: sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==} + dev: true + + /@types/stompjs@2.3.9: + resolution: {integrity: sha512-fu/GgkRdxwyEJ+JeUsGhDxGwmZQi+xeNElradGQ4ehWiG2z/o89gsi5Y7Gv0KC6VK1v78Cjh8zj3VF+RvqCGSA==} + dependencies: + '@types/node': 20.11.19 dev: true - /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==} + /@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -938,25 +1013,25 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/scope-manager': 6.19.0 - '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/visitor-keys': 6.19.0 + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 eslint: 8.56.0 graphemer: 1.4.0 - ignore: 5.3.0 + ignore: 5.3.1 natural-compare: 1.4.0 - semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.3.3) + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==} + /@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -965,10 +1040,10 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.19.0 - '@typescript-eslint/types': 6.19.0 - '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) - '@typescript-eslint/visitor-keys': 6.19.0 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 eslint: 8.56.0 typescript: 5.3.3 @@ -976,16 +1051,16 @@ packages: - supports-color dev: true - /@typescript-eslint/scope-manager@6.19.0: - resolution: {integrity: sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==} + /@typescript-eslint/scope-manager@6.21.0: + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.19.0 - '@typescript-eslint/visitor-keys': 6.19.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 dev: true - /@typescript-eslint/type-utils@6.19.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==} + /@typescript-eslint/type-utils@6.21.0(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -994,23 +1069,23 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) debug: 4.3.4 eslint: 8.56.0 - ts-api-utils: 1.0.3(typescript@5.3.3) + ts-api-utils: 1.2.1(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types@6.19.0: - resolution: {integrity: sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==} + /@typescript-eslint/types@6.21.0: + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.19.0(typescript@5.3.3): - resolution: {integrity: sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==} + /@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3): + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -1018,43 +1093,43 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.19.0 - '@typescript-eslint/visitor-keys': 6.19.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 - semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.3.3) + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@6.19.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==} + /@typescript-eslint/utils@6.21.0(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) '@types/json-schema': 7.0.15 - '@types/semver': 7.5.6 - '@typescript-eslint/scope-manager': 6.19.0 - '@typescript-eslint/types': 6.19.0 - '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) + '@types/semver': 7.5.7 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) eslint: 8.56.0 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - supports-color - typescript dev: true - /@typescript-eslint/visitor-keys@6.19.0: - resolution: {integrity: sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==} + /@typescript-eslint/visitor-keys@6.21.0: + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.19.0 + '@typescript-eslint/types': 6.21.0 eslint-visitor-keys: 3.4.3 dev: true @@ -1062,18 +1137,18 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true - /@vitejs/plugin-react@4.2.1(vite@5.0.12): + /@vitejs/plugin-react@4.2.1(vite@5.1.3): resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 dependencies: - '@babel/core': 7.23.7 - '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.7) - '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.7) + '@babel/core': 7.23.9 + '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.9) + '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.9) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 - vite: 5.0.12 + vite: 5.1.3 transitivePeerDependencies: - supports-color dev: true @@ -1129,21 +1204,22 @@ packages: dequal: 2.0.3 dev: true - /array-buffer-byte-length@1.0.0: - resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} + /array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - is-array-buffer: 3.0.2 + call-bind: 1.0.7 + is-array-buffer: 3.0.4 dev: true /array-includes@3.1.7: resolution: {integrity: sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 - get-intrinsic: 1.2.2 + es-abstract: 1.22.4 + get-intrinsic: 1.2.4 is-string: 1.0.7 dev: true @@ -1152,24 +1228,47 @@ packages: engines: {node: '>=8'} dev: true - /array.prototype.findlastindex@1.2.3: - resolution: {integrity: sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==} + /array.prototype.filter@1.0.3: + resolution: {integrity: sha512-VizNcj/RGJiUyQBgzwxzE5oHdeuXY5hSbbmKMlphj1cy1Vl7Pn2asCGbSrru6hSQjmCzqTBPVWAF/whmEOVHbw==} + + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.22.4 + es-array-method-boxes-properly: 1.0.0 + is-string: 1.0.7 + dev: true + + /array.prototype.findlastindex@1.2.4: + resolution: {integrity: sha512-hzvSHUshSpCflDR1QMUBLHGHP1VIEBegT4pix9H/Z92Xw3ySoy6c2qh7lJWTJnRJ8JCZ9bJNCgTyYaJGcJu6xQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.22.4 + es-array-method-boxes-properly: 1.0.0 + is-string: 1.0.7 + dev: true + + /array.prototype.findlastindex@1.2.4: + resolution: {integrity: sha512-hzvSHUshSpCflDR1QMUBLHGHP1VIEBegT4pix9H/Z92Xw3ySoy6c2qh7lJWTJnRJ8JCZ9bJNCgTyYaJGcJu6xQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 + es-errors: 1.3.0 es-shim-unscopables: 1.0.2 - get-intrinsic: 1.2.2 dev: true /array.prototype.flat@1.3.2: resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 es-shim-unscopables: 1.0.2 dev: true @@ -1177,32 +1276,33 @@ packages: resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 es-shim-unscopables: 1.0.2 dev: true - /array.prototype.tosorted@1.1.2: - resolution: {integrity: sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==} + /array.prototype.tosorted@1.1.3: + resolution: {integrity: sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 + es-errors: 1.3.0 es-shim-unscopables: 1.0.2 - get-intrinsic: 1.2.2 dev: true - /arraybuffer.prototype.slice@1.0.2: - resolution: {integrity: sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==} + /arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} engines: {node: '>= 0.4'} dependencies: - array-buffer-byte-length: 1.0.0 - call-bind: 1.0.5 + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 - get-intrinsic: 1.2.2 - is-array-buffer: 3.0.2 + es-abstract: 1.22.4 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.2 dev: true @@ -1220,8 +1320,8 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false - /available-typed-arrays@1.0.5: - resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} + /available-typed-arrays@1.0.6: + resolution: {integrity: sha512-j1QzY8iPNPG4o4xmO3ptzpRxTciqD3MgEHtifP/YnJpIo58Xu+ne4BejlbkuaLfXn/nz6HFiw29bLpj2PNMdGg==} engines: {node: '>= 0.4'} dev: true @@ -1230,8 +1330,8 @@ packages: engines: {node: '>=4'} dev: true - /axios@1.6.5: - resolution: {integrity: sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==} + /axios@1.6.7: + resolution: {integrity: sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==} dependencies: follow-redirects: 1.15.5 form-data: 4.0.0 @@ -1250,15 +1350,25 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} dependencies: - '@babel/runtime': 7.23.8 + '@babel/runtime': 7.23.9 cosmiconfig: 7.1.0 resolve: 1.22.8 dev: false + /babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + dependencies: + core-js: 1.2.7 + dev: false + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: false + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -1279,17 +1389,24 @@ packages: fill-range: 7.0.1 dev: true - /browserslist@4.22.2: - resolution: {integrity: sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==} + /browserslist@4.23.0: + resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001579 - electron-to-chromium: 1.4.640 + caniuse-lite: 1.0.30001587 + electron-to-chromium: 1.4.672 node-releases: 2.0.14 - update-browserslist-db: 1.0.13(browserslist@4.22.2) + update-browserslist-db: 1.0.13(browserslist@4.23.0) dev: true + /buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: false + /builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -1298,23 +1415,26 @@ packages: /builtins@5.0.1: resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} dependencies: - semver: 7.5.4 + semver: 7.6.0 dev: true - /call-bind@1.0.5: - resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==} + /call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.2 - set-function-length: 1.2.0 + get-intrinsic: 1.2.4 + set-function-length: 1.2.1 dev: true /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - /caniuse-lite@1.0.30001579: - resolution: {integrity: sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==} + /caniuse-lite@1.0.30001587: + resolution: {integrity: sha512-HMFNotUmLXn71BQxg8cijvqxnIAofforZOwGsxyXJ0qugTdspUF4sPSJ2vhgprHCB996tIDzEq1ubumPDV8ULA==} dev: true /chalk@2.4.2: @@ -1333,6 +1453,11 @@ packages: supports-color: 7.2.0 dev: true + /clsx@2.1.0: + resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -1375,6 +1500,11 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + dev: false + /cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -1410,7 +1540,7 @@ packages: supports-color: optional: true dependencies: - ms: 2.1.2 + ms: 2.1.3 dev: true /debug@4.3.4: @@ -1423,27 +1553,26 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true - /define-data-property@1.1.1: - resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.2 + es-define-property: 1.0.0 + es-errors: 1.3.0 gopd: 1.0.1 - has-property-descriptors: 1.0.1 dev: true /define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} dependencies: - define-data-property: 1.1.1 - has-property-descriptors: 1.0.1 + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 object-keys: 1.1.1 dev: true @@ -1478,80 +1607,104 @@ packages: esutils: 2.0.3 dev: true - /electron-to-chromium@1.4.640: - resolution: {integrity: sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==} + /electron-to-chromium@1.4.672: + resolution: {integrity: sha512-YYCy+goe3UqZqa3MOQCI5Mx/6HdBLzXL/mkbGCEWL3sP3Z1BP9zqAzeD3YEmLZlespYGFtyM8tRp5i2vfaUGCA==} dev: true /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} dev: true + /err-code@3.0.1: + resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} + dev: false + /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: is-arrayish: 0.2.1 dev: false - /es-abstract@1.22.3: - resolution: {integrity: sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==} + /es-abstract@1.22.4: + resolution: {integrity: sha512-vZYJlk2u6qHYxBOTjAeg7qUxHdNfih64Uu2J8QqWgXZ2cri0ZpJAkzDUK/q593+mvKwlxyaxr6F1Q+3LKoQRgg==} engines: {node: '>= 0.4'} dependencies: - array-buffer-byte-length: 1.0.0 - arraybuffer.prototype.slice: 1.0.2 - available-typed-arrays: 1.0.5 - call-bind: 1.0.5 + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.6 + call-bind: 1.0.7 + es-define-property: 1.0.0 + es-errors: 1.3.0 es-set-tostringtag: 2.0.2 es-to-primitive: 1.2.1 function.prototype.name: 1.1.6 - get-intrinsic: 1.2.2 - get-symbol-description: 1.0.0 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 globalthis: 1.0.3 gopd: 1.0.1 - has-property-descriptors: 1.0.1 + has-property-descriptors: 1.0.2 has-proto: 1.0.1 has-symbols: 1.0.3 - hasown: 2.0.0 - internal-slot: 1.0.6 - is-array-buffer: 3.0.2 + hasown: 2.0.1 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 is-callable: 1.2.7 is-negative-zero: 2.0.2 is-regex: 1.1.4 is-shared-array-buffer: 1.0.2 is-string: 1.0.7 - is-typed-array: 1.1.12 + is-typed-array: 1.1.13 is-weakref: 1.0.2 object-inspect: 1.13.1 object-keys: 1.1.1 object.assign: 4.1.5 - regexp.prototype.flags: 1.5.1 + regexp.prototype.flags: 1.5.2 safe-array-concat: 1.1.0 - safe-regex-test: 1.0.2 + safe-regex-test: 1.0.3 string.prototype.trim: 1.2.8 string.prototype.trimend: 1.0.7 string.prototype.trimstart: 1.0.7 - typed-array-buffer: 1.0.0 + typed-array-buffer: 1.0.1 typed-array-byte-length: 1.0.0 typed-array-byte-offset: 1.0.0 typed-array-length: 1.0.4 unbox-primitive: 1.0.2 - which-typed-array: 1.1.13 + which-typed-array: 1.1.14 + dev: true + + /es-array-method-boxes-properly@1.0.0: + resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==} + dev: true + + /es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 dev: true - /es-iterator-helpers@1.0.15: - resolution: {integrity: sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==} + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + dev: true + + /es-iterator-helpers@1.0.17: + resolution: {integrity: sha512-lh7BsUqelv4KUbR5a/ZTaGGIMLCjPGPqJ6q+Oq24YP0RdyptX1uzm4vvaqzk7Zx3bpl/76YLTTDj9L7uYQ92oQ==} + engines: {node: '>= 0.4'} dependencies: asynciterator.prototype: 1.0.0 - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 + es-errors: 1.3.0 es-set-tostringtag: 2.0.2 function-bind: 1.1.2 - get-intrinsic: 1.2.2 + get-intrinsic: 1.2.4 globalthis: 1.0.3 - has-property-descriptors: 1.0.1 + has-property-descriptors: 1.0.2 has-proto: 1.0.1 has-symbols: 1.0.3 - internal-slot: 1.0.6 + internal-slot: 1.0.7 iterator.prototype: 1.1.2 safe-array-concat: 1.1.0 dev: true @@ -1560,15 +1713,15 @@ packages: resolution: {integrity: sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.2 - has-tostringtag: 1.0.0 - hasown: 2.0.0 + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.1 dev: true /es-shim-unscopables@1.0.2: resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} dependencies: - hasown: 2.0.0 + hasown: 2.0.1 dev: true /es-to-primitive@1.2.1: @@ -1580,39 +1733,39 @@ packages: is-symbol: 1.0.4 dev: true - /esbuild@0.19.11: - resolution: {integrity: sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==} + /esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} engines: {node: '>=12'} hasBin: true requiresBuild: true optionalDependencies: - '@esbuild/aix-ppc64': 0.19.11 - '@esbuild/android-arm': 0.19.11 - '@esbuild/android-arm64': 0.19.11 - '@esbuild/android-x64': 0.19.11 - '@esbuild/darwin-arm64': 0.19.11 - '@esbuild/darwin-x64': 0.19.11 - '@esbuild/freebsd-arm64': 0.19.11 - '@esbuild/freebsd-x64': 0.19.11 - '@esbuild/linux-arm': 0.19.11 - '@esbuild/linux-arm64': 0.19.11 - '@esbuild/linux-ia32': 0.19.11 - '@esbuild/linux-loong64': 0.19.11 - '@esbuild/linux-mips64el': 0.19.11 - '@esbuild/linux-ppc64': 0.19.11 - '@esbuild/linux-riscv64': 0.19.11 - '@esbuild/linux-s390x': 0.19.11 - '@esbuild/linux-x64': 0.19.11 - '@esbuild/netbsd-x64': 0.19.11 - '@esbuild/openbsd-x64': 0.19.11 - '@esbuild/sunos-x64': 0.19.11 - '@esbuild/win32-arm64': 0.19.11 - '@esbuild/win32-ia32': 0.19.11 - '@esbuild/win32-x64': 0.19.11 - dev: true - - /escalade@3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + dev: true + + /escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} dev: true @@ -1642,13 +1795,13 @@ packages: dependencies: confusing-browser-globals: 1.0.11 eslint: 8.56.0 - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0) object.assign: 4.1.5 object.entries: 1.1.7 semver: 6.3.1 dev: true - /eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@6.19.0)(@typescript-eslint/parser@6.19.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0): + /eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@6.21.0)(@typescript-eslint/parser@6.21.0)(eslint-plugin-import@2.29.1)(eslint@8.56.0): resolution: {integrity: sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==} peerDependencies: '@typescript-eslint/eslint-plugin': ^5.13.0 || ^6.0.0 @@ -1656,11 +1809,11 @@ packages: eslint: ^7.32.0 || ^8.2.0 eslint-plugin-import: ^2.25.3 dependencies: - '@typescript-eslint/eslint-plugin': 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) eslint: 8.56.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1)(eslint@8.56.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0) dev: true /eslint-config-airbnb@19.0.4(eslint-plugin-import@2.29.1)(eslint-plugin-jsx-a11y@6.8.0)(eslint-plugin-react-hooks@4.6.0)(eslint-plugin-react@7.33.2)(eslint@8.56.0): @@ -1675,7 +1828,7 @@ packages: dependencies: eslint: 8.56.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1)(eslint@8.56.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.56.0) eslint-plugin-react: 7.33.2(eslint@8.56.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.56.0) @@ -1683,8 +1836,17 @@ packages: object.entries: 1.1.7 dev: true - /eslint-config-standard-with-typescript@43.0.0(@typescript-eslint/eslint-plugin@6.19.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-AT0qK01M5bmsWiE3UZvaQO5da1y1n6uQckAKqGNe6zPW5IOzgMLXZxw77nnFm+C11nxAZXsCPrbsgJhSrGfX6Q==} + /eslint-config-prettier@9.1.0(eslint@8.56.0): + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + dependencies: + eslint: 8.56.0 + dev: true + + /eslint-config-standard-with-typescript@43.0.1(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-WfZ986+qzIzX6dcr4yGUyVb/l9N3Z8wPXCc5z/70fljs3UbWhhV+WxrfgsqMToRzuuyX9MqZ974pq2UPhDTOcA==} peerDependencies: '@typescript-eslint/eslint-plugin': ^6.4.0 eslint: ^8.0.1 @@ -1693,11 +1855,11 @@ packages: eslint-plugin-promise: ^6.0.0 typescript: '*' dependencies: - '@typescript-eslint/eslint-plugin': 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) eslint: 8.56.0 eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@16.6.2)(eslint-plugin-promise@6.1.1)(eslint@8.56.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0) eslint-plugin-n: 16.6.2(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0) typescript: 5.3.3 @@ -1715,7 +1877,7 @@ packages: eslint-plugin-promise: ^6.0.0 dependencies: eslint: 8.56.0 - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0) eslint-plugin-n: 16.6.2(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0) dev: true @@ -1730,7 +1892,7 @@ packages: - supports-color dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0): + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} peerDependencies: @@ -1751,7 +1913,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) debug: 3.2.7 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 @@ -1771,7 +1933,7 @@ packages: eslint-compat-utils: 0.1.2(eslint@8.56.0) dev: true - /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0)(eslint@8.56.0): + /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0): resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} engines: {node: '>=4'} peerDependencies: @@ -1781,22 +1943,22 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) array-includes: 3.1.7 - array.prototype.findlastindex: 1.2.3 + array.prototype.findlastindex: 1.2.4 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 debug: 3.2.7 doctrine: 2.1.0 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0) - hasown: 2.0.0 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint@8.56.0) + hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 minimatch: 3.1.2 object.fromentries: 2.0.7 - object.groupby: 1.0.1 + object.groupby: 1.0.2 object.values: 1.1.7 semver: 6.3.1 tsconfig-paths: 3.15.0 @@ -1812,7 +1974,7 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 dependencies: - '@babel/runtime': 7.23.8 + '@babel/runtime': 7.23.9 aria-query: 5.3.0 array-includes: 3.1.7 array.prototype.flatmap: 1.3.2 @@ -1821,9 +1983,9 @@ packages: axobject-query: 3.2.1 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - es-iterator-helpers: 1.0.15 + es-iterator-helpers: 1.0.17 eslint: 8.56.0 - hasown: 2.0.0 + hasown: 2.0.1 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.2 @@ -1843,12 +2005,33 @@ packages: eslint-plugin-es-x: 7.5.0(eslint@8.56.0) get-tsconfig: 4.7.2 globals: 13.24.0 - ignore: 5.3.0 + ignore: 5.3.1 is-builtin-module: 3.2.1 is-core-module: 2.13.1 minimatch: 3.1.2 resolve: 1.22.8 - semver: 7.5.4 + semver: 7.6.0 + dev: true + + /eslint-plugin-prettier@5.1.3(eslint-config-prettier@9.1.0)(eslint@8.56.0)(prettier@3.2.5): + resolution: {integrity: sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + dependencies: + eslint: 8.56.0 + eslint-config-prettier: 9.1.0(eslint@8.56.0) + prettier: 3.2.5 + prettier-linter-helpers: 1.0.0 + synckit: 0.8.8 dev: true /eslint-plugin-promise@6.1.1(eslint@8.56.0): @@ -1885,9 +2068,9 @@ packages: dependencies: array-includes: 3.1.7 array.prototype.flatmap: 1.3.2 - array.prototype.tosorted: 1.1.2 + array.prototype.tosorted: 1.1.3 doctrine: 2.1.0 - es-iterator-helpers: 1.0.15 + es-iterator-helpers: 1.0.17 eslint: 8.56.0 estraverse: 5.3.0 jsx-ast-utils: 3.3.5 @@ -1945,7 +2128,7 @@ packages: glob-parent: 6.0.2 globals: 13.24.0 graphemer: 1.4.0 - ignore: 5.3.0 + ignore: 5.3.1 imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 @@ -1999,6 +2182,10 @@ packages: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true + /fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + dev: true + /fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -2018,8 +2205,8 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true - /fastq@1.16.0: - resolution: {integrity: sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==} + /fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} dependencies: reusify: 1.0.4 dev: true @@ -2107,9 +2294,9 @@ packages: resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 functions-have-names: 1.2.3 dev: true @@ -2122,21 +2309,28 @@ packages: engines: {node: '>=6.9.0'} dev: true - /get-intrinsic@1.2.2: - resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==} + /get-browser-rtc@1.1.0: + resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==} + dev: false + + /get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} dependencies: + es-errors: 1.3.0 function-bind: 1.1.2 has-proto: 1.0.1 has-symbols: 1.0.3 - hasown: 2.0.0 + hasown: 2.0.1 dev: true - /get-symbol-description@1.0.0: - resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} + /get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - get-intrinsic: 1.2.2 + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 dev: true /get-tsconfig@4.7.2: @@ -2196,7 +2390,7 @@ packages: array-union: 2.1.0 dir-glob: 3.0.1 fast-glob: 3.3.2 - ignore: 5.3.0 + ignore: 5.3.1 merge2: 1.4.1 slash: 3.0.0 dev: true @@ -2204,7 +2398,7 @@ packages: /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: - get-intrinsic: 1.2.2 + get-intrinsic: 1.2.4 dev: true /graphemer@1.4.0: @@ -2224,10 +2418,10 @@ packages: engines: {node: '>=8'} dev: true - /has-property-descriptors@1.0.1: - resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==} + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} dependencies: - get-intrinsic: 1.2.2 + es-define-property: 1.0.0 dev: true /has-proto@1.0.1: @@ -2240,15 +2434,15 @@ packages: engines: {node: '>= 0.4'} dev: true - /has-tostringtag@1.0.0: - resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + /has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} dependencies: has-symbols: 1.0.3 dev: true - /hasown@2.0.0: - resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} + /hasown@2.0.1: + resolution: {integrity: sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==} engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 @@ -2259,8 +2453,12 @@ packages: react-is: 16.13.1 dev: false - /ignore@5.3.0: - resolution: {integrity: sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==} + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: false + + /ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} dev: true @@ -2285,23 +2483,22 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true - /internal-slot@1.0.6: - resolution: {integrity: sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==} + /internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.2 - hasown: 2.0.0 - side-channel: 1.0.4 + es-errors: 1.3.0 + hasown: 2.0.1 + side-channel: 1.0.5 dev: true - /is-array-buffer@3.0.2: - resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} + /is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - get-intrinsic: 1.2.2 - is-typed-array: 1.1.12 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 dev: true /is-arrayish@0.2.1: @@ -2312,7 +2509,7 @@ packages: resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: true /is-bigint@1.0.4: @@ -2325,8 +2522,8 @@ packages: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - has-tostringtag: 1.0.0 + call-bind: 1.0.7 + has-tostringtag: 1.0.2 dev: true /is-builtin-module@3.2.1: @@ -2344,13 +2541,13 @@ packages: /is-core-module@2.13.1: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} dependencies: - hasown: 2.0.0 + hasown: 2.0.1 /is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: true /is-extglob@2.1.1: @@ -2361,14 +2558,14 @@ packages: /is-finalizationregistry@1.0.2: resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 dev: true /is-generator-function@1.0.10: resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: true /is-glob@4.0.3: @@ -2391,7 +2588,7 @@ packages: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: true /is-number@7.0.0: @@ -2408,8 +2605,8 @@ packages: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - has-tostringtag: 1.0.0 + call-bind: 1.0.7 + has-tostringtag: 1.0.2 dev: true /is-set@2.0.2: @@ -2419,14 +2616,14 @@ packages: /is-shared-array-buffer@1.0.2: resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 dev: true /is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: true /is-symbol@1.0.4: @@ -2436,11 +2633,11 @@ packages: has-symbols: 1.0.3 dev: true - /is-typed-array@1.1.12: - resolution: {integrity: sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==} + /is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} engines: {node: '>= 0.4'} dependencies: - which-typed-array: 1.1.13 + which-typed-array: 1.1.14 dev: true /is-weakmap@2.0.1: @@ -2450,14 +2647,14 @@ packages: /is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 dev: true /is-weakset@2.0.2: resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} dependencies: - call-bind: 1.0.5 - get-intrinsic: 1.2.2 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 dev: true /isarray@2.0.5: @@ -2472,9 +2669,9 @@ packages: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} dependencies: define-properties: 1.2.1 - get-intrinsic: 1.2.2 + get-intrinsic: 1.2.4 has-symbols: 1.0.3 - reflect.getprototypeof: 1.0.4 + reflect.getprototypeof: 1.0.5 set-function-name: 2.0.1 dev: true @@ -2636,6 +2833,9 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true /nanoid@3.3.7: @@ -2670,7 +2870,7 @@ packages: resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 has-symbols: 1.0.3 object-keys: 1.1.1 @@ -2680,43 +2880,44 @@ packages: resolution: {integrity: sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 dev: true /object.fromentries@2.0.7: resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 dev: true - /object.groupby@1.0.1: - resolution: {integrity: sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==} + /object.groupby@1.0.2: + resolution: {integrity: sha512-bzBq58S+x+uo0VjurFT0UktpKHOZmv4/xePiOA1nbB9pMqpGK7rUPNgf+1YC+7mE+0HzhTMqNUuCqvKhj6FnBw==} dependencies: - call-bind: 1.0.5 + array.prototype.filter: 1.0.3 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 - get-intrinsic: 1.2.2 + es-abstract: 1.22.4 + es-errors: 1.3.0 dev: true /object.hasown@1.1.3: resolution: {integrity: sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==} dependencies: define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 dev: true /object.values@1.1.7: resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 dev: true /once@1.4.0: @@ -2798,8 +2999,8 @@ packages: engines: {node: '>=8.6'} dev: true - /postcss@8.4.33: - resolution: {integrity: sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==} + /postcss@8.4.35: + resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} engines: {node: ^10 || ^12 || >=14} dependencies: nanoid: 3.3.7 @@ -2812,8 +3013,15 @@ packages: engines: {node: '>= 0.8.0'} dev: true - /prettier@3.2.4: - resolution: {integrity: sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==} + /prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + dependencies: + fast-diff: 1.3.0 + dev: true + + /prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} engines: {node: '>=14'} hasBin: true dev: true @@ -2837,7 +3045,12 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true + + /randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} @@ -2849,6 +3062,24 @@ packages: scheduler: 0.23.0 dev: false + /react-icon@1.0.0(babel-runtime@5.8.38)(react@18.2.0): + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + dependencies: + babel-runtime: 5.8.38 + react: 18.2.0 + dev: false + + /react-icons@5.0.1(react@18.2.0): + resolution: {integrity: sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==} + peerDependencies: + react: '*' + dependencies: + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2857,27 +3088,38 @@ packages: engines: {node: '>=0.10.0'} dev: true - /react-router-dom@6.21.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-kNzubk7n4YHSrErzjLK72j0B5i969GsuCGazRl3G6j1zqZBLjuSlYBdVdkDOgzGdPIffUOc9nmgiadTEVoq91g==} + /react-router-dom@6.22.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' dependencies: - '@remix-run/router': 1.14.2 + '@remix-run/router': 1.15.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-router: 6.21.3(react@18.2.0) + react-router: 6.22.0(react@18.2.0) dev: false - /react-router@6.21.3(react@18.2.0): - resolution: {integrity: sha512-a0H638ZXULv1OdkmiK6s6itNhoy33ywxmUFT/xtSoVyf9VnC7n7+VT4LjVzdIHSaF5TIh9ylUgxMXksHTgGrKg==} + /react-router@6.22.0(react@18.2.0): + resolution: {integrity: sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' dependencies: - '@remix-run/router': 1.14.2 + '@remix-run/router': 1.15.0 + react: 18.2.0 + dev: false + + /react-toastify@10.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-etR3RgueY8pe88SA67wLm8rJmL1h+CLqUGHuAoNsseW35oTGJEri6eBTyaXnFKNQ80v/eO10hBYLgz036XRGgA==} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + clsx: 2.1.0 react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) dev: false /react@18.2.0: @@ -2887,14 +3129,24 @@ packages: loose-envify: 1.4.0 dev: false - /reflect.getprototypeof@1.0.4: - resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==} + /readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + dev: false + + /reflect.getprototypeof@1.0.5: + resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 - get-intrinsic: 1.2.2 + es-abstract: 1.22.4 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 globalthis: 1.0.3 which-builtin-type: 1.1.3 dev: true @@ -2902,12 +3154,13 @@ packages: /regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - /regexp.prototype.flags@1.5.1: - resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} + /regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 + es-errors: 1.3.0 set-function-name: 2.0.1 dev: true @@ -2948,26 +3201,26 @@ packages: glob: 7.2.3 dev: true - /rollup@4.9.5: - resolution: {integrity: sha512-E4vQW0H/mbNMw2yLSqJyjtkHY9dslf/p0zuT1xehNRqUTBOFMqEjguDvqhXr7N7r/4ttb2jr4T41d3dncmIgbQ==} + /rollup@4.11.0: + resolution: {integrity: sha512-2xIbaXDXjf3u2tajvA5xROpib7eegJ9Y/uPlSFhXLNpK9ampCczXAhLEb5yLzJyG3LAdI1NWtNjDXiLyniNdjQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true dependencies: '@types/estree': 1.0.5 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.9.5 - '@rollup/rollup-android-arm64': 4.9.5 - '@rollup/rollup-darwin-arm64': 4.9.5 - '@rollup/rollup-darwin-x64': 4.9.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.9.5 - '@rollup/rollup-linux-arm64-gnu': 4.9.5 - '@rollup/rollup-linux-arm64-musl': 4.9.5 - '@rollup/rollup-linux-riscv64-gnu': 4.9.5 - '@rollup/rollup-linux-x64-gnu': 4.9.5 - '@rollup/rollup-linux-x64-musl': 4.9.5 - '@rollup/rollup-win32-arm64-msvc': 4.9.5 - '@rollup/rollup-win32-ia32-msvc': 4.9.5 - '@rollup/rollup-win32-x64-msvc': 4.9.5 + '@rollup/rollup-android-arm-eabi': 4.11.0 + '@rollup/rollup-android-arm64': 4.11.0 + '@rollup/rollup-darwin-arm64': 4.11.0 + '@rollup/rollup-darwin-x64': 4.11.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.11.0 + '@rollup/rollup-linux-arm64-gnu': 4.11.0 + '@rollup/rollup-linux-arm64-musl': 4.11.0 + '@rollup/rollup-linux-riscv64-gnu': 4.11.0 + '@rollup/rollup-linux-x64-gnu': 4.11.0 + '@rollup/rollup-linux-x64-musl': 4.11.0 + '@rollup/rollup-win32-arm64-msvc': 4.11.0 + '@rollup/rollup-win32-ia32-msvc': 4.11.0 + '@rollup/rollup-win32-x64-msvc': 4.11.0 fsevents: 2.3.3 dev: true @@ -2981,18 +3234,22 @@ packages: resolution: {integrity: sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==} engines: {node: '>=0.4'} dependencies: - call-bind: 1.0.5 - get-intrinsic: 1.2.2 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 has-symbols: 1.0.3 isarray: 2.0.5 dev: true - /safe-regex-test@1.0.2: - resolution: {integrity: sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==} + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: false + + /safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - get-intrinsic: 1.2.2 + call-bind: 1.0.7 + es-errors: 1.3.0 is-regex: 1.1.4 dev: true @@ -3007,32 +3264,33 @@ packages: hasBin: true dev: true - /semver@7.5.4: - resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + /semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} engines: {node: '>=10'} hasBin: true dependencies: lru-cache: 6.0.0 dev: true - /set-function-length@1.2.0: - resolution: {integrity: sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==} + /set-function-length@1.2.1: + resolution: {integrity: sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==} engines: {node: '>= 0.4'} dependencies: - define-data-property: 1.1.1 + define-data-property: 1.1.4 + es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.2 + get-intrinsic: 1.2.4 gopd: 1.0.1 - has-property-descriptors: 1.0.1 + has-property-descriptors: 1.0.2 dev: true /set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} dependencies: - define-data-property: 1.1.1 + define-data-property: 1.1.4 functions-have-names: 1.2.3 - has-property-descriptors: 1.0.1 + has-property-descriptors: 1.0.2 dev: true /shebang-command@2.0.0: @@ -3047,14 +3305,30 @@ packages: engines: {node: '>=8'} dev: true - /side-channel@1.0.4: - resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} + /side-channel@1.0.5: + resolution: {integrity: sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - get-intrinsic: 1.2.2 + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 object-inspect: 1.13.1 dev: true + /simple-peer@9.11.1: + resolution: {integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==} + dependencies: + buffer: 6.0.3 + debug: 4.3.4 + err-code: 3.0.1 + get-browser-rtc: 1.1.0 + queue-microtask: 1.2.3 + randombytes: 2.1.0 + readable-stream: 3.6.2 + transitivePeerDependencies: + - supports-color + dev: false + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3073,42 +3347,48 @@ packages: /string.prototype.matchall@4.0.10: resolution: {integrity: sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 - get-intrinsic: 1.2.2 + es-abstract: 1.22.4 + get-intrinsic: 1.2.4 has-symbols: 1.0.3 - internal-slot: 1.0.6 - regexp.prototype.flags: 1.5.1 + internal-slot: 1.0.7 + regexp.prototype.flags: 1.5.2 set-function-name: 2.0.1 - side-channel: 1.0.4 + side-channel: 1.0.5 dev: true /string.prototype.trim@1.2.8: resolution: {integrity: sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 dev: true /string.prototype.trimend@1.0.7: resolution: {integrity: sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 dev: true /string.prototype.trimstart@1.0.7: resolution: {integrity: sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.3 + es-abstract: 1.22.4 dev: true + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3147,6 +3427,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /synckit@0.8.8: + resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} + engines: {node: ^14.18.0 || >=16.0.0} + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.6.2 + dev: true + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true @@ -3162,9 +3450,9 @@ packages: is-number: 7.0.0 dev: true - /ts-api-utils@1.0.3(typescript@5.3.3): - resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==} - engines: {node: '>=16.13.0'} + /ts-api-utils@1.2.1(typescript@5.3.3): + resolution: {integrity: sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==} + engines: {node: '>=16'} peerDependencies: typescript: '>=4.2.0' dependencies: @@ -3180,6 +3468,10 @@ packages: strip-bom: 3.0.0 dev: true + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + dev: true + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3192,42 +3484,42 @@ packages: engines: {node: '>=10'} dev: true - /typed-array-buffer@1.0.0: - resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} + /typed-array-buffer@1.0.1: + resolution: {integrity: sha512-RSqu1UEuSlrBhHTWC8O9FnPjOduNs4M7rJ4pRKoEjtx1zUNOPN2sSXHLDX+Y2WPbHIxbvg4JFo2DNAEfPIKWoQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 - get-intrinsic: 1.2.2 - is-typed-array: 1.1.12 + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 dev: true /typed-array-byte-length@1.0.0: resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 for-each: 0.3.3 has-proto: 1.0.1 - is-typed-array: 1.1.12 + is-typed-array: 1.1.13 dev: true /typed-array-byte-offset@1.0.0: resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} engines: {node: '>= 0.4'} dependencies: - available-typed-arrays: 1.0.5 - call-bind: 1.0.5 + available-typed-arrays: 1.0.6 + call-bind: 1.0.7 for-each: 0.3.3 has-proto: 1.0.1 - is-typed-array: 1.1.12 + is-typed-array: 1.1.13 dev: true /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 for-each: 0.3.3 - is-typed-array: 1.1.12 + is-typed-array: 1.1.13 dev: true /typescript@5.3.3: @@ -3239,20 +3531,24 @@ packages: /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: - call-bind: 1.0.5 + call-bind: 1.0.7 has-bigints: 1.0.2 has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 dev: true - /update-browserslist-db@1.0.13(browserslist@4.22.2): + /undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + dev: true + + /update-browserslist-db@1.0.13(browserslist@4.23.0): resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' dependencies: - browserslist: 4.22.2 - escalade: 3.1.1 + browserslist: 4.23.0 + escalade: 3.1.2 picocolors: 1.0.0 dev: true @@ -3270,8 +3566,12 @@ packages: react: 18.2.0 dev: false - /vite@5.0.12: - resolution: {integrity: sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==} + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: false + + /vite@5.1.3: + resolution: {integrity: sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -3298,9 +3598,9 @@ packages: terser: optional: true dependencies: - esbuild: 0.19.11 - postcss: 8.4.33 - rollup: 4.9.5 + esbuild: 0.19.12 + postcss: 8.4.35 + rollup: 4.11.0 optionalDependencies: fsevents: 2.3.3 dev: true @@ -3320,7 +3620,7 @@ packages: engines: {node: '>= 0.4'} dependencies: function.prototype.name: 1.1.6 - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 is-async-function: 2.0.0 is-date-object: 1.0.5 is-finalizationregistry: 1.0.2 @@ -3330,7 +3630,7 @@ packages: isarray: 2.0.5 which-boxed-primitive: 1.0.2 which-collection: 1.0.1 - which-typed-array: 1.1.13 + which-typed-array: 1.1.14 dev: true /which-collection@1.0.1: @@ -3342,15 +3642,15 @@ packages: is-weakset: 2.0.2 dev: true - /which-typed-array@1.1.13: - resolution: {integrity: sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==} + /which-typed-array@1.1.14: + resolution: {integrity: sha512-VnXFiIW8yNn9kIHN88xvZ4yOWchftKDsRJ8fEPacX/wl1lOvBrhsJ/OeJCXq7B0AaijRuqgzSKalJoPk+D8MPg==} engines: {node: '>= 0.4'} dependencies: - available-typed-arrays: 1.0.5 - call-bind: 1.0.5 + available-typed-arrays: 1.0.6 + call-bind: 1.0.7 for-each: 0.3.3 gopd: 1.0.1 - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: true /which@2.0.2: @@ -3383,7 +3683,7 @@ packages: engines: {node: '>=10'} dev: true - /zustand@4.5.0(@types/react@18.2.48)(react@18.2.0): + /zustand@4.5.0(@types/react@18.2.55)(react@18.2.0): resolution: {integrity: sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==} engines: {node: '>=12.7.0'} peerDependencies: @@ -3398,7 +3698,7 @@ packages: react: optional: true dependencies: - '@types/react': 18.2.48 + '@types/react': 18.2.55 react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) dev: false diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 53584a4..6135835 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,14 @@ import WelcomePage from './pages/WelcomePage'; import ErrorPage from './pages/ErrorPage'; import ChattingListPage from './pages/ChattingListPage'; import SignupPage from './pages/SignupPage'; -import SigninPage from './pages/SigninPage'; import ChattingRoomPage from './pages/ChattingRoomPage'; +import Signupnaver from './pages/Signupnaver'; +import SigninPage from './pages/SigninPage'; +import VideoCallPage from './pages/VideoCallPage'; +import { Global } from '@emotion/react'; +import { GlobalStyle } from './styles/GlobalStyle'; +import ChatRoomInfo from './components/chatRoomInfo/ChatRoomInfo'; + const router = createBrowserRouter([ { @@ -19,6 +25,9 @@ const router = createBrowserRouter([ { path: 'signup', element: }, { path: 'chattinglist', element: }, { path: 'chatroom/:chatroom_id', element: }, + { path: 'signupnaver', element: }, + { path: 'videocall', element: }, + { path: 'chatroominfo', element: }, ], }, ]); @@ -26,6 +35,7 @@ const router = createBrowserRouter([ const App = () => { return ( <> + ); diff --git a/frontend/src/assets/Bookmark.svg b/frontend/src/assets/Bookmark.svg new file mode 100644 index 0000000..4fe7ea5 --- /dev/null +++ b/frontend/src/assets/Bookmark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/Close.svg b/frontend/src/assets/Close.svg new file mode 100644 index 0000000..6bb2ecf --- /dev/null +++ b/frontend/src/assets/Close.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/Create.svg b/frontend/src/assets/Create.svg new file mode 100644 index 0000000..3de0e04 --- /dev/null +++ b/frontend/src/assets/Create.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/DefaultUser.svg b/frontend/src/assets/DefaultUser.svg new file mode 100644 index 0000000..e171b09 --- /dev/null +++ b/frontend/src/assets/DefaultUser.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/Email.svg b/frontend/src/assets/Email.svg new file mode 100644 index 0000000..1bed488 --- /dev/null +++ b/frontend/src/assets/Email.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/Home.svg b/frontend/src/assets/Home.svg new file mode 100644 index 0000000..3ad45f2 --- /dev/null +++ b/frontend/src/assets/Home.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/Logo.svg b/frontend/src/assets/Logo.svg new file mode 100644 index 0000000..d9e13aa --- /dev/null +++ b/frontend/src/assets/Logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/Logout.svg b/frontend/src/assets/Logout.svg new file mode 100644 index 0000000..13d79f6 --- /dev/null +++ b/frontend/src/assets/Logout.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/Password.svg b/frontend/src/assets/Password.svg new file mode 100644 index 0000000..b94cd86 --- /dev/null +++ b/frontend/src/assets/Password.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/Search.svg b/frontend/src/assets/Search.svg new file mode 100644 index 0000000..6881e64 --- /dev/null +++ b/frontend/src/assets/Search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/Star.svg b/frontend/src/assets/Star.svg new file mode 100644 index 0000000..518ab83 --- /dev/null +++ b/frontend/src/assets/Star.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/TadakTadak.svg b/frontend/src/assets/TadakTadak.svg new file mode 100644 index 0000000..97c81d6 --- /dev/null +++ b/frontend/src/assets/TadakTadak.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/User.svg b/frontend/src/assets/User.svg new file mode 100644 index 0000000..2b798d4 --- /dev/null +++ b/frontend/src/assets/User.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/auth/InputForm.tsx b/frontend/src/components/auth/InputForm.tsx new file mode 100644 index 0000000..75e11d4 --- /dev/null +++ b/frontend/src/components/auth/InputForm.tsx @@ -0,0 +1,88 @@ +import EmailSVG from "../../assets/Email.svg" +import UserSVG from "../../assets/User.svg" +import PasswordSVG from "../../assets/Password.svg" + +import styled from "@emotion/styled"; + +interface InputFormProps { + type: string; + name: string; + value: string; + onChange: (event: React.ChangeEvent) => void; + placeholder: string; + title: string; + imgSVG: string; +} + +function ChangeSVG(imgSVG: string){ + switch(imgSVG){ + case "Email": + return EmailSVG; + case "User": + return UserSVG; + case "Password": + return PasswordSVG; + } +} + +export const InputForm = ({ type, name, value, onChange, title, imgSVG, ...rest }: InputFormProps) => { + return ( + +

{title}

+ + {imgSVG && ( + + img + + )} + {type === 'number' ? ( + + {[1, 2, 3, 4, 5, 6].map((option) => ( + + ))} + + ) : ( + + )} + +
+ ); +}; + +const InputFormWrapper = styled.section` + p { + font-size: var(--font-size-xs); + margin: 0; + padding: 1rem 0.5rem 0.3rem; + } +` + +const InputFormContainer = styled.div` + height: 44px; + display: flex; + gap: 16px; + align-items: center; + border-radius: 10px; + padding: 0 11px; + + picture { + + } + + input { + font-size: var(--font-size-xs); + border: none; + outline: none; + } + + border: solid 1px var(--color-crusta) +` + +const StyledSelect = styled.select` + width: 100%; + border: none; + padding: 8px; + outline: none; +`; diff --git a/frontend/src/components/auth/NaverLoginButton.tsx b/frontend/src/components/auth/NaverLoginButton.tsx new file mode 100644 index 0000000..e3d87f4 --- /dev/null +++ b/frontend/src/components/auth/NaverLoginButton.tsx @@ -0,0 +1,124 @@ +import styled from '@emotion/styled'; +import { useEffect, useRef } from 'react' + +const CLIENT_ID = import.meta.env.VITE_APP_CLIENT_ID; +const CLIENT_ID_2 = import.meta.env.VITE_APP_CLIENT_ID_2; + + +export const NaverLoginButton = () => { + const naverRef = useRef() + const { naver } = window + const REDIRECT_URI = "http://localhost:5173/signupnaver" + + const STATE = "flase"; + const NAVER_AUTH_URL = `https://nid.naver.com/oauth2.0/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&state=${STATE}`; + const NAVER_AUTH_LOGIN = `http://localhost:8001/oauth2/authorization/naver` + + const NaverLogin = () => { + window.location.href = NAVER_AUTH_LOGIN; + } + + const initializeNaverLogin = () => { + const naverLogin = new naver.LoginWithNaverId({ + clientId: CLIENT_ID_2, + callbackUrl: NAVER_AUTH_LOGIN, + isPopup: false, + loginButton: { color: 'green', type: 3, height: 58 }, + callbackHandle: true, + }) + naverLogin.init() + + naverLogin.getLoginStatus(async function (status) { + if (status) { + const userid = naverLogin.user.getEmail() + const username = naverLogin.user.getName() + console.log(userid,username) + } + }) + } + + useEffect(() => { + initializeNaverLogin() + }, []) + + const handleNaverLogin = () => { + naverRef.current.children[0].click() + } + + + return ( + <> + + {/* +
+
+
+ + + + + +
+ FE 프록시 적용 간편 가입 +
+
+
*/} + + {/* BE TEST */} + +
+
+
+ + + + + +
+ 네이버 간편 가입 +
+
+
+ + ); +} + +const NaverIdLogin = styled.div` + display: none`; + +const NaverButtonStyles = styled.div` + .NaverLoginButton-wrapper { + display: flex; + justify-content: space-between; + } + + .Logo{ + display: flex; + } + + .btn-naver { + flex: 1; + height: 46px; + background: #03C75A; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + transition: background-color 0.3s; + } + + .text { + font-family: 'Noto Sans', sans-serif; + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 24px; + text-align: center; + color: #ffffff; + } + + .btn-naver:hover { + background-color: #029445; + } +`; \ No newline at end of file diff --git a/frontend/src/components/chat/ChatForm.tsx b/frontend/src/components/chat/ChatForm.tsx new file mode 100644 index 0000000..e70bb96 --- /dev/null +++ b/frontend/src/components/chat/ChatForm.tsx @@ -0,0 +1,80 @@ +import styled from '@emotion/styled'; +import ChatMessage from './ChatMessage'; + +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useChatStore } from '../../stores/useChatStore'; +import { ColumnDisplay, OverFlowScrollbar } from '../../styles/ComponentLayout'; +import { FlexCenterWrapper } from '../../styles/Layout'; +import { UserDataProps } from '../../interface/UserListInterface'; +import { useConnectChatRoom } from '../../hooks/custom-hook/useConnectChatRoom'; + +const ChatForm = ({ isLoading, isError, username }: UserDataProps) => { + const { messages, inputMessage, setInputMessage } = useChatStore(); + const { chatroom_id } = useParams<{ chatroom_id: string }>(); + const roomId = chatroom_id ?? ''; + const { chatConnect, sendMessage } = useConnectChatRoom(); + + useEffect(() => { + if (!isLoading && !isError && username) { + chatConnect(username, roomId); + } + }, [isLoading, isError]); + + // console.log(username); + // console.log(messages); + + return ( + + + + + + setInputMessage(e.target.value)} + onKeyUp={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (username && chatroom_id) { + sendMessage(username, chatroom_id); + } + } + }} + /> + + + ); +}; + +export default ChatForm; + +const InputContainer = styled.footer` + width: 100%; + height: 4rem; + ${FlexCenterWrapper} + flex-direction: column; + background-color: var(--color-shark); +`; + +const ChattingContainer = styled.section` + ${OverFlowScrollbar} + width: 100%; + height: 100%; + background-color: var(--color-white); +`; + +const ChatWrapper = styled.div` + ${ColumnDisplay} + height: calc(100% - 3.125rem); +`; + +const ChatInput = styled.textarea` + ${OverFlowScrollbar} + width: calc(100% - 6px); + height: 100%; + margin: 0.5rem 0rem 0rem 0rem; + border: 3px solid var(--color-crusta); + border-radius: 5px; + resize: none; + font-size: var(--font-size-sm); +`; diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx new file mode 100644 index 0000000..33ee157 --- /dev/null +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -0,0 +1,177 @@ +import { Message } from '../../interface/ChatInterface'; +import styled from '@emotion/styled'; + +interface ChatMessageListProps { + messages: Message[]; + username: string | undefined; +} + +const ChatMessage = ({ messages, username }: ChatMessageListProps) => { + let currentFormattedDate = ''; + // console.log('본인', username); + + return ( + <> + {messages.map((item, index, array) => { + const date = new Date(item.createdAt); + + const formattedDate = date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const formattedTime = date.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); + + const isLastMessageForWriter = + index === array.length - 1 || + array[index + 1].username !== item.username || + new Date(array[index + 1].createdAt).toISOString().slice(0, 16) !== + new Date(item.createdAt).toISOString().slice(0, 16); + + const shouldDisplayYear = formattedDate !== currentFormattedDate; + + currentFormattedDate = formattedDate; + + // console.log('boolean', item.sender === username); + // console.log(item.sender); + + return ( + + {shouldDisplayYear && {formattedDate}} + + {/* 추후 코드 본인일 경우 상태관리 추가해야됨 */} + {item.username === username ? ( + + {item.content} + + {isLastMessageForWriter && formattedTime} + + + ) : ( + <> + + {item.username} + + + {item.content} + + {isLastMessageForWriter && formattedTime} + + + + )} + + + ); + })} + + ); +}; + +export default ChatMessage; + +const MessageContainer = styled.div` + display: flex; + flex-direction: column; + margin: 0.5rem; +`; + +const DateWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + font-size: var(--font-size-xs); + color: var(--color-rangoongreen); + margin: 0.5rem; +`; + +const MessageWrapper = styled.div` + display: flex; + flex-direction: column; +`; +// -------------------------------------- + +const FormattedTime = styled.span` + font-size: var(--font-size-xs); + color: var(--color-rangoongreen); + margin: 0.1rem; +`; + +const ChatSender = styled.span` + font-weight: 700; + color: var(--color-rangoongreen); +`; + +const ChatSenderWrapper = styled.div` + margin: 0.1rem 0.1rem 0.3rem 0rem; +`; + +const ChatDateWrapper = styled.div` + display: flex; + flex-direction: column-reverse; +`; + +// -------------------------------------- +const ChatWrapper = styled.div` + display: flex; + flex-direction: row; +`; + +const ChatReverseWrapper = styled.div` + display: flex; + flex-direction: row-reverse; +`; +// -------------------------------------- +const ChatBoxStyles = ` + max-width: 70%; + padding: 0.5rem; + word-wrap: break-word; + font-size: var(--font-size-sm); +`; + +const ChatBox = styled.div` + ${ChatBoxStyles} + border-radius: 15px 10px 10px 25px; + background-color: var(--color-mercury); + color: var(--color-rangoongreen); +`; + +const ChatOwnBox = styled.div` + ${ChatBoxStyles} + border-radius: 10px 15px 25px 10px; + background-color: var(--color-crusta); + color: var(--color-white); +`; + +// const DUMMY_DATA: Message[] = [ +// { +// sender: '감자', +// content: '헬로', +// createdAt: '2023-12-31T23:57:59', +// }, +// { +// sender: '감자', +// content: '점메추', +// createdAt: '2023-12-31T23:57:59', +// }, +// { +// sender: '감자', +// content: '점메추123', +// createdAt: '2023-12-31T23:59:59', +// }, +// { +// sender: '고구마', +// content: '헬로', +// createdAt: '2023-12-31T23:59:59', +// }, +// { +// sender: '고구마', +// content: '점메추', +// createdAt: '2024-01-01T00:05:59', +// }, +// ]; diff --git a/frontend/src/components/chat/ChatRoom.tsx b/frontend/src/components/chat/ChatRoom.tsx new file mode 100644 index 0000000..32949f6 --- /dev/null +++ b/frontend/src/components/chat/ChatRoom.tsx @@ -0,0 +1,35 @@ +import ChatForm from './ChatForm'; +import styled from '@emotion/styled'; +import { ChattingRoomHeader } from '../../styles/ComponentLayout'; +import { UserDataProps } from '../../interface/UserListInterface'; + +const ChatRoom = ({ username, isLoading, isError }: UserDataProps) => { + return ( + + 채팅방 + + + ); +}; + +export default ChatRoom; + +const ChattingRoom = styled.div` + width: 100%; + height: 100%; + background-color: var(--color-rangoongreen); + display: flex; + flex-direction: column; + /* border: 1px solid var(--color-rangoongreen); */ +`; + +const ChatHeader = styled.header` + ${ChattingRoomHeader} + width: 100%; + height: 3.125rem; + font-size: var(--font-size-md); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +`; diff --git a/frontend/src/components/chatRoomInfo/ChangeBtn.tsx b/frontend/src/components/chatRoomInfo/ChangeBtn.tsx new file mode 100644 index 0000000..8b5efd6 --- /dev/null +++ b/frontend/src/components/chatRoomInfo/ChangeBtn.tsx @@ -0,0 +1,10 @@ +import { BsPersonFillCheck } from 'react-icons/bs'; +interface ChangeBtnProps { + onClick: () => void; +} + +const ChangeBtn: React.FC = ({ onClick }) => { + return ; +}; + +export default ChangeBtn; diff --git a/frontend/src/components/chatRoomInfo/ChatRoomInfo.tsx b/frontend/src/components/chatRoomInfo/ChatRoomInfo.tsx new file mode 100644 index 0000000..f5cac89 --- /dev/null +++ b/frontend/src/components/chatRoomInfo/ChatRoomInfo.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from '@emotion/styled'; +import RoomName from './RoomName'; +import RoomMember from './RoomMember'; +import { useRoomInfoStore } from '../../stores/useRoomInfoStore'; +import { listDataProps } from '../../interface/UserListInterface'; +import { UserInfoStore } from '../../stores/UserInfoStore'; + +const ChatRoomInfo = ({ roomName }: listDataProps) => { + const { setRoomInfo, owner, fetchInitialRoomIn, setFetchInitialRoomIn } = useRoomInfoStore(); + const { chatroom_id } = useParams(); + const [refreshIntervalId, setRefreshIntervalId] = useState(); + const userInfo = UserInfoStore(); + const userId = userInfo.username; + + useEffect(() => { + fetchRoomInfo(); + + const intervalId = setInterval(fetchRoomInfo, 3000); + setRefreshIntervalId(intervalId); + + return () => clearInterval(intervalId); + }, [chatroom_id, owner]); + + useEffect(() => { + if (fetchInitialRoomIn) { + fetchRoomIn(); + console.log('ChatRoom 입장'); + setFetchInitialRoomIn(false); + console.log('ChatRoom 입장 API 1회 호출'); + fetchRoomInfo(); + } else { + console.log('ChatRoom 입장 API 호출 불가능'); + } + }, []); + + const fetchRoomIn = async () => { + try { + const apiUrl = `http://localhost:8002/chatroom-service/room-in/${chatroom_id}`; + fetch(apiUrl, { + method: 'POST', + body: JSON.stringify(userId), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + console.error('Error fetching room in:', error); + } + }; + + const fetchRoomInfo = async () => { + try { + const response = await fetch(`http://localhost:8002/chatroom-service/rooms/${chatroom_id}/roomInformation`, { + method: 'GET', + }); + const data = await response.json(); + setRoomInfo(data); + + // test 더미데이터 + // setRoomInfo({ + // roomName: 'Sample Room', + // participation: 3, + // owner: 'John Doe', + // chatMemberResponses: [{ username: 'Alice' }, { username: 'Bob' }, { username: 'Charlie' }], + // }); + } catch (error) { + console.error('Error fetching room info:', error); + } + }; + + return ( + + + + + + + + + ); +}; + +export default ChatRoomInfo; + +const ChatRoomInfoWrapper = styled.div` + width: 100%; + height: 100%; + border-radius: 0px 0px 5px 5px; + display: flex; + flex-direction: column; + background-color: var(--color-white); +`; +const UpWrapper = styled.div` + background-color: var(--color-pumpkin); + color: var(--color-white); + border-radius: 0px 5px 0px 0px; +`; +const DownWrapper = styled.div` + background-color: var(--color-white); + border-radius: 0px 0px 5px 0px; +`; diff --git a/frontend/src/components/chatRoomInfo/KickBtn.tsx b/frontend/src/components/chatRoomInfo/KickBtn.tsx new file mode 100644 index 0000000..c3ca148 --- /dev/null +++ b/frontend/src/components/chatRoomInfo/KickBtn.tsx @@ -0,0 +1,11 @@ +import { FiMinusCircle } from 'react-icons/fi'; + +interface KickBtnProps { + onClick: () => void; +} + +const KickBtn: React.FC = ({ onClick }) => { + return ; +}; + +export default KickBtn; diff --git a/frontend/src/components/chatRoomInfo/Member.tsx b/frontend/src/components/chatRoomInfo/Member.tsx new file mode 100644 index 0000000..d624566 --- /dev/null +++ b/frontend/src/components/chatRoomInfo/Member.tsx @@ -0,0 +1,88 @@ +import styled from '@emotion/styled'; +import KickBtn from './KickBtn'; +import ChangeBtn from './ChangeBtn'; +import { useRoomInfoStore } from '../../stores/useRoomInfoStore'; +import { useParams } from 'react-router-dom'; +import { UserInfoStore } from '../../stores/UserInfoStore'; +interface MemberProps { + username: string; +} + +const Member: React.FC = ({ username }) => { + const { owner, setOwner, setChatMemberResponses } = useRoomInfoStore(); + const { chatroom_id } = useParams(); + + const userInfo = UserInfoStore(); + const userId = userInfo.username; + // 멤버 강퇴 함수 + const handleKick = () => { + const apiUrl = `http://localhost:8002/chatroom-service/rooms/${chatroom_id}/kicked/${username}`; + fetch(apiUrl, { + method: 'POST', + body: JSON.stringify(userId), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to kick member'); + } + // 강퇴 후 리렌더링 + setChatMemberResponses([]); + }) + .catch((error) => { + console.error('Error kicking member:', error); + }); + }; + + // 방장 변경 함수 + const handleChangeOwner = () => { + const apiUrl = `http://localhost:8002/chatroom-service/rooms/${chatroom_id}/change-owner/${username}`; + fetch(apiUrl, { + method: 'PATCH', + body: JSON.stringify(userId), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to change owner'); + } + // 강퇴 후 리렌더링 + setOwner(username); + }) + .catch((error) => { + console.error('Error changing owner:', error); + }); + }; + + return ( + + {owner === userId && } + {username} + {owner === userId && } + + ); +}; + +export default Member; + +const MemberWrapper = styled.div` + width: 100%; + hight: 2rem; + display: flex; + flex-direction: row; + padding: 0.3em 0rem 0.3rem 0rem; +`; +const KickWrapper = styled.div` + margin-right: auto; +`; +const MemberName = styled.div` + margin-left: 0.2rem; + text-align: left; +`; +const ChangeOwner = styled.div` + margin-left: auto; +`; diff --git a/frontend/src/components/chatRoomInfo/RoomMember.tsx b/frontend/src/components/chatRoomInfo/RoomMember.tsx new file mode 100644 index 0000000..4a0c4ea --- /dev/null +++ b/frontend/src/components/chatRoomInfo/RoomMember.tsx @@ -0,0 +1,42 @@ +import styled from '@emotion/styled'; +import Member from './Member'; +import { useRoomInfoStore } from '../../stores/useRoomInfoStore'; + +const RoomMember: React.FC = () => { + const { owner, chatMemberResponses } = useRoomInfoStore(); + + return ( + + 방장 : {owner} + 팀원 + + {chatMemberResponses.map((member, index) => ( + + ))} + + + ); +}; + +export default RoomMember; + +const RoomMemberWrapper = styled.div` + display: flex; + flex-direction: column; + padding: 0.5rem 0.625rem 0.5rem 0.625rem; +`; + +const OwnerWrapper = styled.div` + font-size: var(--font-size-mg); + margin-bottom: 1rem; +`; + +const TitleWrapper = styled.div` + font-size: var(--font-size-mg); + margin-bottom: 0.5rem; +`; + +const MemberList = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/frontend/src/components/chatRoomInfo/RoomName.tsx b/frontend/src/components/chatRoomInfo/RoomName.tsx new file mode 100644 index 0000000..bcbd588 --- /dev/null +++ b/frontend/src/components/chatRoomInfo/RoomName.tsx @@ -0,0 +1,35 @@ +import styled from '@emotion/styled'; +import { useRoomInfoStore } from '../../stores/useRoomInfoStore'; +import { listDataProps } from '../../interface/UserListInterface'; + +const RoomName = ({ roomName }: listDataProps) => { + const { participation } = useRoomInfoStore(); + + return ( + + {roomName} + 참여자수 : {participation} + + ); +}; + +export default RoomName; + +const RoomNameWrapper = styled.div` + width: calc(100% - 1.25rem); + height: 2.5rem; + padding: 0.5rem 0.625rem 0.5rem 0.625rem; + display: flex; + flex-direction: column; + justify-content: left; + align-items: left; +`; + +const NameWrapper = styled.div` + margin-bottom: 0.2rem; + font-size: var(--font-size-lg); +`; + +const ParticipantNum = styled.div` + font-size: var(--font-size-mg); +`; diff --git a/frontend/src/components/common/Button.tsx b/frontend/src/components/common/Button.tsx new file mode 100644 index 0000000..86ca15d --- /dev/null +++ b/frontend/src/components/common/Button.tsx @@ -0,0 +1,35 @@ +import styled from "@emotion/styled" + +function changeColor(backgroundColor){ + switch(backgroundColor){ + case "primary": + return "var(--color-buttercap)"; + case "secondary": + return "var(--color-shark)"; + } +} + +export const Button = ({ onClick, label,backgroundColor, ...rest })=>{ + return( + + {label} + + ) +} + +const StyledButton = styled.button` + height: 49px; + padding: 10px 20px; + margin: 0.5rem 0; + background-color: ${(props)=> props.backgroundColor || "var(--color-buttercap)"}; + color: #fff; + border: none; + border-radius: 10px; + cursor: pointer; + transition: 0.3s ease; + align-items: center; + + &:hover { + background-color: #000000; + } +`; \ No newline at end of file diff --git a/frontend/src/components/common/Toast.tsx b/frontend/src/components/common/Toast.tsx new file mode 100644 index 0000000..2876225 --- /dev/null +++ b/frontend/src/components/common/Toast.tsx @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import { toast, ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import styled from '@emotion/styled'; + +export default function Toast({ messageType = 'default', text = 'text', type = 'error' }) { + const getMessageByType = () => { + switch (messageType) { + case 'default': + return '기본 메시지'; + case 'duplicateEmail': + return '이미 사용 중인 이메일입니다.'; + case 'loginRequired': + return '로그인이 필요한 서비스입니다.'; + case 'loginError': + return '로그인이 실패하였습니다.'; + case 'success': + return `성공`; + case 'errId': + return '설정하지 않은 닉네임은 접속이 불가합니다.'; + case 'text': + return text; + case 'logout': + return '로그아웃이 완료되었습니다.'; + default: + return '알 수 없는 유형의 메시지'; + } + }; + + const getType = () => { + switch (type) { + case 'error': + return 'error'; + case 'info': + return 'info'; + case 'success': + return 'success'; + case 'warning': + return 'warning'; + default: + return 'error'; + } + }; + + const notify = () => { + const message = getMessageByType(); + const toastType = getType(); + + switch (toastType) { + case 'error': + toast.error({message}); + break; + case 'info': + toast.info({message}); + break; + case 'success': + toast.success({message}); + break; + case 'warning': + toast.warning({message}); + break; + default: + toast.error('알 수 없는 타입의 메시지'); + } + }; + + useEffect(() => { + notify(); + }, []); // 빈 배열을 두어 한 번만 호출되도록 설정 + + return ( + <> + + + ); +} + +const CustomStyledToast = styled.span` + padding-left: 0.5rem; +`; diff --git a/frontend/src/components/roomPreview/CreateRoomPreview.tsx b/frontend/src/components/roomPreview/CreateRoomPreview.tsx new file mode 100644 index 0000000..afcd643 --- /dev/null +++ b/frontend/src/components/roomPreview/CreateRoomPreview.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { InputForm } from '../../components/auth/InputForm'; +import axios from 'axios'; +import styled from '@emotion/styled'; +import Close from '../../assets/Close.svg'; +import { Button } from '../common/Button'; +import { RoomInfo } from '../../stores/useRoomStore'; +import { useNavigate } from 'react-router-dom'; + +interface CreateRoomPreviewProps { + onClose: () => void; + onAddRoom: () => void; + roominfo: RoomInfo; + username: string; + handleInputChange: (e: React.ChangeEvent) => void; + newRoom: { + roomName: string; + description: string; + owner: string; + hashtag: string; + capacity: number; + }; +} + +const CreateRoomPreview: React.FC = ({ onClose, roominfo, username, handleInputChange }) => { + const navigate = useNavigate(); + const handleAddRoomClick = async () => { + try { + const roomWithOwner = { + roomName: roominfo.roomName, + hashtag: roominfo.hashtag, + capacity: roominfo.capacity, + description: roominfo.description, + owner: username, + }; + console.log(roomWithOwner); + const response = await axios.post('http://localhost:8002/chatroom-service/create', roomWithOwner); + onClose(); + navigate(`/chatroom/${response.data.id.toString()}`); + } catch (error) { + console.error('Error creating room:', error); + } + }; + + const handleCloseClick = () => { + onClose(); + }; + + return ( + + + Close + +

Create Room

+ + + + +
+ +
+ +
+ + + + {toast && } + + + ) + }; export default SigninPage; + +const SignInWrapper = styled.main` + display: flex; + padding: 2rem; + align-items: center; + justify-content: center; +` + +const SignInContainer = styled.section` + display: flex; + flex-direction: column; +` + +const LogoContainer = styled.figure` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + img { + width: 191px; + } +` diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx index ea4f828..6ef3830 100644 --- a/frontend/src/pages/SignupPage.tsx +++ b/frontend/src/pages/SignupPage.tsx @@ -1,5 +1,259 @@ +import { useEffect, useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { InputForm } from "../components/auth/InputForm"; +import { Button } from "../components/common/Button"; +import { useStore } from "zustand"; +import { UserInfoStore } from "../stores/UserInfoStore"; +import styled from '@emotion/styled'; + +import { AuthApis } from "../hooks/react-query/useAuthQuery"; + +type isValid = { + passwordIsValid: boolean; + passwordCheckIsValid: boolean; + emailIsValid: boolean; + usernameIsValid: boolean; + authCode: string; + messageValidPw1Color: 'black' | 'blue'; + messageValidPw2Color: 'black' | 'blue'; +}; + const SignupPage = () => { - return
It's SignupPage!
; + const navigate = useNavigate(); + const [messageValidPw1, setMessageValidPw1] = useState('숫자/영어/특수문자를 혼용하여 3종류를 사용하세요.'); + const [messageValidPw2, setMessageValidPw2] = useState('비밀번호는 1자 이상 20자 이하로 입력하세요.'); + const [messagePw, setMessagePw] = useState(""); + const [messageEmail, setMessageEmail] = useState(""); + const [messageUsername, setMessageUsername] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + + const userInfo = useStore(UserInfoStore); + const [isValid, setIsValid] = useState({ + passwordIsValid: false, + passwordCheckIsValid: false, + emailIsValid: false, + usernameIsValid: false, + authCode: '', + messageValidPw1Color: 'black', + messageValidPw2Color: 'black', + }); + + // const authApis = AuthApis(userInfo); + + // 값 보존을 위한 코드 -> 값 변경되면 false 처리 + useEffect(()=>{ + console.log(isValid) + },[isValid]) + + //inputForm 코드 + const onChange = (e: React.ChangeEvent) => { + const { value, name } = e.target; + + if (name === 'email') { + setIsValid((prev) => ({ ...prev, emailIsValid: false })); + userInfo.updateEmail(value); + } else if (name === 'authCode'){ + setIsValid((prev)=>({...prev, authCode: value})) + } else if (name === 'password') { + userInfo.updatePassword(value); + checkValidPassword(value); + if (isValid.passwordIsValid) + console.log("사용가능한 비밀번호입니다"); + } else if (name === 'password-check') { + setPasswordConfirm(value); + if (userInfo.password === value){ + isValid.passwordCheckIsValid= true; + setMessagePw("일치하는 비밀번호 입니다.")} + else{ + isValid.passwordCheckIsValid= false; + setMessagePw("비밀번호가 일치하지 않습니다.") + } + } else if (name === 'username') { + setIsValid((prev) => ({ ...prev, usernameIsValid: false })); + userInfo.updateUsername(value); + } + } + + // 비밀번호 예외처리 -> 색 변경 + const checkValidPassword = (password: string): void => { + // 숫자/영어/특수문자를 혼용했는가 + const pattern = /^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]+$/; + + // 1~15자내인가 + const isValidLength = password.length >= 1 && password.length <= 15; + setIsValid(prevState => ({ + ...prevState, + messageValidPw1Color: pattern.test(password) ? 'blue' : 'black', + messageValidPw2Color: isValidLength ? 'blue' : 'black', + passwordIsValid: pattern.test(password) && isValidLength, + })); + }; + + // 이메일중복 및 형식체크 -> t/f + const handleCheckEmailDuplicate = async (): Promise => { + // const { data, isLoading } = await AuthApis.useCheckEmailDuplicateQuery(userInfo); + const data = await AuthApis.checkEmailDuplicate(userInfo); + return data; + }; + + // 닉네임 글자수 체크 -> t/f + const checkValidUsername = (username: string): boolean =>{ + //2~15자내인가 + const isValidLength = username.length >= 2 && username.length <= 15; + setIsValid(prevState => ({ + ...prevState, + usernameIsValid: isValidLength + })) + return isValidLength; + } + + // 닉네임중복체크 -> t/f + const handlecheckUsernameDuplicate = async (): Promise => { + const data = await AuthApis.checkUsernameDuplicate(userInfo) + return data; + }; + + // 버튼코드 + const onButtonClick = async (action: string): Promise => { + switch(action){ + case "onSetEmailCheck": + console.log(`API 통신 ${userInfo.email}`) + const res1 = await handleCheckEmailDuplicate(); + + if(!res1) + setMessageEmail('이메일 형식이 잘못됐거나 중복된 이메일입니다.') + else{ + + setMessageEmail('이메일로 전송된 인증코드를 입력해주세요.') + setIsValid((prev) => ({ ...prev, emailIsValid: true })); + break; + } + + case "onSetNicknameCheck": + const res3 = checkValidUsername(userInfo.username); + console.log(`API 통신 ${userInfo.username}`) + const res2 = await handlecheckUsernameDuplicate(); + if(!res2) + setMessageUsername('중복된 닉네임입니다') + else if(!res3){ + setMessageUsername('닉네임은 2~15자이내입니다.') + } + else if(res2 && res3){ + setMessageUsername('사용 가능한 닉네임입니다') + setIsValid((prev) => ({ ...prev, usernameIsValid: true })); + } + break; + + case "onSignup": + console.log(`회원가입 ${userInfo.email}, ${userInfo.password},${passwordConfirm}, ${userInfo.username}, ${isValid.authCode}`); + + if (isValid.passwordIsValid && isValid.passwordCheckIsValid && isValid.emailIsValid && isValid.usernameIsValid) { + const res = await AuthApis.signup(userInfo,passwordConfirm,isValid.authCode); + console.log(res,res); + if (res.id){ + navigate("/signin"); + alert(`${res.username}님 가입을 환영합니다`);} + else + alert(res.message) + } + else + alert("회원가입에 실패했습니다. 입력 정보를 다시 확인해주세요."); + break; + + default: + console.error("error") + } + } + + + return( + + + + {/* email input */} + + + {messageEmail} + + {/* emailToggle */} + { isValid.emailIsValid ?
+ + onButtonClick("onSetEmailCheck")}>이메일을 받지 못하셨나요? +
: null} + + {/* pw input */} +
+ {messageValidPw1} + {messageValidPw2} + {/* password duplicate check */} + + {messagePw} + + {/* username input */} + + + {messageUsername} + +
+
+
+ ) }; export default SignupPage; + + +// 스타일 적용 +const MessageValidPw = styled.p<{ color: string }>` + color: ${(props) => props.color}; + margin: 0 0 0.5rem; + font-size: var(--font-size-xs); +`; + +const SignInWrapper = styled.main` + display: flex; + padding: 2rem; + align-items: center; + justify-content: center; +` + +const SignInContainer = styled.section` + display: flex; + flex-direction: column; + width: 70%; + max-width: 361px; + + p { + position: relative; + font-size: var(--font-size-xs); + } + + span { + position: relative; + margin-left: auto; + padding: 0.5rem; + font-size: var(--font-size-xs); + } + + em { + display: flex; + position: relative; + width: 100%; + justify-content: flex-end; + margin-left: auto; + padding: 0.5rem; + cursor: pointer; + font-size: var(--font-size-xxs); + } +` diff --git a/frontend/src/pages/Signupnaver.tsx b/frontend/src/pages/Signupnaver.tsx new file mode 100644 index 0000000..22460ba --- /dev/null +++ b/frontend/src/pages/Signupnaver.tsx @@ -0,0 +1,60 @@ +import { useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import axios from "axios"; +import { UserInfoStore } from '../stores/UserInfoStore'; +import { useStore } from 'zustand'; + +const Signupnaver = () => { + const navigate = useNavigate(); + const userInfo = useStore(UserInfoStore); + const [code, setCode] = useState(null); + + const userAccessToken = () => { + // window.location.href.includes('access_token') && getToken(); + getToken(); + navigate('/'); + }; + + const getToken = async () => { + const token = window.location.href.split('=')[1].split('&')[0]; + localStorage.setItem('Accesstoken', token); + + // // FE 코드 CORS로 proxy적용 + // try{ + // const userInfoResponse = await axios.get('/api/v1/nid/me', { + // headers: { + // 'Authorization': `Bearer ${token}` + // } + // }); + // console.log(userInfoResponse.data.response.email); + // userInfo.updateEmail(userInfoResponse.data.response.email); + // userInfo.updateUsername(userInfoResponse.data.response.nickname); + // } catch(error) { + // console.error("FE proxy 오류",error); + // } + + + // BE에게 전달 + try { + const response = await axios.post('/oauth2/token', { + code: code + }); + console.log(response.data); + + navigate('/'); + } catch (error) { + console.error('BE 전달오류',error); + } + }; + + useEffect(() => { + let code = new URL(window.location.href).searchParams.get("code"); + setCode(code); + console.log("BE 전달:",code); + userAccessToken(); + }, []); + + return
; +}; + +export default Signupnaver; diff --git a/frontend/src/pages/VideoCallPage.tsx b/frontend/src/pages/VideoCallPage.tsx new file mode 100644 index 0000000..42b5e03 --- /dev/null +++ b/frontend/src/pages/VideoCallPage.tsx @@ -0,0 +1,67 @@ +// import Layout from '../components/layout/Layout'; +import { Container, Wrapper, SideWrapper, MainWrapper } from '../styles/Layout'; +import styled from '@emotion/styled'; +import { useQuery } from '@tanstack/react-query'; +import { getUserData } from '../hooks/react-query/useUserData'; +import { useParams } from 'react-router-dom'; +import VideoCall from '../components/video/VideoCall'; + +const VideoCallPage = () => { + const accessToken = localStorage.getItem('Accesstoken'); + const isLoggedIn = accessToken !== null; + const { chatroom_id } = useParams<{ chatroom_id: string }>(); + const roomId = chatroom_id ?? ''; + const { + data: userData, + isLoading: isLoading, + isError: isError, + } = useQuery({ + queryKey: ['userData'], + queryFn: getUserData, + staleTime: 5000, + enabled: isLoggedIn, + }); + let username = userData?.username; + + if (isLoading) return
Loading...
; + if (isError) return
Error fetching user data
; + + return ( + + + + + + + + + + + + + ); +}; + +export default VideoCallPage; + +export const VideoWrapper = styled.div` + width: calc(50% - 1rem); + height: calc(100% - 1rem); + background-color: var(--color-white); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0.5rem; +`; + +export const ChatWrapper = styled.div` + width: calc(50% - 1rem); + height: calc(100% - 1rem); + background-color: var(--color-shark); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0.5rem; +`; diff --git a/frontend/src/pages/WelcomePage.tsx b/frontend/src/pages/WelcomePage.tsx index 83658d4..e6edeac 100644 --- a/frontend/src/pages/WelcomePage.tsx +++ b/frontend/src/pages/WelcomePage.tsx @@ -1,7 +1,176 @@ +import { useRef, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getUserData } from '../hooks/react-query/useUserData'; +import { useLoginWebSocket } from '../hooks/custom-hook/useLoginWebSocket'; +import { Sidebar } from '../components/welcome/Sidebar'; +import { Search } from '../components/welcome/Search'; +import { Favorite } from '../components/welcome/Favorite'; +import Logo from '../assets/Logo.svg'; +import Toast from '../components/common/Toast'; +import { Container, Wrapper, SideWrapper, FlexCenterWrapper } from '../styles/Layout'; +import styled from '@emotion/styled'; +import { useStore } from 'zustand'; +import { RoomInfo, RoomsInfo } from '../stores/useRoomStore'; +import { UserInfoStore } from '../stores/UserInfoStore'; +import RoomPreviewList from '../components/roomPreview/RoomPreviewList'; +import CreateRoomPreview from '../components/roomPreview/CreateRoomPreview'; +import { GetAllRoomsApis } from '../hooks/react-query/useGetAllRoom'; + const WelcomePage = () => { - return( - <>It's Home! - ) + const navigate = useNavigate(); + const clientConnected = useRef(false); + const userinfo = useStore(UserInfoStore); + const roominfo = useStore(RoomInfo); + const [loginText, setLoginText] = useState('Login'); + const [showToast, setShowToast] = useState(false); + const [CreateRoom, setCreateRoom] = useState(false); + const { connect, unconnect } = useLoginWebSocket(); + const accessToken = localStorage.getItem('Accesstoken'); + const isLoggedIn = accessToken !== null; + + const handleCreateRoom = () => { + setCreateRoom(true); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + roominfo.update(name, value); + }; + + const { + data: userData, + isLoading, + isError, + } = useQuery({ + queryKey: ['userData'], + queryFn: getUserData, + staleTime: 60000, + enabled: isLoggedIn, + }); + + const { + data: roomsPreviewListData, + isLoading: roomsIsLoading, + isError: roomsIsError, + refetch: refetchRooms, + } = useQuery({ + queryKey: ['roomPreviewListData'], + queryFn: GetAllRoomsApis.getAllRooms, + staleTime: 3000, + enabled: isLoggedIn, + refetchInterval: 3000, + }); + + useEffect(() => { + if (isLoggedIn && userData) { + setLoginText('Logout'); + connect(userData.username, '온라인'); + userinfo.updateUsername(userData.username); + } else { + setLoginText('Login'); + } + }, [isLoggedIn, userData]); + + useEffect(() => { + if (isLoading) return; + if (isError) { + console.error('Error fetching user data:', isError); + return; + } + if (!clientConnected.current) { + clientConnected.current = true; + } + }, [isLoading, isError]); + + const handleLoginClick = () => { + if (loginText === 'Login') { + navigate('/signin'); + } else { + localStorage.removeItem('Accesstoken'); + localStorage.removeItem('Refreshtoken'); + unconnect(); + setLoginText('Login'); + setShowToast(true); + } + }; + + return ( + <> + {/* 전체 컴포넌트와 토스트 컴포넌트 함께 보여주기 */} + + + + + {/* Logo */} + + logo + + + TadakTadak + {isLoggedIn ? userData?.username : '로그인이 필요한 서비스입니다.'} + + {/* Search */} + + + {/* Menu */} +
+ + +
+ + + + + + + {/* Favorite */} + + + + } + bottom={} + /> +
+ + + {CreateRoom && ( + setCreateRoom(false)} + handleInputChange={handleInputChange} + username={userinfo.username} + roominfo={roominfo} + /> + )} + +
+
+ {showToast && } + + ); }; -export default WelcomePage; \ No newline at end of file +export default WelcomePage; + +const ServiceText = styled.div` + font-size: var(--font-size-lg); + padding: 12px 16px; + font-weight: 700; +`; + +const UsernameText = styled.div` + font-size: var(--font-size-sm); + padding: 0px 16px; +`; + +const LogoDiv = styled.div` + padding: 12px 16px 0; +`; + +const MainContainer = styled.div` + height: 100%; + width: calc(100% - 11.5rem); + ${FlexCenterWrapper} +`; diff --git a/frontend/src/stores/UserInfoStore.ts b/frontend/src/stores/UserInfoStore.ts new file mode 100644 index 0000000..b4dfcc5 --- /dev/null +++ b/frontend/src/stores/UserInfoStore.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +export interface UserInfo { + email: string; + password: string; + username: string; + updateEmail: (email: UserInfo['email']) => void; + updatePassword: (password: UserInfo['password']) => void; + updateUsername: (username: UserInfo['username']) => void; +} + +const createUserInfoStore = (set) => ({ + email: '', + password: '', + username: '', + updateEmail: (email: string) => set({ email }), + updatePassword: (password: string) => set({ password }), + updateUsername: (username: string) => set({ username }), +}); + +let userInfoStoreTemp; + +//devtools +if (import.meta.env.DEV) { + userInfoStoreTemp = create()(devtools(createUserInfoStore, { name: 'userInfo' })); +} else { + userInfoStoreTemp = create()(createUserInfoStore); +} + +export const UserInfoStore = userInfoStoreTemp; \ No newline at end of file diff --git a/frontend/src/stores/useChatStore.ts b/frontend/src/stores/useChatStore.ts new file mode 100644 index 0000000..83bfb37 --- /dev/null +++ b/frontend/src/stores/useChatStore.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand'; +import { Message } from '../interface/ChatInterface'; + +type ChatStore = { + // id: string; + messages: Message[]; + inputMessage: string; + // setId: (id: string) => void; + setMessages: (messages: (prev: Message[]) => Message[]) => void; + setInputMessage: (inputMessage: string) => void; +}; + +export const useChatStore = create((set) => ({ + // id: '', + messages: [], + inputMessage: '', + // setId: (id) => set({ id }), + setMessages: (messages) => set((state) => ({ messages: messages(state.messages) })), + setInputMessage: (inputMessage) => set({ inputMessage }), +})); diff --git a/frontend/src/stores/useRoomInfoStore.ts b/frontend/src/stores/useRoomInfoStore.ts new file mode 100644 index 0000000..0e4fc30 --- /dev/null +++ b/frontend/src/stores/useRoomInfoStore.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +interface RoomInfoState { + roomName: string; + participation: number; + owner: string; + kicked: boolean; + fetchInitialRoomIn: boolean; + chatMemberResponses: { username: string }[]; + setRoomInfo: (info: Partial) => void; + setOwner: (owner: string) => void; + setKicked: (kicked: boolean) => void; + setFetchInitialRoomIn: (fetchInitialRoomIn: boolean) => void; + setChatMemberResponses: (responses: { username: string }[]) => void; +} + +export const useRoomInfoStore = create((set) => ({ + roomName: '', + participation: 0, + owner: '', + kicked: false, + fetchInitialRoomIn: true, + chatMemberResponses: [], + setRoomInfo: (info) => set((state) => ({ ...state, ...info })), + setOwner: (owner) => set({ owner }), + setKicked: (kicked) => set({ kicked }), + setFetchInitialRoomIn: (fetchInitialRoomIn) => set({ fetchInitialRoomIn }), + setChatMemberResponses: (responses) => set({ chatMemberResponses: responses }), +})); diff --git a/frontend/src/stores/useRoomStore.ts b/frontend/src/stores/useRoomStore.ts new file mode 100644 index 0000000..41babf9 --- /dev/null +++ b/frontend/src/stores/useRoomStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; +import { devtools, persist } from "zustand/middleware"; + +export interface RoomInfo { + roomId: string; + roomName: string; + description: string; + participation : string; + capacity: number; + owner: string; + hashtag: string; + + updateRoomId: (roomId: RoomInfo['roomId']) => void; + updateRoomName: (roomName: RoomInfo['roomName']) => void; + updateDescription: (description: RoomInfo['description']) => void; + updateParticipationn: (participation: RoomInfo['participation']) => void; + updateCapacity: (capacity: RoomInfo['capacity']) => void; + updateOwner: (owner: RoomInfo['owner']) => void; + updateHashtag: (hashtag: string) => void; + + update: (field: string, value: string) => void; +} + +interface RoomsInfo { + rooms: RoomInfo[]; + setRooms: (rooms: RoomsInfo['rooms']) => void; +} + +const createRoomStore = (set) => ({ + roomId: '', + roomName: '', + description: '', + participation: '', + capacity: 1, + owner: '', + hashtag: '', + updateRoomId: (roomId: string) => set({ roomId }), + updateRoomName: (roomName: string) => set({ roomName }), + updateDescription: (description: string) => set({ description }), + updateParticipationn: (participation: string) => set({ participation }), + updateCapacity: (capacity: number) => set({ capacity }), + updateOwner: (owner: string) => set({ owner }), + updateHashtag: (hashtag: string) => set({ hashtag }), + + update: (field, value) => set({ [field]: value }), +}); + +let roomInfoTemp; +let roomsInfoTemp; + +const createRoomsStore = (set) => ({ + rooms: [] as RoomInfo[], + setRooms: (rooms: RoomInfo[]) => set({ rooms }), +}); + +//devtools +if (import.meta.env.DEV) { + roomInfoTemp = create()(devtools(createRoomStore, { name: 'roomInfo' })); +} else { + roomInfoTemp = create()(createRoomStore); +} + +roomsInfoTemp = create()(createRoomsStore); + +export const RoomInfo = roomInfoTemp; +export const RoomsInfo = roomsInfoTemp; \ No newline at end of file diff --git a/frontend/src/stores/useUserListStore.ts b/frontend/src/stores/useUserListStore.ts new file mode 100644 index 0000000..be5bf34 --- /dev/null +++ b/frontend/src/stores/useUserListStore.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; +import { UserList } from '../interface/UserListInterface'; + +type UserListStore = { + userlist: UserList[]; + setUserList: (userlist: UserList[]) => void; +}; + +export const useUserListStore = create((set) => ({ + userlist: [], + setUserList: (userlist) => set({ userlist }), +})); diff --git a/frontend/src/stores/useVideoCallStore.ts b/frontend/src/stores/useVideoCallStore.ts new file mode 100644 index 0000000..8384789 --- /dev/null +++ b/frontend/src/stores/useVideoCallStore.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +interface VideoCallState { + audioEnabled: boolean; + videoEnabled: boolean; + setAudioEnabled: (enabled: boolean) => void; + setVideoEnabled: (enabled: boolean) => void; +} + +export const useVideoCallStore = create((set) => ({ + audioEnabled: true, + videoEnabled: true, + setAudioEnabled: (enabled) => set({ audioEnabled: enabled }), + setVideoEnabled: (enabled) => set({ videoEnabled: enabled }), +})); diff --git a/frontend/src/styles/ComponentLayout.ts b/frontend/src/styles/ComponentLayout.ts new file mode 100644 index 0000000..139e174 --- /dev/null +++ b/frontend/src/styles/ComponentLayout.ts @@ -0,0 +1,32 @@ +export const ChattingRoomHeader = ` + background-color: var(--color-pumpkin); + color: var(--color-white); + border-radius: 5px 5px 0px 0px; +`; + +export const ColumnDisplay = ` + display: flex; + flex-direction: column; +`; + +export const OverFlowScrollbar = ` + overflow: auto; + + &::-webkit-scrollbar { + width: 0.5rem; + height: 0rem; + } + + &::-webkit-scrollbar-thumb { + background-color: var(--color-crusta); + border-radius: 0.25rem; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: var(--color-pumpkin); + } +`; diff --git a/frontend/src/styles/GlobalStyle.ts b/frontend/src/styles/GlobalStyle.ts new file mode 100644 index 0000000..06c5967 --- /dev/null +++ b/frontend/src/styles/GlobalStyle.ts @@ -0,0 +1,41 @@ +import { css } from '@emotion/react'; + +// fontSize 정의 +const fontSizeLg = '1.375rem'; // 22px +const fontSizeMd = '1rem'; // 16px +const fontSizeSm = '0.875rem'; // 14px +const fontSizeXs = '0.75rem'; // 12px +const fontSizeXxs = '0.625rem'; // 10px + +//color 정의 +const colorWhite = '#ffffff'; +const colorButterCap = '#F49D1A'; +const colorCrusta = '#FF8533'; +const colorPumpkin = '#FF6F0F'; +const colorMercury = '#e5e5e5'; +const colorShark = '#2B2D31'; +const colorRangoonGreen = '#1B1A17'; +const colorHarlequin = '#2ACC02'; +const colorOrient = '#02607E'; +const colorWildsand = '#F5F5F5'; + +export const GlobalStyle = css` + :root { + --font-size-lg: ${fontSizeLg}; + --font-size-md: ${fontSizeMd}; + --font-size-sm: ${fontSizeSm}; + --font-size-xs: ${fontSizeXs}; + --font-size-xxs: ${fontSizeXxs}; + + --color-white: ${colorWhite}; + --color-buttercap: ${colorButterCap}; + --color-crusta: ${colorCrusta}; + --color-pumpkin: ${colorPumpkin}; + --color-mercury: ${colorMercury}; + --color-shark: ${colorShark}; + --color-rangoongreen: ${colorRangoonGreen}; + --color-harlequin: ${colorHarlequin}; + --color-orient: ${colorOrient}; + --color-wildsand: ${colorWildsand} + } +`; diff --git a/frontend/src/styles/Layout.ts b/frontend/src/styles/Layout.ts new file mode 100644 index 0000000..e890b25 --- /dev/null +++ b/frontend/src/styles/Layout.ts @@ -0,0 +1,41 @@ +import styled from '@emotion/styled'; + +export const Container = styled.div` + width: 100vw; + height: 100vh; +`; + +export const Wrapper = styled.section` + width: 100%; + height: 100%; + min-width: 768px; + display: flex; + flex-direction: row; + background-color: var(--color-shark); +`; + +export const MainWrapper = styled.div` + width: calc(100% - 23rem); + height: 100%; + display: flex; + flex-direction: row; +`; + +export const SideWrapper = styled.div` + width: 11.5rem; + height: calc(100% - 1rem); + background-color: var(--color-shark); + margin: 0.5rem 0rem 0.5rem 0rem; +`; + +export const FlexCenterWrapper = ` + display: flex; + justify-content: center; + align-items: center; +`; + +export const ChattingRoomHeader = ` + background-color: var(--color-pumpkin); + color: var(--color-white); + border-radius: 5px 5px 0px 0px; +`; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a7fc6fb..66eda50 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,6 +5,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "types": ["vite/client"], /* Bundler mode */ "moduleResolution": "bundler", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5a33944..51dcc68 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,32 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], -}) + optimizeDeps: { + include: ['@stomp/stompjs'], + }, + define: { + global: {}, + }, + plugins: [react()], + server: { + // Proxy 설정 + proxy: { + // 경로가 "/api" 로 시작하는 요청을 대상으로 proxy 설정 + '/api': { + // 요청 전달 대상 서버 주소 설정 + target: 'https://openapi.naver.com/', + // 요청 헤더 host 필드 값을 대상 서버의 호스트 이름으로 변경 + changeOrigin: true, + // 요청 경로에서 '/api' 제거 + rewrite: (path) => path.replace(/^\/api/, ''), + // SSL 인증서 검증 무시 + secure: false, + // WebSocket 프로토콜 사용 + ws: true, + }, + }, + } +}); +