Skip to content

Commit eab61f4

Browse files
authored
Merge pull request #8 from jsybf/dev
백엔드 정리
2 parents 8303db2 + 5e2b1b0 commit eab61f4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1825
-3
lines changed

.gitignore

+8-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1-
/backend/.env
1+
# env file
2+
/backend/.env
3+
.env
4+
5+
# editor and ide
6+
.idea
7+
__pycache__/
8+
=======

backend/Dockerfile

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Use the official Python image from the Docker Hub
2+
FROM python:3.10.2-slim
3+
4+
# Set the working directory in the container
5+
WORKDIR /app
6+
7+
# Install system dependencies
8+
RUN apt-get update && apt-get install -y \
9+
gcc \
10+
pkg-config \
11+
default-libmysqlclient-dev \
12+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
13+
14+
# Install pipenv
15+
RUN pip install --no-cache-dir pipenv
16+
17+
# Copy the Pipfile and Pipfile.lock into the container
18+
COPY Pipfile Pipfile.lock /app/
19+
20+
# # Update Pipfile.lock
21+
# RUN pipenv lock
22+
23+
# Install dependencies using pipenv
24+
RUN pipenv install --deploy --ignore-pipfile
25+
26+
# Copy the rest of the application code into the container
27+
COPY ./app/ /app/
28+
29+
# Expose port 8000 for the FastAPI app
30+
EXPOSE 8000
31+
32+
# Run the FastAPI application using uvicorn
33+
CMD ["pipenv", "run", "python", "main.py"]

backend/Pipfile

+11
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ verify_ssl = true
44
name = "pypi"
55

66
[packages]
7+
pyjwt = "==2.8.0"
8+
python-dotenv = "==1.0.1"
9+
httpx = "*"
10+
fastapi = {extras = ["standard"], version = "==0.115.1"}
11+
uvicorn = {extras = ["standard"], version = "*"}
12+
sqlalchemy = "==2.0.36"
13+
mysqlclient = "*"
14+
python-multipart = "*"
15+
boto3 = "*"
16+
boto3-stubs = {extras = ["s3"], version = "*"}
17+
pytest = "*"
718

819
[dev-packages]
920

backend/Pipfile.lock

+936-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/api/auth_router.py

Whitespace-only changes.

backend/api/user_router.py

Whitespace-only changes.

backend/api/voice_router.py

Whitespace-only changes.
File renamed without changes.
File renamed without changes.

backend/app/api/auth_router.py

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, HTTPException, Depends
4+
from fastapi.responses import RedirectResponse
5+
6+
from auth import JwtAuth, request_access_token, request_user_info
7+
from core import utils, Config
8+
from entity import User
9+
from models import JwtResponse
10+
from repository import UserRepository, get_user_repository
11+
12+
router = APIRouter()
13+
14+
15+
@router.get("/login")
16+
async def login() -> RedirectResponse:
17+
"""
18+
login main entry point
19+
책임
20+
- kakao oauth server로 redirect
21+
"""
22+
kakao_login_url = "https://kauth.kakao.com/oauth/authorize"
23+
query_param = {
24+
"client_id": Config.Kakako.ACCESS_KEY,
25+
"redirect_uri": Config.Kakako.REDIRECT_URI,
26+
"response_type": "code",
27+
}
28+
return RedirectResponse(
29+
kakao_login_url + "?" + utils.build_query_param(query_param)
30+
)
31+
32+
33+
@router.get("/authenticate")
34+
async def auhtenticate(
35+
user_repo: Annotated[UserRepository, Depends(get_user_repository)],
36+
code: str | None = None,
37+
error: str | None = None,
38+
error_description: str | None = None,
39+
) -> JwtResponse:
40+
"""
41+
(참고) 인증 흐름
42+
1. /login 에서 kauth.kakako.com/oauth/authroize로 redirect
43+
2. 카카오톡 서버에서 이 api를 호출하며 query param으로 인증코드와 에러 코드들을 넘겨준다.
44+
45+
책임
46+
- 카카오톡 서버로부터 전달받은 인증코드로 access_token 요청 보내기
47+
- access_token으로 user info 요청 보내기
48+
:param
49+
code: 카카오톡 access_token 인증코드
50+
"""
51+
if not code:
52+
raise HTTPException(
53+
status_code=400, detail={"error": error, "error_desc": error_description}
54+
)
55+
56+
access_token: str = request_access_token(
57+
redirect_uri=Config.Kakako.REDIRECT_URI,
58+
auth_code=code,
59+
client_id=Config.Kakako.ACCESS_KEY,
60+
)["access_token"]
61+
user_kakao_id: int = int(request_user_info(access_token)["id"])
62+
63+
# register new user
64+
# user: User = User(kakao_id=user_id, username="foo", nickname="foo")
65+
user: User | None = user_repo.find_by_kakao_id(user_kakao_id)
66+
if user is None:
67+
user = User(kakao_id=user_kakao_id, username="foo", nickname="foo")
68+
user_repo.insert(user)
69+
70+
if user.id is None:
71+
raise Exception("user id must not be None")
72+
73+
# TODO: expire and path
74+
access_jwt = JwtAuth.create_token(user.id)
75+
return JwtResponse(access_token=access_jwt)

