119 lines
4.6 KiB
Python
119 lines
4.6 KiB
Python
import logging
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from app.config import settings
|
|
from app.database import SessionLocal
|
|
from app.routers import auth, habits, journal, journal_pages, logs, pages, push
|
|
from app.routers.pages import templates
|
|
from app.security import get_current_user_optional
|
|
from app.services import scheduler_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
Path(settings.journal_media_root).mkdir(parents=True, exist_ok=True)
|
|
scheduler_service.start_scheduler()
|
|
yield
|
|
scheduler_service.shutdown_scheduler()
|
|
|
|
|
|
app = FastAPI(title="해빗랩", lifespan=lifespan)
|
|
|
|
# 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다.
|
|
# 로그인 유지용 쿠키(habit_session)와는 별개.
|
|
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def redirect_http_to_https(request: Request, call_next):
|
|
# 리버스 프록시가 X-Forwarded-Proto로 원래 스킴을 알려줄 때만 동작한다
|
|
# (로컬 uvicorn 직접 실행 시에는 이 헤더가 없어 그냥 통과).
|
|
if request.headers.get("x-forwarded-proto") == "http":
|
|
return RedirectResponse(str(request.url.replace(scheme="https")), status_code=301)
|
|
return await call_next(request)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def support_head_requests(request: Request, call_next):
|
|
# 이 Starlette 버전은 GET 라우트에 HEAD를 자동으로 열어주지 않아 크롤러의 HEAD 요청이
|
|
# 전부 405로 막힌다(구글 OAuth 브랜딩 인증 크롤러가 홈페이지를 HEAD로 먼저 확인하면서
|
|
# "콘텐츠 없음"으로 오판하는 원인이 됐음). GET과 동일하게 처리한 뒤 본문만 비워서 응답한다.
|
|
if request.method != "HEAD":
|
|
return await call_next(request)
|
|
|
|
request.scope["method"] = "GET"
|
|
response = await call_next(request)
|
|
|
|
async def _empty_body():
|
|
return
|
|
yield b"" # pragma: no cover - 제너레이터로 만들기 위한 도달 불가 코드
|
|
|
|
response.body_iterator = _empty_body()
|
|
# uvicorn은 실제 전송 바이트와 Content-Length가 다르면 예외를 던지므로 0으로 맞춘다.
|
|
response.headers["content-length"] = "0"
|
|
return response
|
|
|
|
|
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
|
|
app.include_router(auth.router)
|
|
app.include_router(habits.router)
|
|
app.include_router(journal.router)
|
|
app.include_router(logs.router)
|
|
app.include_router(push.router)
|
|
app.include_router(journal_pages.router)
|
|
app.include_router(pages.router)
|
|
|
|
|
|
def _is_api_path(path: str) -> bool:
|
|
return path.startswith("/api/") or path.startswith("/static/")
|
|
|
|
|
|
def _current_user_context(request: Request) -> dict:
|
|
db = SessionLocal()
|
|
try:
|
|
current_user = get_current_user_optional(request, db)
|
|
finally:
|
|
db.close()
|
|
return {"logged_in": current_user is not None, "current_user": current_user}
|
|
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|
if exc.status_code == 404 and not _is_api_path(request.url.path):
|
|
return templates.TemplateResponse(
|
|
request, "404.html", _current_user_context(request), status_code=404
|
|
)
|
|
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
|
logger.exception("처리되지 않은 오류")
|
|
if _is_api_path(request.url.path):
|
|
return JSONResponse({"detail": "서버 오류가 발생했습니다"}, status_code=500)
|
|
return templates.TemplateResponse(
|
|
request, "500.html", _current_user_context(request), status_code=500
|
|
)
|
|
|
|
|
|
@app.get("/service-worker.js")
|
|
def service_worker():
|
|
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
|
|
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
|
|
|
|
|
|
@app.get("/.well-known/assetlinks.json")
|
|
def assetlinks():
|
|
# Android TWA(kr.co.alphalok.habit.twa)의 도메인 소유권 증명용 — 반드시 /.well-known/ 루트 경로여야 한다.
|
|
return FileResponse("app/static/.well-known/assetlinks.json", media_type="application/json")
|