add login

This commit is contained in:
jaehwang 2026-09-15 16:00:54 +09:00
parent b27a7b528a
commit c4efd6406d
22 changed files with 786 additions and 17 deletions

View File

@ -26,8 +26,29 @@ uv sync
| `TYPECAST_API_KEY` | 롱컷 tts (축제는 OpenAI를 써서 필요 없다) | | `TYPECAST_API_KEY` | 롱컷 tts (축제는 OpenAI를 써서 필요 없다) |
| `SUNO_API_KEY` · `SUNO_CALLBACK_URL` | bgm | | `SUNO_API_KEY` · `SUNO_CALLBACK_URL` | bgm |
| `HIGGSFIELD_ACCOUNT` | upscale · i2v 계정 가드 | | `HIGGSFIELD_ACCOUNT` | upscale · i2v 계정 가드 |
| `MYSQL_URL` | DB 세션 | | `MYSQL_HOST` · `MYSQL_PORT` · `MYSQL_USER` · `MYSQL_PASSWORD` · `MYSQL_DB` | DB 세션 |
| `AZURE_BLOB_BASE_URL` · `AZURE_BLOB_SAS_TOKEN` | blob 업로드 | | `AZURE_BLOB_BASE_URL` · `AZURE_BLOB_SAS_TOKEN` | blob 업로드 |
| `GOOGLE_CLIENT_ID` | 구글 로그인. 비면 로그인만 꺼지고 서버는 뜬다 |
| `JWT_SECRET` | 세션 쿠키 서명. 바뀌면 로그인된 사람이 전부 풀린다 |
### 구글 로그인
구글 클라우드 콘솔에서 **웹 애플리케이션** OAuth 클라이언트를 만들고 승인된 자바스크립트
원본에 서비스 주소를 넣는다. 발급된 client id를 `GOOGLE_CLIENT_ID`에 넣는다.
`client_secret`은 쓰지 않는다 — 코드 교환 없이 ID 토큰만 검증한다.
`JWT_SECRET`은 아무 긴 임의 문자열이면 된다.
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
```
사용자는 처음 로그인할 때 만들어지고 잡을 `job_limit`(기본 3)개까지 만들 수 있다.
무제한으로 둘 계정은 `user` 테이블의 그 값을 올린다.
```sql
UPDATE user SET job_limit = 100000 WHERE email = 'jhyeu@o2o.kr';
```
### Docker ### Docker

View File

@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from pipelines import worker from pipelines import worker
from routers import archive, f1, playreel from routers import archive, auth, f1, playreel
from utils import blob from utils import blob
from utils.database import close_engine, create_missing_tables from utils.database import close_engine, create_missing_tables
@ -21,6 +21,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="poster-alive", lifespan=lifespan) app = FastAPI(title="poster-alive", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(f1.router) app.include_router(f1.router)
app.include_router(playreel.router) app.include_router(playreel.router)
app.include_router(archive.router) app.include_router(archive.router)

View File

@ -33,6 +33,7 @@ from services.split_detail import split_details
from services.tts import synthesize from services.tts import synthesize
from services.upscale_poster import as_png_path, upscale_poster from services.upscale_poster import as_png_path, upscale_poster
from tables.task import PlayreelTask from tables.task import PlayreelTask
from tables.user import UNASSIGNED_USER_ID
from utils import blob from utils import blob
from utils.higgsfield import KLING_3_0 from utils.higgsfield import KLING_3_0
from utils.video import frames_at from utils.video import frames_at
@ -79,10 +80,10 @@ def thumbnail_url(task: PlayreelTask) -> str | None:
return artifact_url(PIPELINE, task.id, THUMBNAIL) if task.video_url else None return artifact_url(PIPELINE, task.id, THUMBNAIL) if task.video_url else None
async def create_task(session: AsyncSession, url: str, goods_id: str, async def create_task(session: AsyncSession, url: str, goods_id: str, slug: str, *,
slug: str) -> PlayreelTask: user_id: str = UNASSIGNED_USER_ID) -> PlayreelTask:
task = PlayreelTask(source_url=url, goods_id=goods_id, slug=slug, task = PlayreelTask(source_url=url, goods_id=goods_id, slug=slug,
name=f"공연 {goods_id}", name=f"공연 {goods_id}", user_id=user_id,
stage_timings=initial_timings(PlayreelState)) stage_timings=initial_timings(PlayreelState))
session.add(task) session.add(task)
await session.commit() await session.commit()

View File

@ -24,6 +24,7 @@ from services.render import render
from services.tts import synthesize from services.tts import synthesize
from services.upscale_poster import as_png_path from services.upscale_poster import as_png_path
from tables.task import PosterAliveTask, new_task_id from tables.task import PosterAliveTask, new_task_id
from tables.user import UNASSIGNED_USER_ID
from utils import blob from utils import blob
from utils.image import sniff_extension from utils.image import sniff_extension
@ -31,10 +32,12 @@ PIPELINE = "poster_alive"
async def create_task(session: AsyncSession, name: str, poster: bytes, *, async def create_task(session: AsyncSession, name: str, poster: bytes, *,
skip_review: bool = False) -> PosterAliveTask: skip_review: bool = False,
user_id: str = UNASSIGNED_USER_ID) -> PosterAliveTask:
# poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다. # poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다.
# 그 경로에 id가 필요하므로 여기서 미리 만든다 # 그 경로에 id가 필요하므로 여기서 미리 만든다
task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review, task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review,
user_id=user_id,
stage_timings=initial_timings(PosterAliveState)) stage_timings=initial_timings(PosterAliveState))
with Image.open(io.BytesIO(poster)) as image: with Image.open(io.BytesIO(poster)) as image:
task.poster_width, task.poster_height = image.size task.poster_width, task.poster_height = image.size

View File

@ -11,6 +11,7 @@ dependencies = [
"openai>=3.6.0", "openai>=3.6.0",
"pillow>=12.3.0", "pillow>=12.3.0",
"pydantic-settings>=2.15.0", "pydantic-settings>=2.15.0",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.32", "python-multipart>=0.0.32",
"scipy>=1.18.1", "scipy>=1.18.1",
"sqlalchemy>=2.0.52", "sqlalchemy>=2.0.52",

View File

@ -15,6 +15,7 @@ from routers.view import UNNAMED
from tables.task import PlayreelTask, PosterAliveTask from tables.task import PlayreelTask, PosterAliveTask
from utils.database import get_session from utils.database import get_session
# 완성본 구경은 로그인 없이 연다
router = APIRouter(prefix="/api/archive", tags=["archive"]) router = APIRouter(prefix="/api/archive", tags=["archive"])

110
backend/routers/auth.py Normal file
View File

@ -0,0 +1,110 @@
"""/api/auth — 구글 로그인과 세션
브라우저가 구글에서 받아온 credential을 검증하고, 뒤로는 자체 쿠키를 쓴다.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from tables.user import User
from utils import session_token
from utils.database import get_session
from utils.google_identity import (GoogleLoginDisabled, GoogleTokenInvalid, is_enabled,
verify_id_token)
router = APIRouter(prefix="/api/auth", tags=["auth"])
class GoogleLoginBody(BaseModel):
credential: str
def user_view(user: User) -> dict:
"""구글 sub는 내보내지 않는다 — 화면이 쓸 일이 없다"""
return {
"email": user.email,
"name": user.name,
"jobs_created": user.jobs_created,
"job_limit": user.job_limit,
"jobs_left": user.jobs_left,
}
async def find_or_create(session: AsyncSession, account) -> User:
user = await session.get(User, account.sub)
if user is None:
user = User(id=account.sub, email=account.email, name=account.name)
session.add(user)
else:
# 이메일·이름은 구글 쪽에서 바뀔 수 있어 로그인할 때마다 맞춘다
user.email, user.name = account.email, account.name
await session.commit()
return user
@router.get("/config")
async def config():
"""프론트가 버튼을 그릴지 정하는 데 쓴다. client_id는 비밀이 아니다"""
from settings import settings
return {"enabled": is_enabled(), "client_id": settings.google_client_id}
@router.post("/google")
async def google_login(body: GoogleLoginBody, response: Response,
session: AsyncSession = Depends(get_session)):
try:
account = await verify_id_token(body.credential)
except GoogleLoginDisabled:
raise HTTPException(503, "구글 로그인이 설정되지 않았습니다")
except GoogleTokenInvalid as failure:
raise HTTPException(401, str(failure))
user = await find_or_create(session, account)
response.set_cookie(
session_token.COOKIE_NAME, session_token.issue(user.id),
max_age=session_token.cookie_max_age(),
httponly=True, samesite="lax", path="/",
)
return user_view(user)
@router.get("/me")
async def me(request: Request, session: AsyncSession = Depends(get_session)):
user = await optional_user(request, session)
if user is None:
raise HTTPException(401, "로그인이 필요합니다")
return user_view(user)
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(session_token.COOKIE_NAME, path="/")
return {"ok": True}
async def optional_user(request: Request, session: AsyncSession) -> User | None:
token = request.cookies.get(session_token.COOKIE_NAME)
if not token:
return None
try:
user_id = session_token.read(token)
except session_token.SessionInvalid:
return None
return await session.get(User, user_id)
async def current_user(request: Request,
session: AsyncSession = Depends(get_session)) -> User:
"""로그인이 필요한 엔드포인트가 의존성으로 받는다"""
user = await optional_user(request, session)
if user is None:
raise HTTPException(401, "로그인이 필요합니다")
return user
async def creating_user(user: User = Depends(current_user)) -> User:
"""잡을 만드는 엔드포인트용 — 한도를 여기서 막는다"""
if not user.can_create_job:
raise HTTPException(
403, f"만들 수 있는 개수를 다 썼습니다 ({user.jobs_created}/{user.job_limit})")
return user

View File

@ -11,13 +11,17 @@ from models.pipeline_state import PosterAliveState
from pipelines import poster_alive from pipelines import poster_alive
from pipelines.artifact import load_image from pipelines.artifact import load_image
from pipelines.runner import AWAITING_REVIEW, FAILED, QUEUED, RUNNING, reset_from from pipelines.runner import AWAITING_REVIEW, FAILED, QUEUED, RUNNING, reset_from
from routers.auth import creating_user, current_user
from routers.view import motion_elements, poster_alive_view from routers.view import motion_elements, poster_alive_view
from tables.user import User
from services.i2v import MIN_LONG_EDGE_PX from services.i2v import MIN_LONG_EDGE_PX
from services.motion import plan_motion from services.motion import plan_motion
from tables.task import PosterAliveTask from tables.task import PosterAliveTask
from utils.database import get_session from utils.database import get_session
router = APIRouter(prefix="/api/f1", tags=["f1"]) # 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다
router = APIRouter(prefix="/api/f1", tags=["f1"],
dependencies=[Depends(current_user)])
ACCEPTED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} ACCEPTED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_UPLOAD_BYTES = 30 * 1024 * 1024 MAX_UPLOAD_BYTES = 30 * 1024 * 1024
@ -60,6 +64,7 @@ async def rewrite_motion_plan(task: PosterAliveTask, motions: list[str]) -> None
@router.post("/jobs") @router.post("/jobs")
async def create(poster: UploadFile = File(...), name: str = Form(""), async def create(poster: UploadFile = File(...), name: str = Form(""),
auto: bool = Form(False), auto: bool = Form(False),
user: User = Depends(creating_user),
session: AsyncSession = Depends(get_session)): session: AsyncSession = Depends(get_session)):
if poster.content_type not in ACCEPTED_TYPES: if poster.content_type not in ACCEPTED_TYPES:
raise HTTPException(415, f"지원 형식: JPG/PNG/WEBP/GIF (받은 것: {poster.content_type})") raise HTTPException(415, f"지원 형식: JPG/PNG/WEBP/GIF (받은 것: {poster.content_type})")
@ -81,7 +86,11 @@ async def create(poster: UploadFile = File(...), name: str = Form(""),
f"(받은 것: {width}x{height}). 고해상 원본으로 올려주세요.") f"(받은 것: {width}x{height}). 고해상 원본으로 올려주세요.")
# 이름을 비워 두면 narration_text 가 포스터에서 읽은 행사명으로 채운다 # 이름을 비워 두면 narration_text 가 포스터에서 읽은 행사명으로 채운다
task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto) task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto,
user_id=user.id)
# 잡을 지워도 회복되지 않게 누적값을 올린다
user.jobs_created += 1
await session.commit()
return {"id": task.id, "ahead": 0} return {"id": task.id, "ahead": 0}

View File

@ -11,11 +11,15 @@ from pipelines import playreel
from pipelines.gate import (REVERSIBLE_GATES, PlayreelGate, apply_playreel_edits, from pipelines.gate import (REVERSIBLE_GATES, PlayreelGate, apply_playreel_edits,
previous_gate, resume_stage) previous_gate, resume_stage)
from pipelines.runner import AWAITING_REVIEW, DONE, FAILED, QUEUED, RUNNING, reset_from from pipelines.runner import AWAITING_REVIEW, DONE, FAILED, QUEUED, RUNNING, reset_from
from routers.auth import creating_user, current_user
from routers.view import playreel_view from routers.view import playreel_view
from tables.task import PlayreelTask from tables.task import PlayreelTask
from tables.user import User
from utils.database import get_session from utils.database import get_session
router = APIRouter(prefix="/api/playreel", tags=["playreel"]) # 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다
router = APIRouter(prefix="/api/playreel", tags=["playreel"],
dependencies=[Depends(current_user)])
# 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다 # 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다
GOODS_ID_PATTERNS = ( GOODS_ID_PATTERNS = (
@ -56,7 +60,8 @@ async def find_task(session: AsyncSession, task_id: str) -> PlayreelTask:
@router.post("/jobs") @router.post("/jobs")
async def create(body: CreateBody, session: AsyncSession = Depends(get_session)): async def create(body: CreateBody, user: User = Depends(creating_user),
session: AsyncSession = Depends(get_session)):
goods_id = parse_goods_id(body.url) goods_id = parse_goods_id(body.url)
if not goods_id: if not goods_id:
raise HTTPException(400, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.") raise HTTPException(400, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.")
@ -64,7 +69,11 @@ async def create(body: CreateBody, session: AsyncSession = Depends(get_session))
if not SLUG_PATTERN.fullmatch(slug): if not SLUG_PATTERN.fullmatch(slug):
raise HTTPException(400, "slug는 영문 소문자·숫자·밑줄만 씁니다") raise HTTPException(400, "slug는 영문 소문자·숫자·밑줄만 씁니다")
task = await playreel.create_task(session, body.url.strip(), goods_id, slug) task = await playreel.create_task(session, body.url.strip(), goods_id, slug,
user_id=user.id)
# 잡을 지워도 회복되지 않게 누적값을 올린다
user.jobs_created += 1
await session.commit()
return {"id": task.id} return {"id": task.id}

View File

@ -21,6 +21,12 @@ class Settings(BaseSettings):
azure_blob_base_url: str = "" azure_blob_base_url: str = ""
azure_blob_sas_token: str = "" azure_blob_sas_token: str = ""
# 비어 있으면 구글 로그인이 꺼진다. 값은 비밀이 아니라 프론트 번들에도 들어간다
google_client_id: str = ""
# 자체 세션 토큰 서명 키. 바뀌면 로그인된 사람이 전부 풀린다
jwt_secret: str = ""
jwt_days: int = 14
@property @property
def mysql_url(self) -> str: def mysql_url(self) -> str:
password = quote_plus(self.mysql_password) password = quote_plus(self.mysql_password)

View File

@ -9,6 +9,7 @@ from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, Integer, String, Te
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from models.pipeline_state import PlayreelState, PosterAliveState from models.pipeline_state import PlayreelState, PosterAliveState
from tables.user import UNASSIGNED_USER_ID, USER_ID_LENGTH
from utils.database import Base from utils.database import Base
URL_LENGTH = 512 URL_LENGTH = 512
@ -31,6 +32,9 @@ class TaskBase(Base):
id: Mapped[str] = mapped_column(String(TASK_ID_LENGTH), primary_key=True, id: Mapped[str] = mapped_column(String(TASK_ID_LENGTH), primary_key=True,
default=new_task_id) default=new_task_id)
# 만든 사람의 구글 sub. 모르면 UNASSIGNED_USER_ID가 들어간다
user_id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), index=True,
default=UNASSIGNED_USER_ID)
name: Mapped[str] = mapped_column(String(255), default="") name: Mapped[str] = mapped_column(String(255), default="")
status: Mapped[str] = mapped_column(String(32), default="queued", index=True) status: Mapped[str] = mapped_column(String(32), default="queued", index=True)

42
backend/tables/user.py Normal file
View File

@ -0,0 +1,42 @@
"""구글 계정으로 로그인한 사람
id가 구글 sub다. 이메일이 바뀌어도 유지되는 값이라 따로 발급하지 않는다.
생성 횟수는 행을 세지 않고 누적값을 올린다 잡을 지워도 회복되지 않는다.
"""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from utils.database import Base
USER_ID_LENGTH = 64
EMAIL_LENGTH = 320
DEFAULT_JOB_LIMIT = 3
# 소유자를 모르는 잡의 자리. 나중에 admin의 sub로 덮어쓴다
UNASSIGNED_USER_ID = "0"
class User(Base):
__tablename__ = "user"
id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), primary_key=True)
email: Mapped[str] = mapped_column(String(EMAIL_LENGTH), index=True)
name: Mapped[str] = mapped_column(String(255), default="")
# 만들 수 있는 총 잡 수. 무제한으로 둘 계정은 이 값을 크게 올린다
job_limit: Mapped[int] = mapped_column(Integer, default=DEFAULT_JOB_LIMIT)
jobs_created: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
last_login_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(),
onupdate=func.now())
@property
def can_create_job(self) -> bool:
return self.jobs_created < self.job_limit
@property
def jobs_left(self) -> int:
return max(0, self.job_limit - self.jobs_created)

View File

@ -1,6 +1,5 @@
"""MySQL 비동기 세션. """MySQL 비동기 세션
FastAPI 의존성으로 받아
FastAPI 의존성으로 쓴다:
async def handler(session: AsyncSession = Depends(get_session)): ... async def handler(session: AsyncSession = Depends(get_session)): ...
""" """
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator

View File

@ -0,0 +1,116 @@
"""구글 ID 토큰 검증
브라우저가 받아온 토큰이 구글이 우리 앞으로 발급한 것인지만 본다
코드 교환을 하지 않아 client_secret이 없다 검증에 필요한 것은 공개키와 client_id뿐이다.
검증을 통과하면 뒤로는 우리 토큰을 쓴다. 구글 토큰을 세션으로 들고 다니지 않는다.
빠지면 되는 검사
서명 구글 JWKS 공개키. 없으면 아무나 만든 JSON이 통과한다
aud 우리 client_id. 없으면 다른 앞으로 발급된 진짜 구글 토큰이 통과한다
iss accounts.google.com
"""
import asyncio
import time
import httpx
from jose import JWTError, jwt
from settings import settings
JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
# 구글이 두 표기를 다 쓴다. 한쪽만 받으면 어느 날 전원 로그인 실패가 된다
ISSUERS = ("accounts.google.com", "https://accounts.google.com")
JWKS_TTL_SECONDS = 3600
HTTP_TIMEOUT = 5.0
_jwks: dict | None = None
_jwks_fetched_at = 0.0
# 토큰이 동시에 여러 개 들어와도 JWKS는 한 번만 받는다
_jwks_lock = asyncio.Lock()
class GoogleLoginDisabled(RuntimeError):
"""GOOGLE_CLIENT_ID 없음 — 구글 로그인만 꺼지고 서버는 뜬다"""
class GoogleTokenInvalid(RuntimeError):
"""서명·aud·iss·만료 중 하나가 어긋남"""
class GoogleAccount:
def __init__(self, sub: str, email: str, name: str):
self.sub = sub
self.email = email
self.name = name
def is_enabled() -> bool:
return bool(settings.google_client_id)
async def fetch_jwks() -> dict:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
response = await client.get(JWKS_URL)
response.raise_for_status()
return response.json()
async def get_jwks(*, force: bool = False) -> dict:
global _jwks, _jwks_fetched_at
async with _jwks_lock:
fresh = _jwks is not None and (time.monotonic() - _jwks_fetched_at) < JWKS_TTL_SECONDS
if fresh and not force:
return _jwks
try:
_jwks = await fetch_jwks()
_jwks_fetched_at = time.monotonic()
except Exception as failure:
print(f"[google] JWKS 조회 실패: {failure}", flush=True)
# 낡은 캐시라도 있으면 그걸로 간다. 구글이 잠깐 안 될 때 로그인이 통째로 죽는 것보다 낫다
if _jwks is None:
raise GoogleTokenInvalid("구글 공개키를 받지 못했습니다") from failure
return _jwks
def has_key(jwks: dict, kid: str | None) -> bool:
return any(key.get("kid") == kid for key in jwks.get("keys") or [])
async def verify_id_token(credential: str) -> GoogleAccount:
if not is_enabled():
raise GoogleLoginDisabled("GOOGLE_CLIENT_ID 없음")
if not credential:
raise GoogleTokenInvalid("빈 토큰")
try:
kid = jwt.get_unverified_header(credential).get("kid")
except JWTError as failure:
raise GoogleTokenInvalid("토큰 형식이 아님") from failure
jwks = await get_jwks()
# 공개키는 주기적으로 바뀐다. 캐시에 없는 kid면 한 번만 다시 받는다
if not has_key(jwks, kid):
jwks = await get_jwks(force=True)
try:
claims = jwt.decode(
credential, jwks, algorithms=["RS256"],
audience=settings.google_client_id, issuer=ISSUERS,
# at_hash는 access_token과 짝일 때만 의미가 있다. 브라우저가 주는
# 크리덴셜에는 access_token이 없어 켜 두면 정상 토큰이 거부된다
options={"verify_at_hash": False},
)
except JWTError as failure:
# 사유는 로그에만 남긴다
print(f"[google] ID 토큰 거부: {failure}", flush=True)
raise GoogleTokenInvalid("구글 토큰이 유효하지 않습니다") from failure
sub = str(claims.get("sub") or "")
if not sub:
raise GoogleTokenInvalid("sub 없음")
# 미인증 이메일은 신원으로 쓸 수 없다
if not claims.get("email_verified"):
raise GoogleTokenInvalid("이메일이 인증되지 않은 계정입니다")
return GoogleAccount(sub=sub, email=str(claims.get("email") or ""),
name=str(claims.get("name") or ""))

View File

@ -0,0 +1,41 @@
"""자체 세션 토큰
구글 검증을 통과한 뒤로는 이걸 HttpOnly 쿠키로 들고 다닌다
담는 것은 user.id 하나다. 이메일·이름은 바뀔 있어 토큰에 넣지 않고 매번 DB에서 읽는다.
"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from settings import settings
ALGORITHM = "HS256"
COOKIE_NAME = "poster_alive_session"
class SessionInvalid(RuntimeError):
"""서명이 안 맞거나 만료됨"""
def issue(user_id: str) -> str:
if not settings.jwt_secret:
raise RuntimeError("JWT_SECRET 없음 — 세션을 발급할 수 없습니다")
expires = datetime.now(timezone.utc) + timedelta(days=settings.jwt_days)
return jwt.encode({"sub": user_id, "exp": expires}, settings.jwt_secret,
algorithm=ALGORITHM)
def read(token: str) -> str:
"""토큰에서 user.id를 꺼낸다"""
try:
claims = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
except JWTError as failure:
raise SessionInvalid("세션이 유효하지 않습니다") from failure
user_id = str(claims.get("sub") or "")
if not user_id:
raise SessionInvalid("sub 없음")
return user_id
def cookie_max_age() -> int:
return settings.jwt_days * 24 * 3600

181
backend/uv.lock generated
View File

@ -95,6 +95,7 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "scipy" }, { name = "scipy" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@ -111,6 +112,7 @@ requires-dist = [
{ name = "openai", specifier = ">=3.6.0" }, { name = "openai", specifier = ">=3.6.0" },
{ name = "pillow", specifier = ">=12.3.0" }, { name = "pillow", specifier = ">=12.3.0" },
{ name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.32" }, { name = "python-multipart", specifier = ">=0.0.32" },
{ name = "scipy", specifier = ">=1.18.1" }, { name = "scipy", specifier = ">=1.18.1" },
{ name = "sqlalchemy", specifier = ">=2.0.52" }, { name = "sqlalchemy", specifier = ">=2.0.52" },
@ -126,6 +128,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
] ]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.5.0" version = "8.5.0"
@ -135,6 +196,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
] ]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.141.1" version = "0.141.1"
@ -450,6 +573,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
] ]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@ -529,6 +670,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
] ]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.32" version = "0.0.32"
@ -564,6 +724,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]] [[package]]
name = "scipy" name = "scipy"
version = "1.18.1" version = "1.18.1"
@ -615,6 +787,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo"; import Ado2Logo from "@/components/ado2-logo";
import AuthGate from "@/components/auth-gate";
import NavLink from "@/components/nav-link"; import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features"; import { FEATURES } from "@/lib/features";
@ -27,7 +28,9 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
<div className="sidebar-foot"> · </div> <div className="sidebar-foot"> · </div>
</aside> </aside>
<main className="main-content"> <main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div> <div style={{ maxWidth: 1080, margin: "0 auto" }}>
<AuthGate>{children}</AuthGate>
</div>
</main> </main>
</> </>
); );

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo"; import Ado2Logo from "@/components/ado2-logo";
import AuthGate from "@/components/auth-gate";
import NavLink from "@/components/nav-link"; import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features"; import { FEATURES } from "@/lib/features";
@ -38,7 +39,9 @@ export default function PlayreelLayout({ children }: { children: React.ReactNode
<div className="sidebar-foot">Playreel · an ADO2 product · </div> <div className="sidebar-foot">Playreel · an ADO2 product · </div>
</aside> </aside>
<main className="main-content"> <main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div> <div style={{ maxWidth: 1080, margin: "0 auto" }}>
<AuthGate>{children}</AuthGate>
</div>
</main> </main>
</> </>
); );

View File

@ -0,0 +1,42 @@
"use client";
/** 로그인하지 않았으면 화면 대신 로그인만 보여준다 */
import { useState } from "react";
import { usePathname } from "next/navigation";
import GoogleSignIn from "@/components/google-sign-in";
import { signIn, useMe } from "@/lib/auth";
// 완성본 구경은 로그인 없이 연다. 백엔드의 /api/archive 와 같은 규칙
const OPEN_PATHS = ["/archive", "/playreel/archive"];
export default function AuthGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { me, setMe } = useMe();
const [error, setError] = useState<string | null>(null);
if (OPEN_PATHS.some((open) => pathname === open || pathname.startsWith(`${open}/`))) {
return <>{children}</>;
}
if (me === null) {
return <p style={{ color: "var(--color-text-gray-400)" }}> </p>;
}
if (me === false) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center",
gap: "1.5rem", paddingTop: "6rem" }}>
<h1 className="page-title"> </h1>
<p className="page-subtitle"> .</p>
<GoogleSignIn onCredential={(credential) => {
setError(null);
signIn(credential).then(setMe).catch((failure) => setError((failure as Error).message));
}} />
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)" }}>{error}</p>}
</div>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,54 @@
"use client";
/** 구글이 그려주는 버튼. 우리가 모양을 흉내 내면 브랜드 규정에 걸린다 */
import { useEffect, useRef, useState } from "react";
import {
fetchConfig, loadGoogleIdentity, type GoogleButtonOptions,
} from "@/lib/auth";
export default function GoogleSignIn({ onCredential, text = "signin_with" }: {
onCredential: (credential: string) => void;
text?: GoogleButtonOptions["text"];
}) {
const host = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
// 콜백이 매 렌더마다 바뀌어도 구글에 다시 등록하지 않게 최신 것만 들고 있는다
const latest = useRef(onCredential);
latest.current = onCredential;
useEffect(() => {
let cancelled = false;
(async () => {
const config = await fetchConfig();
if (!config.enabled) throw new Error("구글 로그인이 설정되지 않았습니다");
const google = await loadGoogleIdentity();
if (cancelled || !host.current) return;
google.initialize({
client_id: config.client_id,
callback: (response) => {
if (response.credential) latest.current(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
});
google.renderButton(host.current, {
type: "standard", theme: "filled_black", size: "large",
shape: "pill", text, logo_alignment: "center", width: 320,
});
})().catch((failure) => {
if (!cancelled) setError((failure as Error).message);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
if (error) {
return <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", margin: 0 }}>{error}</p>;
}
return <div ref={host} style={{ display: "flex", justifyContent: "center" }} />;
}

122
frontend/lib/auth.ts Normal file
View File

@ -0,0 +1,122 @@
"use client";
/**
* .
* credential , HttpOnly
* .
*
* GIS . .
*/
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
const SCRIPT_URL = "https://accounts.google.com/gsi/client?hl=ko";
const SCRIPT_ID = "google-identity-services";
export interface Me {
email: string;
name: string;
jobs_created: number;
job_limit: number;
jobs_left: number;
}
export interface AuthConfig {
enabled: boolean;
client_id: string;
}
type CredentialResponse = { credential?: string };
export type GoogleButtonOptions = {
type?: "standard" | "icon";
theme?: "outline" | "filled_blue" | "filled_black";
size?: "small" | "medium" | "large";
shape?: "rectangular" | "pill" | "circle" | "square";
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
width?: number;
logo_alignment?: "left" | "center";
};
type GoogleIdApi = {
initialize(config: {
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
}): void;
renderButton(parent: HTMLElement, options: GoogleButtonOptions): void;
disableAutoSelect(): void;
};
declare global {
interface Window {
google?: { accounts?: { id?: GoogleIdApi } };
}
}
// 스크립트는 한 번만 받는다
let loading: Promise<GoogleIdApi> | null = null;
export function loadGoogleIdentity(): Promise<GoogleIdApi> {
const ready = window.google?.accounts?.id;
if (ready) return Promise.resolve(ready);
if (loading) return loading;
loading = new Promise<GoogleIdApi>((resolve, reject) => {
const done = () => {
const api = window.google?.accounts?.id;
if (api) resolve(api);
else reject(new Error("구글 스크립트에 accounts.id 가 없습니다"));
};
const fail = () => {
// 다음 시도에서 다시 받을 수 있게 비운다 — 광고 차단기나 사내망에서 한 번 막히는 일이 흔하다
loading = null;
reject(new Error("구글 로그인 스크립트를 받지 못했습니다"));
};
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener("load", done, { once: true });
existing.addEventListener("error", fail, { once: true });
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = SCRIPT_URL;
script.async = true;
script.defer = true;
script.addEventListener("load", done, { once: true });
script.addEventListener("error", fail, { once: true });
document.head.appendChild(script);
});
return loading;
}
export const fetchConfig = () => apiFetch<AuthConfig>("/api/auth/config");
export const signIn = (credential: string) =>
apiFetch<Me>("/api/auth/google", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential }),
});
export const signOut = () => apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
/** 로그인 여부. null 이면 아직 확인 중, false 면 로그아웃 상태 */
export function useMe() {
const [me, setMe] = useState<Me | null | false>(null);
const refresh = () =>
apiFetch<Me>("/api/auth/me").then(setMe).catch(() => setMe(false));
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 확인
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { me, refresh, setMe };
}

View File

@ -3,7 +3,7 @@
/** /**
* . * .
* `playreel.autoApprove` = { fetch_confirm?: true, narration_confirm?: true } * `playreel.autoApprove` = { fetch_confirm?: true, narration_confirm?: true }
* (·). ·· GATE_META.credits/ . * (·). ·· .
* ( ). `approve` /playreel/[id] . * ( ). `approve` /playreel/[id] .
*/ */