diff --git a/.env.example b/.env.example index 5773591..fe53503 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,31 @@ CAFE24_REDIRECT_URI= # 앱 설정 # ============================================ APP_PORT=8002 + +# NPM 리버스 프록시 뒤 서브경로 호스팅 +APP_ROOT_PATH=/corm + +# ============================================ +# SSO (dbx-main 세션 공유) +# ============================================ +# dbx-main, OMS 와 반드시 같은 값으로 맞춰야 SSO 동작. +# 미설정 시 컨테이너 기동 실패 (fail-fast). openssl rand -hex 32 로 생성. +SESSION_SECRET_KEY=__paste-same-value-as-dbx-main__ +SESSION_COOKIE_NAME=session +SESSION_SAME_SITE=lax +SESSION_HTTPS_ONLY=1 +SESSION_MAX_AGE=28800 + +# 세션에서 읽을 사용자 키. dbx-main 이 어느 키로 쓰는지에 맞춰라. +SESSION_USER_KEY=user_email +SESSION_NAME_KEY=user_name + +# 미인증 시 보낼 dbx-main 로그인/로그아웃 URL +MAIN_LOGIN_URL=https://dbx.no1king.freeddns.org/login +MAIN_LOGOUT_URL=https://dbx.no1king.freeddns.org/logout + +# ============================================ +# Docker network +# ============================================ +POSTGRES_NETWORK=postgres_default +WEB_NETWORK=npm_default diff --git a/Dockerfile b/Dockerfile index 22c0c4b..2e7302c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,7 @@ COPY . . EXPOSE 8002 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002", "--proxy-headers", "--forwarded-allow-ips=*"] +# APP_ROOT_PATH 를 uvicorn --root-path 로 넘긴다 (shell form 필요 — 환경변수 확장). +# NPM 이 prefix 를 잘라 백엔드의 / 로 전달하므로 라우트는 prefix 없이 등록되고 +# URL 생성 시에만 ASGI scope.root_path 가 prepend 된다. +CMD uvicorn main:app --host 0.0.0.0 --port 8002 --proxy-headers --forwarded-allow-ips=* --root-path "${APP_ROOT_PATH:-}" diff --git a/docker-compose.yml b/docker-compose.yml index 3a752b4..07cd9ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,19 +4,35 @@ services: image: dbx-corm:latest container_name: dbx-corm restart: unless-stopped - ports: - - "8002:8002" env_file: - .env + environment: + # NPM 리버스 프록시 뒤 서브경로 호스팅. + APP_ROOT_PATH: ${APP_ROOT_PATH:-/corm} + # 호스트 포트 바인딩 없음 — NPM(Nginx Proxy Manager) 를 통해서만 접근. + expose: + - "8002" volumes: # 민감 파일은 Git에 안 올리고 서버 호스트에서 직접 마운트 - ./cafe24_tokens.json:/app/cafe24_tokens.json - ./manual-ordering.json:/app/manual-ordering.json - # 토큰 파일 자동 갱신 결과를 호스트에 영구 보존 networks: - - postgres-net + - postgres_net + - web_net + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8002/health/db"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s networks: - postgres-net: + # 이미 운영 중인 postgres-db 컨테이너가 속해 있는 외부 네트워크. + postgres_net: external: true - name: postgres_default # 서버의 실제 PostgreSQL 네트워크 이름으로 수정 + name: ${POSTGRES_NETWORK:-postgres_default} + # NPM(Nginx Proxy Manager) 가 속해 있는 외부 네트워크. + # NPM 이 dbx-corm 컨테이너에 컨테이너 이름으로 도달 가능해야 한다. + web_net: + external: true + name: ${WEB_NETWORK:-npm_default} diff --git a/main.py b/main.py index 41ddb18..0f52ef2 100644 --- a/main.py +++ b/main.py @@ -12,9 +12,12 @@ import configparser import urllib.request import urllib.parse import uuid +from urllib.parse import quote from dotenv import load_dotenv from fastapi import FastAPI, HTTPException, Request, Body, UploadFile, File, Form, Query, BackgroundTasks -from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse, RedirectResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.sessions import SessionMiddleware import pandas as pd from naver_api import get_access_token, get_payment_done_orders, get_product_order_details, dispatch_orders, confirm_orders, get_product_details import cafe24_api @@ -26,6 +29,69 @@ from typing import List, Optional, Dict, Any # .env 로드 load_dotenv() +# ========================================== +# SSO / 서브경로 호스팅 설정 +# ========================================== +APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/") +MAIN_LOGIN_URL = os.getenv("MAIN_LOGIN_URL", "https://dbx.no1king.freeddns.org/login") +MAIN_LOGOUT_URL = os.getenv("MAIN_LOGOUT_URL", "https://dbx.no1king.freeddns.org/logout") +SESSION_USER_KEY = os.getenv("SESSION_USER_KEY", "user_email") +SESSION_NAME_KEY = os.getenv("SESSION_NAME_KEY", "user_name") + +# 인증 우회 경로 — 정적 자원, 헬스, OAuth 콜백, 로그인/로그아웃 위임 +# Starlette scope.path 는 root_path 가 제거된 형태로 들어오므로 prefix 없이 매칭. +_AUTH_EXEMPT_PREFIXES = ( + "/static/", + "/health", + "/api/cafe24/callback", + "/admin/oauth/callback", + "/login", + "/logout", + "/favicon.ico", +) + + +def _require_session_secret() -> str: + secret = os.getenv("SESSION_SECRET_KEY", "").strip() + if not secret: + raise RuntimeError( + "SESSION_SECRET_KEY 환경변수가 설정되지 않았습니다. " + "openssl rand -hex 32 로 새 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요. " + "dbx-main 과 SSO 하려면 양쪽 .env 에 같은 값이어야 합니다." + ) + return secret + + +class AuthGuardMiddleware(BaseHTTPMiddleware): + """세션에 user_email 이 없으면 차단. + + - HTML 요청 (Accept: text/html 또는 / 같은 페이지) → dbx-main /login?next=... 302 + - 그 외 (API 등) → 401 JSON + - _AUTH_EXEMPT_PREFIXES 에 해당하는 경로는 통과 + """ + + async def dispatch(self, request: Request, call_next): + path = request.url.path # ASGI 에서 root_path 가 빠진 경로 + for prefix in _AUTH_EXEMPT_PREFIXES: + if path == prefix or path.startswith(prefix): + return await call_next(request) + + email = request.session.get(SESSION_USER_KEY) + if email: + return await call_next(request) + + # 미인증 + accept = request.headers.get("accept", "") + wants_html = "text/html" in accept or not path.startswith("/api/") + if wants_html: + next_path = f"{APP_ROOT_PATH}{path}" if APP_ROOT_PATH else path + if request.url.query: + next_path = f"{next_path}?{request.url.query}" + 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) + return JSONResponse({"detail": "Not authenticated"}, status_code=401) + # ========================================== # 데이터베이스 접속 설정 (환경변수 기반) # ========================================== @@ -72,6 +138,49 @@ except ImportError: app = FastAPI(title="CS Web Program") +# 세션 미들웨어 — dbx-main 과 동일한 SESSION_SECRET_KEY / SESSION_COOKIE_NAME 사용 시 +# 같은 도메인의 세션 쿠키를 그대로 읽을 수 있다 (SSO). +app.add_middleware( + SessionMiddleware, + secret_key=_require_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")), + path="/", +) +# 인증 가드 — 정적/헬스/콜백/로그인·로그아웃 외 모든 경로에 적용 +app.add_middleware(AuthGuardMiddleware) + + +@app.get("/health/db") +async def health_db(): + """DB ping — 인증 불필요. dbx-main 의 헬스 배지가 이 경로를 폴링한다.""" + try: + conn = psycopg2.connect(**DB_CONFIG, connect_timeout=3) + conn.close() + return JSONResponse({"ok": True, "detail": "ok"}) + except Exception as e: + return JSONResponse({"ok": False, "detail": str(e)}, status_code=503) + + +@app.get("/login") +async def corm_login(request: Request, next: Optional[str] = None): + """OMS 와 동일 패턴 — 자체 OAuth 없음. dbx-main 으로 위임.""" + root = APP_ROOT_PATH + fallback = f"{root}/" if root else "/" + safe = next if (next and next.startswith("/") and not next.startswith("//") and not next.startswith("/\\")) else fallback + sep = "&" if "?" in MAIN_LOGIN_URL else "?" + target = f"{MAIN_LOGIN_URL}{sep}next={quote(safe, safe='/')}" + return RedirectResponse(url=target, status_code=302) + + +@app.get("/logout") +async def corm_logout(): + """전적으로 dbx-main 에 위임. CORM 은 세션을 직접 만지지 않는다 (쿠키 path 분리 방지).""" + return RedirectResponse(url=MAIN_LOGOUT_URL, status_code=302) + + from routers.returns import router as returns_router app.include_router(returns_router) @@ -168,11 +277,16 @@ async def index(request: Request): except Exception as e: print("버전 히스토리 로드 실패:", e) version_history = [] - + return templates.TemplateResponse( - request=request, - name="index.html", - context={"version_history": version_history} + request=request, + name="index.html", + context={ + "version_history": version_history, + "root_path": APP_ROOT_PATH, + "user_email": request.session.get(SESSION_USER_KEY, ""), + "user_name": request.session.get(SESSION_NAME_KEY, ""), + } ) @app.get("/api/config") @@ -1368,11 +1482,12 @@ async def cafe24_login(): @app.get("/api/cafe24/callback") @app.get("/admin/oauth/callback") async def cafe24_callback(code: str): + home = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/" try: cafe24_api.request_new_token(code) - return HTMLResponse("") + return HTMLResponse(f"") except Exception as e: - return HTMLResponse(f"") + return HTMLResponse(f"") task_store = {} diff --git a/static/js/app.js b/static/js/app.js index 1ab917d..bee253f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,4 +1,25 @@ +// ============================================================ +// 서브경로 호스팅 — fetch wrapper +// 모든 /api/* 호출에 APP_BASE_PATH 를 prepend, 401 응답 시 dbx-main 로그인으로 이동. +// ============================================================ +(function () { + const APP_BASE_PATH = (window.APP_BASE_PATH || '').replace(/\/$/, ''); + const origFetch = window.fetch.bind(window); + window.fetch = async function (input, init) { + if (typeof input === 'string' && APP_BASE_PATH && input.startsWith('/api/')) { + input = APP_BASE_PATH + input; + } + const response = await origFetch(input, init); + if (response.status === 401) { + // 세션 만료 — dbx-main 로그인으로 위임 (next 로 현재 위치 보존) + const next = encodeURIComponent((APP_BASE_PATH || '') + window.location.pathname + window.location.search); + window.location.href = (APP_BASE_PATH || '') + '/login?next=' + next; + } + return response; + }; +})(); + function showToast(message) { const toast = document.createElement('div'); toast.textContent = message; diff --git a/templates/index.html b/templates/index.html index 127c654..45077eb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,8 +6,12 @@