commit c93e2be0a7c6bd0bce92bc2652a588ffa44a8ef3 Author: king Date: Thu May 21 22:43:41 2026 +0900 feat: 초기 커밋 — Pretendard 폰트 적용 및 Docker 배포 구성 - Pretendard Regular/Bold/Thin 폰트를 app/static/fonts/ 에 추가하고 @font-face 로 등록하여 전체 페이지에 적용 - Dockerfile(python:3.12-slim + uvicorn --proxy-headers) 작성 - docker-compose.yml: NPM 뒤 운영 서버용 (포트 80) - docker-compose.local.yml: 로컬 테스트용 (포트 8080) - .env.local.example: 로컬 OAuth 설정 가이드 포함 - .dockerignore: 이미지 빌드 컨텍스트 최소화 - .gitignore: .env / .env.local 제외 명시 - README: Docker 빌드·배포·업데이트 절차 문서화 Co-Authored-By: Claude Sonnet 4.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e6f434 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.env +.env.local +.venv/ +__pycache__/ +*.py[cod] +.git/ +.gitignore +.dockerignore +nginx/ +docker-compose*.yml +README.md +.claude/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..36a3e9a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret +SESSION_SECRET_KEY=replace-with-a-long-random-secret +SESSION_COOKIE_SECURE=true +PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org +CS_ORDER_URL=https://cs.example.com +CUSTOMER_ORDER_LIST_URL=https://orders.example.com diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..5b68801 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,18 @@ +# 로컬 Docker 테스트용 환경 변수 +# 이 파일을 복사하여 .env.local 로 저장한 뒤 값을 채워주세요. +# +# Google Cloud Console에서 아래 URI를 승인된 리디렉션 URI에 추가해야 합니다: +# http://localhost:8080/auth/google + +GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret + +# 로컬은 HTTP이므로 false +SESSION_COOKIE_SECURE=false +SESSION_SECRET_KEY=replace-with-a-long-random-secret-for-local + +# 로컬 접속 주소 +PUBLIC_BASE_URL=http://localhost:8080 + +CS_ORDER_URL=https://cs.example.com +CUSTOMER_ORDER_LIST_URL=https://orders.example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7319622 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# 환경 변수 (실제 키 포함 — 절대 커밋 금지) +.env +.env.local + +# Python +__pycache__/ +*.py[cod] +.venv/ + +# IDE +.vscode/ +.idea/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7c1221f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +# 작업 디렉토리 +WORKDIR /app + +# 의존성 먼저 설치 (캐시 활용) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 소스 복사 +COPY app/ ./app/ + +EXPOSE 8000 + +# --proxy-headers: NPM(Nginx Proxy Manager)의 X-Forwarded-* 헤더 신뢰 +CMD ["uvicorn", "app.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--proxy-headers", \ + "--forwarded-allow-ips=*"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f2f662 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# DBX 메인 페이지 + +Google Workspace 계정으로 로그인한 뒤 허용된 사용자만 업무 메뉴에 접근하는 FastAPI 웹앱입니다. +Nginx Proxy Manager(NPM) 뒤에서 Docker 컨테이너로 실행하는 구성을 기준으로 작성했습니다. + +## 접속 허용 계정 + +- king@dbxcorp.co.kr +- julie@dbxcorp.co.kr +- ellen@dbxcorp.co.kr +- bj@dbxcorp.co.kr + +허용 목록은 `app/main.py`의 `ALLOWED_EMAILS`에서 서버 측으로 검사합니다. + +--- + +## Google OAuth 설정 + +Google Cloud Console에서 OAuth 클라이언트를 만들고 아래 값을 등록합니다. + +| 항목 | 값 | +|---|---| +| 승인된 JavaScript 원본 | `https://dbx.no1king.freeddns.org` | +| 승인된 리디렉션 URI (운영) | `https://dbx.no1king.freeddns.org/auth/google` | +| 승인된 리디렉션 URI (로컬) | `http://localhost:8080/auth/google` | + +Google 로그인 화면에서 `hd=dbxcorp.co.kr` 힌트를 보내지만, +실제 접근 제한은 서버에서 이메일 도메인과 허용 계정 목록으로 다시 검사합니다. + +--- + +## 환경 변수 + +| 변수 | 설명 | +|---|---| +| `GOOGLE_CLIENT_ID` | Google OAuth 클라이언트 ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth 클라이언트 보안 비밀 | +| `SESSION_SECRET_KEY` | 세션 쿠키 서명용 랜덤 문자열 | +| `SESSION_COOKIE_SECURE` | HTTPS 환경 `true` / 로컬 HTTP `false` | +| `PUBLIC_BASE_URL` | 외부 접속 주소 (예: `https://dbx.no1king.freeddns.org`) | +| `CS_ORDER_URL` | CS 발주 업무 버튼 이동 주소 | +| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 | + +--- + +## 로컬 Docker 테스트 + +```powershell +# 1. 환경 변수 파일 준비 +copy .env.local.example .env.local +# .env.local 을 열어 Google OAuth 키 등 실제 값으로 수정 + +# 2. 이미지 빌드 및 컨테이너 실행 +docker compose -f docker-compose.local.yml up --build + +# 3. 브라우저에서 확인 +# http://localhost:8080 +``` + +> **Google 로컬 OAuth 주의** +> Google Cloud Console → OAuth 클라이언트 → 승인된 리디렉션 URI에 +> `http://localhost:8080/auth/google` 를 반드시 추가해야 합니다. + +--- + +## 서버 배포 (Ubuntu + NPM) + +NPM은 `dbx.no1king.freeddns.org` → `http://192.168.0.194:80` 으로 이미 설정되어 있습니다. + +```bash +# 서버에서 실행 + +# 1. 저장소 클론 +git clone https://gitea.no1king.freeddns.org/king/dbx-main.git +cd dbx-main + +# 2. 환경 변수 파일 준비 +cp .env.example .env +# .env 를 열어 실제 값으로 수정 +nano .env + +# 3. 이미지 빌드 및 백그라운드 실행 +docker compose up --build -d + +# 4. 로그 확인 +docker compose logs -f +``` + +### 업데이트 배포 + +```bash +git pull +docker compose up --build -d +``` + +### 컨테이너 관리 + +```bash +docker compose ps # 상태 확인 +docker compose down # 중지 +docker compose restart web # 재시작 +``` + +--- + +## 폰트 + +`app/static/fonts/` 에 Pretendard 폰트가 포함되어 있습니다. + +| 파일 | 굵기 | +|---|---| +| `Pretendard-Thin.ttf` | 100 (얇음) | +| `Pretendard-Regular.ttf` | 400 (기본) | +| `Pretendard-Bold.ttf` | 700 (굵음) | diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f2a678c --- /dev/null +++ b/app/main.py @@ -0,0 +1,190 @@ +import inspect +import os +from pathlib import Path +from typing import Any + +from authlib.integrations.starlette_client import OAuth, OAuthError +from dotenv import load_dotenv +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent +ALLOWED_DOMAIN = "dbxcorp.co.kr" +ALLOWED_EMAILS = { + "king@dbxcorp.co.kr", + "julie@dbxcorp.co.kr", + "ellen@dbxcorp.co.kr", + "bj@dbxcorp.co.kr", +} + + +def env(name: str, default: str = "") -> str: + return os.getenv(name, default).strip() + + +def build_google_oauth() -> OAuth: + oauth = OAuth() + oauth.register( + name="google", + client_id=env("GOOGLE_CLIENT_ID"), + client_secret=env("GOOGLE_CLIENT_SECRET"), + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, + ) + return oauth + + +app = FastAPI(title="DBX 메인 페이지") +app.add_middleware( + SessionMiddleware, + secret_key=env("SESSION_SECRET_KEY", "change-this-session-secret"), + https_only=env("SESSION_COOKIE_SECURE", "true").lower() == "true", + same_site="lax", + max_age=60 * 60 * 8, +) +app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") + +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +oauth = build_google_oauth() + + +def public_url_for(request: Request, route_name: str) -> str: + public_base_url = env("PUBLIC_BASE_URL").rstrip("/") + if public_base_url: + return f"{public_base_url}{request.url_for(route_name).path}" + return str(request.url_for(route_name)) + + +def get_user(request: Request) -> dict[str, Any] | None: + user = request.session.get("user") + return user if isinstance(user, dict) else None + + +def render_template( + request: Request, + name: str, + context: dict[str, Any] | None = None, + status_code: int = 200, +) -> HTMLResponse: + template_context = {"request": request, **(context or {})} + first_param = next(iter(inspect.signature(templates.TemplateResponse).parameters)) + if first_param == "request": + return templates.TemplateResponse( + request, + name, + template_context, + status_code=status_code, + ) + return templates.TemplateResponse( + name, + template_context, + status_code=status_code, + ) + + +def is_allowed_google_user(userinfo: dict[str, Any]) -> tuple[bool, str]: + email = str(userinfo.get("email", "")).lower().strip() + email_verified = bool(userinfo.get("email_verified")) + domain = email.rsplit("@", 1)[-1] if "@" in email else "" + + if not email_verified: + return False, "Google 계정 이메일 인증이 확인되지 않았습니다." + if domain != ALLOWED_DOMAIN: + return False, "회사 Google Workspace 계정만 접속할 수 있습니다." + if email not in ALLOWED_EMAILS: + return False, "접속 허용 목록에 없는 계정입니다." + return True, "" + + +@app.get("/", response_class=HTMLResponse) +async def home(request: Request) -> HTMLResponse: + user = get_user(request) + if not user: + return render_template(request, "login.html") + + menu_items = [ + { + "title": "CS 발주 업무", + "description": "CS 발주 업무 페이지로 이동", + "url": env("CS_ORDER_URL", "#"), + }, + { + "title": "고객 주문리스트 프로그램", + "description": "고객 주문리스트 프로그램으로 이동", + "url": env("CUSTOMER_ORDER_LIST_URL", "#"), + }, + ] + return render_template( + request, + "main.html", + {"user": user, "menu_items": menu_items}, + ) + + +@app.get("/login") +async def login(request: Request): + if not env("GOOGLE_CLIENT_ID") or not env("GOOGLE_CLIENT_SECRET"): + return render_template( + request, + "denied.html", + {"reason": "Google OAuth 환경 변수가 아직 설정되지 않았습니다."}, + status_code=500, + ) + + redirect_uri = public_url_for(request, "auth_google") + return await oauth.google.authorize_redirect( + request, + redirect_uri, + hd=ALLOWED_DOMAIN, + prompt="select_account", + ) + + +@app.get("/auth/google") +async def auth_google(request: Request): + try: + token = await oauth.google.authorize_access_token(request) + userinfo = token.get("userinfo") + if userinfo is None: + userinfo = await oauth.google.userinfo(token=token) + except OAuthError as exc: + return render_template( + request, + "denied.html", + {"reason": f"Google 로그인 실패: {exc.error}"}, + status_code=401, + ) + + allowed, reason = is_allowed_google_user(dict(userinfo)) + if not allowed: + request.session.clear() + return render_template( + request, + "denied.html", + {"reason": reason}, + status_code=403, + ) + + request.session["user"] = { + "email": str(userinfo.get("email", "")).lower().strip(), + "name": userinfo.get("name") or userinfo.get("email"), + "picture": userinfo.get("picture", ""), + } + return RedirectResponse(url="/", status_code=303) + + +@app.get("/logout") +async def logout(request: Request) -> RedirectResponse: + request.session.clear() + return RedirectResponse(url="/", status_code=303) + + +@app.get("/healthz") +async def healthz() -> dict[str, str]: + return {"status": "ok"} diff --git a/app/static/fonts/Pretendard-Bold.ttf b/app/static/fonts/Pretendard-Bold.ttf new file mode 100644 index 0000000..fb07fc6 Binary files /dev/null and b/app/static/fonts/Pretendard-Bold.ttf differ diff --git a/app/static/fonts/Pretendard-Regular.ttf b/app/static/fonts/Pretendard-Regular.ttf new file mode 100644 index 0000000..01147e9 Binary files /dev/null and b/app/static/fonts/Pretendard-Regular.ttf differ diff --git a/app/static/fonts/Pretendard-Thin.ttf b/app/static/fonts/Pretendard-Thin.ttf new file mode 100644 index 0000000..fe9825f Binary files /dev/null and b/app/static/fonts/Pretendard-Thin.ttf differ diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..5fd6424 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,222 @@ +@font-face { + font-family: "Pretendard"; + src: url("/static/fonts/Pretendard-Thin.ttf") format("truetype"); + font-weight: 100; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Pretendard"; + src: url("/static/fonts/Pretendard-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Pretendard"; + src: url("/static/fonts/Pretendard-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +:root { + color-scheme: light; + --bg: #f5f7f9; + --surface: #ffffff; + --text: #17202a; + --muted: #5d6975; + --line: #d9e0e7; + --accent: #146c5f; + --accent-strong: #0e554b; + --shadow: 0 18px 50px rgba(22, 31, 42, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: + "Pretendard", + "Segoe UI", + "Apple SD Gothic Neo", + system-ui, + sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +.auth-page { + display: grid; + place-items: center; + padding: 28px; +} + +.auth-shell { + width: min(100%, 460px); +} + +.auth-panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 36px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + font-size: 13px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(30px, 4vw, 44px); + line-height: 1.1; + letter-spacing: 0; +} + +.lead { + margin: 18px 0 28px; + color: var(--muted); + font-size: 16px; + line-height: 1.6; +} + +.note { + margin: 18px 0 0; + color: var(--muted); + font-size: 13px; +} + +.primary-button, +.ghost-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + border-radius: 6px; + font-weight: 700; + white-space: nowrap; +} + +.primary-button { + width: 100%; + background: var(--accent); + color: #fff; + padding: 0 18px; +} + +.primary-button:hover { + background: var(--accent-strong); +} + +.ghost-button { + border: 1px solid var(--line); + background: #fff; + padding: 0 14px; + color: var(--muted); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 28px clamp(20px, 5vw, 56px); + background: var(--surface); + border-bottom: 1px solid var(--line); +} + +.account { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.account img { + width: 42px; + height: 42px; + border-radius: 50%; +} + +.account strong, +.account span { + display: block; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account span { + color: var(--muted); + font-size: 13px; +} + +.menu-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 18px; + padding: clamp(20px, 5vw, 56px); +} + +.menu-card { + display: flex; + min-height: 150px; + flex-direction: column; + justify-content: space-between; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 24px; + box-shadow: 0 10px 30px rgba(22, 31, 42, 0.07); +} + +.menu-card:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.menu-card span { + font-size: 22px; + font-weight: 800; + line-height: 1.3; +} + +.menu-card small { + color: var(--muted); + font-size: 14px; +} + +@media (max-width: 720px) { + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .account { + width: 100%; + align-items: flex-start; + flex-wrap: wrap; + } + + .ghost-button { + margin-left: auto; + } +} diff --git a/app/templates/denied.html b/app/templates/denied.html new file mode 100644 index 0000000..2e73255 --- /dev/null +++ b/app/templates/denied.html @@ -0,0 +1,19 @@ + + + + + + 접속 불가 + + + +
+
+

