Adds an 8th development stage that lets users keep a free-form journal alongside habit tracking, reusing the existing Google OAuth/DB/PWA infrastructure instead of a separate project. Users organize entries into custom categories, filter by a month calendar with day-detail drill-down, attach photos/videos (served via an authenticated route, never /static), tag entries, and pick multiple emotions per entry from a curated 9-option set. Includes a global journal prompt bank for lightweight guided journaling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
98 lines
3.7 KiB
Python
98 lines
3.7 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.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")
|