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 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:43:41 +09:00
commit c93e2be0a7
18 changed files with 722 additions and 0 deletions
+190
View File
@@ -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"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+222
View File
@@ -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;
}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>접속 불가</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="auth-page">
<main class="auth-shell">
<section class="auth-panel">
<p class="eyebrow">Access denied</p>
<h1>접속할 수 없습니다</h1>
<p class="lead">{{ reason }}</p>
<a class="primary-button" href="/login">다른 Google 계정으로 로그인</a>
</section>
</main>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DBX 메인 페이지 로그인</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="auth-page">
<main class="auth-shell">
<section class="auth-panel">
<p class="eyebrow">DBX Workspace</p>
<h1>메인 페이지</h1>
<p class="lead">회사 Google Workspace 계정으로 로그인하세요.</p>
<a class="primary-button" href="/login">Google 계정으로 로그인</a>
<p class="note">허용된 계정만 접속할 수 있습니다.</p>
</section>
</main>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DBX 메인 페이지</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header class="topbar">
<div>
<p class="eyebrow">DBX Workspace</p>
<h1>업무 메뉴</h1>
</div>
<div class="account">
{% if user.picture %}
<img src="{{ user.picture }}" alt="" />
{% endif %}
<div>
<strong>{{ user.name }}</strong>
<span>{{ user.email }}</span>
</div>
<a class="ghost-button" href="/logout">로그아웃</a>
</div>
</header>
<main class="menu-grid">
{% for item in menu_items %}
<a class="menu-card" href="{{ item.url }}">
<span>{{ item.title }}</span>
<small>{{ item.description }}</small>
</a>
{% endfor %}
</main>
</body>
</html>