backend/app/api/user_router.py

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from typing import Annotated
2+
3+
import jwt
4+
from fastapi import APIRouter, Depends, HTTPException
5+
from jwt.exceptions import InvalidTokenError
6+
7+
from auth import JwtAuth
8+
from models import UserInvitationUrl
9+
from core import Config
10+
from repository import get_user_repository, UserRepository
11+
from entity import User
12+
13+
router = APIRouter(dependencies=[Depends(JwtAuth())])
14+
15+
16+
def create_invitation_token(user_id: int) -> str:
17+
return jwt.encode(
18+
{"user_id": user_id, "type": "user_invitation"},
19+
Config.JWT.SECRET_KEY,
20+
Config.JWT.ALGORITHM,
21+
)
22+
23+
24+
@router.get("/invitation/user")
25+
def get_user_id_by_invitation(
26+
token: str, user_repo: Annotated[UserRepository, Depends(get_user_repository)]
27+
) -> int:
28+
"""
29+
@param
30+
token: invitation jwt token
31+
"""
32+
try:
33+
user_id: int = int(
34+
jwt.decode(token, Config.JWT.SECRET_KEY, Config.JWT.ALGORITHM)["user_id"]
35+
)
36+
except InvalidTokenError as e:
37+
raise HTTPException(401, detail=str(e))
38+
39+
user: User | None = user_repo.find_by_user_id(user_id)
40+
if user is None:
41+
raise HTTPException(status_code=404, detail="user not found")
42+
43+
return user_id
44+
45+
46+
@router.get("/user/invitation")
47+
def generate_invitation_url(
48+
user_id: Annotated[int, Depends(JwtAuth())],
49+
) -> UserInvitationUrl:
50+
"""
51+
invitation url format: ${server_host}/invitation?token=${jwt}
52+
example: http://0.0.0.0:8000/invitation?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJ0eXBlIjoidXNlcl9pbnZpdGF0aW9uIn0.LpR5N0xMXGr9Yn8UjsNVm6kcdkdH78HTvMId2f-47gY"
53+
"""
54+
jwt: str = create_invitation_token(user_id)
55+
invitation_url = f"{Config.ENV.SERVER_HOST}/invitation?token={jwt}"
56+
return UserInvitationUrl(url=invitation_url)

backend/app/api/voice_router.py

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from typing import Annotated
2+
from uuid import uuid4, UUID
3+
4+
from fastapi import APIRouter, Depends, UploadFile, HTTPException, Response
5+
6+
from auth import JwtAuth
7+
from entity import User, Voice
8+
from models import VoiceMetaData, VoiceIds
9+
from repository import (
10+
get_voice_repository,
11+
get_user_repository,
12+
VoiceRepository,
13+
UserRepository,
14+
)
15+
from s3_service import upload_audio, download_audio
16+
17+
router = APIRouter(dependencies=[Depends(JwtAuth())])
18+
19+
20+
@router.post("/voice")
21+
async def upload_voice(
22+
to_user_id: int,
23+
audio_file: UploadFile,
24+
from_user_id: Annotated[int, Depends(JwtAuth())],
25+
voice_repo: Annotated[VoiceRepository, Depends(get_voice_repository)],
26+
user_repo: Annotated[UserRepository, Depends(get_user_repository)],
27+
) -> VoiceMetaData:
28+
# save voice data to db
29+
from_user: User | None = user_repo.find_by_user_id(from_user_id)
30+
if from_user is None:
31+
raise HTTPException(status_code=404, detail="can't find user")
32+
33+
s3_id = uuid4()
34+
35+
voice = Voice(from_user=from_user.id, to_user=to_user_id, s3_id=s3_id.bytes)
36+
voice_repo.insert(voice)
37+
38+
# upload to s3
39+
upload_audio(f"{s3_id}.mp3", await audio_file.read())
40+
41+
# (tmp)
42+
return VoiceMetaData(
43+
id=voice.id,
44+
s3_id=UUID(bytes=voice.s3_id),
45+
from_user=voice.from_user,
46+
to_user=voice.to_user,
47+
annonymous=voice.annonymous,
48+
is_read=voice.is_read,
49+
is_correct=voice.is_correct,
50+
created_at=voice.created_at,
51+
)
52+
53+
54+
@router.get("/voice/{voice_id}/meta")
55+
async def get_voice_metadata(
56+
voice_id: int,
57+
user_id: Annotated[int, Depends(JwtAuth())],
58+
voice_repo: Annotated[VoiceRepository, Depends(get_voice_repository)],
59+
) -> VoiceMetaData:
60+
# find voice
61+
voice = voice_repo.find_by_id(voice_id)
62+
if voice is None:
63+
raise HTTPException(status_code=404, detail="metadata not found")
64+
65+
# authorize: 보낸 쪽(from_user), 받는 쪽(to_user)모두 권한 있임
66+
67+
if user_id not in [voice.to_user, voice.from_user]:
68+
raise HTTPException(status_code=401, detail="unauthorized")
69+
70+
print(f"voice_id: {voice_id} user_id: {user_id}")
71+
return VoiceMetaData(
72+
id=voice.id,
73+
s3_id=UUID(bytes=voice.s3_id),
74+
from_user=voice.from_user,
75+
to_user=voice.to_user,
76+
annonymous=voice.annonymous,
77+
is_read=voice.is_read,
78+
is_correct=voice.is_correct,
79+
created_at=voice.created_at,
80+
)
81+
82+
83+
@router.get("/voice/{voice_id}/audio")
84+
async def get_voice_audio(
85+
voice_id: int,
86+
user_id: Annotated[int, Depends(JwtAuth())],
87+
voice_repo: Annotated[VoiceRepository, Depends(get_voice_repository)],
88+
) -> Response:
89+
# find voice
90+
voice = voice_repo.find_by_id(voice_id)
91+
if voice is None:
92+
raise HTTPException(status_code=404, detail="metadata not found")
93+
94+
# authorize
95+
# authorize: 보낸 쪽(from_user), 받는 쪽(to_user)모두 권한 있임
96+
if user_id not in [voice.to_user, voice.from_user]:
97+
raise HTTPException(status_code=401, detail="unauthorized")
98+
99+
s3_id = str(UUID(bytes=voice.s3_id))
100+
101+
# TODO: if audio file doesn't exist in bucket
102+
audio_binary: bytes = download_audio(f"{s3_id}.mp3")
103+
104+
return Response(content=audio_binary, media_type="audio/mpeg")
105+
106+
107+
@router.get("/user/voices")
108+
async def get_voice_id_list(
109+
user_id: Annotated[int, Depends(JwtAuth())],
110+
voice_repo: Annotated[VoiceRepository, Depends(get_voice_repository)],
111+
) -> VoiceIds:
112+
received_voices: list[Voice] = voice_repo.find_by_to_user_id(user_id)
113+
sent_voices: list[Voice] = voice_repo.find_by_from_user_id(user_id)
114+
115+
return VoiceIds(
116+
received_voice_ids=list(map(lambda v: v.id, received_voices)),
117+
sent_voice_ids=list(map(lambda v: v.id, sent_voices)),
118+
)

