Files
shinalok be8a4e6908
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m44s
add terms page, improve landing content for branding review, and handle HTTP HEAD requests
2026-08-08 20:40:47 +09:00

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")