Files
dbx-orderlist/app/main.py
T
king 931ad173ab feat(ui): /orderlist 미인증 시 dbx-main 로그인으로 위임, 프로필 헤더 개선
- read_root() 가 세션 미인증 시 dbx-main /login?next=/orderlist/ 로 302.
  OMS 는 더 이상 자체 로그인 UI 를 가지지 않는다.
- index.html: #login-screen 블록 제거, dashboard 만 렌더.
- 우측 상단 프로필 영역을 메인 페이지 스타일로 개편:
  이름 + 이메일 + 아바타(Google picture URL, 폴백 아이콘) + 로그아웃 텍스트 버튼.
- main.py 가 세션의 user_name/user_email/user_picture 를 HTML placeholder 로 치환.
- app.js checkAuth() 단순화: 화면 토글 제거, 401 시 dbx-main 로그인으로 자동 이동.
- style.css 에 새 프로필 영역 CSS 추가.

Google 아바타가 보이려면 dbx-main 도 같이 user_picture 를 세션 top-level 에
저장하도록 수정됨 (dbx-main 별도 커밋).
2026-05-24 14:20:42 +09:00

136 lines
5.8 KiB
Python

from fastapi import FastAPI, Depends, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from html import escape
from urllib.parse import quote
import logging
import os
from app.database import engine, Base, check_db_connection, log_db_startup_status
from app.database_maintenance import ensure_search_optimizations
from app.routers import orders, settings
from app.auth import router as auth_router
logging.basicConfig(level=logging.INFO)
APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/")
# Verify DB connectivity at startup (log only — do not crash so the
# container can stay up and report status via /health/db).
log_db_startup_status()
# Create all DB tables
Base.metadata.create_all(bind=engine)
ensure_search_optimizations(engine)
# root_path 는 FastAPI 생성자에 넘기지 않는다.
# Starlette 의 Mount 가 root_path 를 흡수해 mount 경로에 prefix 가 붙어버려서
# @app.get 라우트와 mount 가 비대칭으로 등록되는 문제 때문이다.
# (예: /health/db 는 그대로지만 /static/* 는 /orderlist/static/* 로 등록됨)
#
# 대신 uvicorn --root-path 로 ASGI scope.root_path 만 세팅한다.
# 그러면 모든 라우트/마운트가 prefix 없이 등록되고, URL 생성 시에만 prefix 가 붙는다.
# Dockerfile 의 CMD 가 ${APP_ROOT_PATH} 를 --root-path 로 넘긴다.
app = FastAPI(title="Order Management System")
@app.get("/health/db")
async def health_db():
ok, msg = check_db_connection()
status_code = 200 if ok else 503
return JSONResponse({"ok": ok, "detail": msg}, status_code=status_code)
# ---- Session middleware ----
# dbx-main 과 SSO 하려면 SESSION_SECRET_KEY 와 SESSION_COOKIE_NAME 을
# 두 앱이 동일하게 맞춰야 한다. 그래야 dbx-main 이 발급한 세션 쿠키를
# OMS 가 그대로 읽을 수 있다.
_session_secret = os.getenv("SESSION_SECRET_KEY", "").strip()
if not _session_secret:
raise RuntimeError(
"SESSION_SECRET_KEY 환경변수가 설정되지 않았습니다. "
"openssl rand -hex 32 로 새 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요. "
"dbx-main 과 SSO 하려면 양쪽 .env 에 같은 값이어야 합니다."
)
app.add_middleware(
SessionMiddleware,
secret_key=_session_secret,
session_cookie=os.getenv("SESSION_COOKIE_NAME", "session"),
same_site=os.getenv("SESSION_SAME_SITE", "lax"),
https_only=os.getenv("SESSION_HTTPS_ONLY", "0") == "1",
max_age=int(os.getenv("SESSION_MAX_AGE", "28800")), # 8h — dbx-main 과 통일
)
# Include routers
app.include_router(auth_router)
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
# Mount static files for frontend
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
MAIN_LOGIN_URL = os.getenv("MAIN_LOGIN_URL", "https://dbx.no1king.freeddns.org/login")
@app.get("/", response_class=HTMLResponse)
async def read_root(request: Request):
# 미인증 사용자는 dbx-main 의 로그인 페이지로 위임 (SSO 원칙).
# OMS 는 자체 로그인 UI 를 보여주지 않는다.
user_email = request.session.get("user_email")
if not user_email:
next_path = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/"
sep = "&" if "?" in MAIN_LOGIN_URL else "?"
target = f"{MAIN_LOGIN_URL}{sep}next={quote(next_path, safe='/')}"
return RedirectResponse(url=target, status_code=302)
user_name = request.session.get("user_name") or user_email
user_picture = request.session.get("user_picture", "") or ""
# 아바타: Google picture URL 이 있으면 <img>, 없으면 폴백 아이콘
if user_picture:
avatar_html = (
f'<img src="{escape(user_picture, quote=True)}" '
f'alt="" class="profile-avatar-img" referrerpolicy="no-referrer" '
f'onerror="this.style.display=\'none\';'
f'this.nextElementSibling.style.display=\'flex\'">'
f'<i class="fa-solid fa-user profile-avatar-icon" style="display:none"></i>'
)
else:
avatar_html = '<i class="fa-solid fa-user profile-avatar-icon"></i>'
if os.path.exists("static/index.html"):
with open("static/index.html", "r", encoding="utf-8") as f:
content = f.read()
content = content.replace("__APP_BASE_PATH__", APP_ROOT_PATH)
content = content.replace(
'src="/static/',
f'src="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'src="/static/',
)
content = content.replace(
'href="/static/',
f'href="{APP_ROOT_PATH}/static/' if APP_ROOT_PATH else 'href="/static/',
)
content = content.replace(
"window.location.href='/login",
f"window.location.href='{APP_ROOT_PATH}/login",
)
content = content.replace(
'window.location.href="/login',
f'window.location.href="{APP_ROOT_PATH}/login',
)
content = content.replace(
"window.location.href='/logout",
f"window.location.href='{APP_ROOT_PATH}/logout",
)
content = content.replace(
'window.location.href="/logout',
f'window.location.href="{APP_ROOT_PATH}/logout',
)
# 사용자 정보 주입 (HTML 안의 placeholder 치환)
content = content.replace("__USER_NAME__", escape(user_name))
content = content.replace("__USER_EMAIL__", escape(user_email))
content = content.replace("__USER_AVATAR__", avatar_html)
return HTMLResponse(content=content)
return HTMLResponse(content="<h1>Welcome. static/index.html is missing.</h1>")