backend/app/auth/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .jwt_handler import JwtAuth
2+
from .kakao_oauth import request_access_token, request_user_info
3+
4+
__all__ = ["request_access_token", "request_user_info", "JwtAuth"]

backend/app/auth/jwt_handler.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import jwt
2+
from fastapi import HTTPException, Request
3+
from fastapi.security import HTTPBearer
4+
from jwt.exceptions import InvalidTokenError
5+
6+
from core import Config
7+
8+
9+
class JwtAuth(HTTPBearer):
10+
def __init__(self, auto_error: bool = True):
11+
super(JwtAuth, self).__init__(auto_error=auto_error)
12+
13+
async def __call__(self, request: Request) -> int:
14+
auth_header: str = request.headers.get("Authorization")
15+
if not auth_header:
16+
raise HTTPException(401, detail=f"require Authorization header")
17+
try:
18+
scheme, token = auth_header.split(" ")
19+
if scheme.lower() != "bearer":
20+
raise Exception()
21+
except Exception:
22+
raise HTTPException(
23+
401, detail=f"scheme should be bearer current:[{auth_header}] "
24+
)
25+
26+
try:
27+
request.state.token_info = jwt.decode(
28+
token, Config.JWT.SECRET_KEY, Config.JWT.ALGORITHM
29+
)["user_id"]
30+
except InvalidTokenError as e:
31+
raise HTTPException(401, detail=str(e))
32+
33+
return int(request.state.token_info)
34+
35+
@staticmethod
36+
def create_token(user_id: int) -> str:
37+
return jwt.encode(
38+
{"user_id": user_id}, Config.JWT.SECRET_KEY, Config.JWT.ALGORITHM
39+
)

backend/app/auth/kakao_oauth.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import httpx
2+
3+
4+
def request_access_token(redirect_uri: str, auth_code: str, client_id: str) -> dict:
5+
# TODO: http exception handling
6+
resp = httpx.post(
7+
"https://kauth.kakao.com/oauth/token",
8+
data={
9+
"client_id": client_id,
10+
"redirect_uri": redirect_uri,
11+
"code": auth_code,
12+
"grant_type": "authorization_code",
13+
},
14+
)
15+
return resp.json()
16+
17+
18+
def request_user_info(access_token: str) -> dict:
19+
# TODO: http exception handling
20+
resp = httpx.post(
21+
url="https://kapi.kakao.com/v2/user/me",
22+
headers={"Authorization": f"Bearer {access_token}"},
23+
)
24+
25+
return resp.json()

backend/app/core/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .config import Config
2+
3+
__all__ = ["Config"]

0 commit comments

Comments
 (0)