Access denied

+

접속할 수 없습니다

+

{{ reason }}

+ 다른 Google 계정으로 로그인 +
+
+ + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..26be61f --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,20 @@ + + + + + + DBX 메인 페이지 로그인 + + + +
+
+

DBX Workspace

+

메인 페이지

+

회사 Google Workspace 계정으로 로그인하세요.

+ Google 계정으로 로그인 +

허용된 계정만 접속할 수 있습니다.

+
+
+ + diff --git a/app/templates/main.html b/app/templates/main.html new file mode 100644 index 0000000..e21e02d --- /dev/null +++ b/app/templates/main.html @@ -0,0 +1,36 @@ + + + + + + DBX 메인 페이지 + + + +
+
+

DBX Workspace

+

업무 메뉴

+
+ +
+ +
+ {% for item in menu_items %} + + {{ item.title }} + {{ item.description }} + + {% endfor %} +
+ + diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..0fc7dd7 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,10 @@ +services: + web: + build: . + container_name: dbx-main-local + restart: "no" + ports: + # 로컬에서는 http://localhost:8080 으로 접속 + - "8080:8000" + env_file: + - .env.local diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cfa083f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + web: + build: . + image: dbx-main:latest + container_name: dbx-main + restart: unless-stopped + ports: + # NPM이 192.168.0.194:80 으로 프록시 → 컨테이너 8000으로 전달 + - "80:8000" + env_file: + - .env diff --git a/nginx/main-page.conf b/nginx/main-page.conf new file mode 100644 index 0000000..b4117f4 --- /dev/null +++ b/nginx/main-page.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name dbx.no1king.freeddns.org; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name dbx.no1king.freeddns.org; + + ssl_certificate /etc/letsencrypt/live/dbx.no1king.freeddns.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dbx.no1king.freeddns.org/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6c13006 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +authlib>=1.3 +httpx>=0.28 +jinja2>=3.1 +itsdangerous>=2.2 +python-dotenv>=1.0