Files
nginx-portal/backend/main.py
qorgh529 54877f8e96 feat: 허브 홈페이지 구축 및 URL 구조 개편 (2026-05)
- hub/ 신규 추가: cyanburu.com/ 허브 홈페이지 (에디토리얼+사이버펑크 디자인)
- hub/index.html: /portal/api/homepage/cards 동적 카드 로딩
- k8s/00-hub.yaml: hub 네임스페이스 + Deployment + Service
- k8s/13-ingress-hub.yaml: cyanburu.com/ → hub 라우팅
- k8s/08-ingress.yaml: cyanburu.com/ → cyanburu.com/portal 경로 변경
- backend/main.py: homepage_cards CRUD API 추가, root_path=/portal 설정
- frontend/index.html: API 경로 /portal/api 수정, 홈 카드 관리 탭 추가
- README.md: 2026-05 변경 이력 추가
2026-06-10 18:18:53 +09:00

937 lines
39 KiB
Python
Executable File

from fastapi import FastAPI, HTTPException, Depends, status
from contextlib import asynccontextmanager
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, List
import psycopg2
import psycopg2.extras
import bcrypt
import jwt
import os
import secrets
import string
from datetime import datetime, timedelta
from notifier import notify_both, notify_email_only, notify_discord_only
from monitor import start_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = start_scheduler()
yield
scheduler.shutdown()
app = FastAPI(title="Web Portal API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
SECRET_KEY = os.getenv("JWT_SECRET", "supersecretkey1234")
ALGORITHM = "HS256"
security = HTTPBearer()
MAX_LOGIN_ATTEMPTS = 5
DB_CONFIG = {
"host": os.getenv("DB_HOST", "postgres-service"),
"port": int(os.getenv("DB_PORT", "5432")),
"database": os.getenv("DB_NAME", "portaldb"),
"user": os.getenv("DB_USER", "portaluser"),
"password": os.getenv("DB_PASSWORD", "portalpass"),
}
def get_db():
conn = psycopg2.connect(**DB_CONFIG)
try:
yield conn
finally:
conn.close()
def init_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
must_change_password BOOLEAN DEFAULT TRUE,
login_attempts INTEGER DEFAULT 0,
is_locked BOOLEAN DEFAULT FALSE,
password_change_requested BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS webpages (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
url VARCHAR(500) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_webpage_access (
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
webpage_id INTEGER REFERENCES webpages(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, webpage_id)
);
CREATE TABLE IF NOT EXISTS notify_config (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
);
""")
# 컬럼 추가 (기존 DB 마이그레이션)
for col, definition in [
("must_change_password", "BOOLEAN DEFAULT TRUE"),
("login_attempts", "INTEGER DEFAULT 0"),
("is_locked", "BOOLEAN DEFAULT FALSE"),
("password_change_requested", "BOOLEAN DEFAULT FALSE"),
]:
try:
cur.execute(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col} {definition}")
except Exception:
pass
# 기본 관리자 계정 (최초 로그인 시 비밀번호 변경 불필요)
cur.execute("SELECT id FROM users WHERE username = 'admin'")
if not cur.fetchone():
hashed = bcrypt.hashpw("admin1234".encode(), bcrypt.gensalt()).decode()
cur.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) VALUES (%s, %s, TRUE, FALSE)",
("admin", hashed)
)
# 테스트 일반 사용자 (최초 로그인 시 비밀번호 변경 필요)
cur.execute("SELECT id FROM users WHERE username = 'user1'")
if not cur.fetchone():
hashed = bcrypt.hashpw("user1234".encode(), bcrypt.gensalt()).decode()
cur.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) VALUES (%s, %s, FALSE, TRUE)",
("user1", hashed)
)
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup():
import time
for _ in range(10):
try:
init_db()
print("DB initialized successfully")
break
except Exception as e:
print(f"DB not ready, retrying... {e}")
time.sleep(3)
def create_token(user_id: int, username: str, is_admin: bool, must_change_password: bool):
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"must_change_password": must_change_password,
"exp": datetime.utcnow() + timedelta(hours=8)
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
def require_admin(token=Depends(verify_token)):
if not token.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin only")
return token
def generate_temp_password(length=10):
chars = string.ascii_letters + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
# ─── Auth ───────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/api/auth/login")
async def login(req: LoginRequest, conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM users WHERE username = %s", (req.username,))
user = cur.fetchone()
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
# 계정 잠금 확인
if user["is_locked"]:
raise HTTPException(status_code=403, detail="Account locked. Please contact admin.")
# 비밀번호 검증
if not bcrypt.checkpw(req.password.encode(), user["password_hash"].encode()):
attempts = (user["login_attempts"] or 0) + 1
locked = attempts >= MAX_LOGIN_ATTEMPTS
cur.execute(
"UPDATE users SET login_attempts=%s, is_locked=%s, password_change_requested=%s WHERE id=%s",
(attempts, locked, locked, user["id"])
)
conn.commit()
if locked:
await notify_discord_only(
title="🔒 계정 잠금 발생",
message=(
f"사용자: `{req.username}`\n"
f"사유: 비밀번호 {MAX_LOGIN_ATTEMPTS}회 오류로 계정이 잠겼습니다.\n"
f"관리자 페이지에서 잠금 해제 또는 임시 비밀번호를 발급해주세요."
),
color=0xe74c3c
)
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts. Please contact admin.")
remaining = MAX_LOGIN_ATTEMPTS - attempts
raise HTTPException(status_code=401, detail=f"Invalid credentials. {remaining} attempts remaining.")
# 로그인 성공 - 실패 횟수 초기화
cur.execute("UPDATE users SET login_attempts=0 WHERE id=%s", (user["id"],))
conn.commit()
token = create_token(user["id"], user["username"], user["is_admin"], user["must_change_password"])
return {
"token": token,
"username": user["username"],
"is_admin": user["is_admin"],
"must_change_password": user["must_change_password"]
}
@app.get("/api/auth/me")
def me(token=Depends(verify_token)):
return {
"username": token["username"],
"is_admin": token["is_admin"],
"must_change_password": token.get("must_change_password", False)
}
# ─── 비밀번호 변경 (본인) ────────────────────────────────
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@app.post("/api/auth/change-password")
def change_password(req: ChangePasswordRequest, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
user_id = int(token["sub"])
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
user = cur.fetchone()
if not bcrypt.checkpw(req.current_password.encode(), user["password_hash"].encode()):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
hashed = bcrypt.hashpw(req.new_password.encode(), bcrypt.gensalt()).decode()
cur.execute(
"UPDATE users SET password_hash=%s, must_change_password=FALSE, login_attempts=0, is_locked=FALSE WHERE id=%s",
(hashed, user_id)
)
conn.commit()
new_token = create_token(user["id"], user["username"], user["is_admin"], False)
return {"ok": True, "token": new_token}
# ─── My Pages ───────────────────────────────────────────
@app.get("/api/my-pages")
def my_pages(token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
user_id = int(token["sub"])
if token["is_admin"]:
cur.execute("SELECT * FROM webpages ORDER BY name")
else:
cur.execute("""
SELECT w.* FROM webpages w
JOIN user_webpage_access ua ON w.id = ua.webpage_id
WHERE ua.user_id = %s ORDER BY w.name
""", (user_id,))
return cur.fetchall()
# ─── Admin: Webpages CRUD ────────────────────────────────
@app.get("/api/admin/webpages")
def list_webpages(token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM webpages ORDER BY name")
return cur.fetchall()
class WebpageCreate(BaseModel):
name: str
url: str
description: Optional[str] = ""
@app.post("/api/admin/webpages")
def create_webpage(data: WebpageCreate, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO webpages (name, url, description) VALUES (%s, %s, %s) RETURNING *",
(data.name, data.url, data.description)
)
conn.commit()
return cur.fetchone()
@app.put("/api/admin/webpages/{page_id}")
def update_webpage(page_id: int, data: WebpageCreate, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"UPDATE webpages SET name=%s, url=%s, description=%s WHERE id=%s RETURNING *",
(data.name, data.url, data.description, page_id)
)
result = cur.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Not found")
conn.commit()
return result
@app.delete("/api/admin/webpages/{page_id}")
def delete_webpage(page_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute("DELETE FROM webpages WHERE id = %s", (page_id,))
conn.commit()
return {"ok": True}
# ─── Admin: Users ────────────────────────────────────────
@app.get("/api/admin/users")
def list_users(token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
SELECT id, username, is_admin, must_change_password,
login_attempts, is_locked, password_change_requested, created_at
FROM users ORDER BY username
""")
return cur.fetchall()
class UserCreate(BaseModel):
username: str
password: str
is_admin: bool = False
@app.post("/api/admin/users")
def create_user(data: UserCreate, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
hashed = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode()
try:
cur.execute(
"""INSERT INTO users (username, password_hash, is_admin, must_change_password)
VALUES (%s, %s, %s, TRUE) RETURNING id, username, is_admin""",
(data.username, hashed, data.is_admin)
)
conn.commit()
return cur.fetchone()
except psycopg2.errors.UniqueViolation:
raise HTTPException(status_code=400, detail="Username already exists")
@app.delete("/api/admin/users/{user_id}")
def delete_user(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute("DELETE FROM users WHERE id = %s AND username != 'admin'", (user_id,))
conn.commit()
return {"ok": True}
# ─── Admin: 비밀번호 변경 (관리자가 타 사용자) ──────────────
class AdminPasswordChange(BaseModel):
new_password: str
@app.put("/api/admin/users/{user_id}/password")
def admin_change_password(user_id: int, data: AdminPasswordChange, token=Depends(require_admin), conn=Depends(get_db)):
if len(data.new_password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
cur = conn.cursor()
hashed = bcrypt.hashpw(data.new_password.encode(), bcrypt.gensalt()).decode()
cur.execute(
"""UPDATE users SET password_hash=%s, must_change_password=TRUE,
login_attempts=0, is_locked=FALSE, password_change_requested=FALSE
WHERE id=%s AND username != 'admin'""",
(hashed, user_id)
)
conn.commit()
return {"ok": True}
# ─── Admin: 임시 비밀번호 발급 ───────────────────────────
@app.post("/api/admin/users/{user_id}/reset-password")
async def reset_password(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
temp_pw = generate_temp_password()
hashed = bcrypt.hashpw(temp_pw.encode(), bcrypt.gensalt()).decode()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
user = cur.fetchone()
cur.execute(
"""UPDATE users SET password_hash=%s, must_change_password=TRUE,
login_attempts=0, is_locked=FALSE, password_change_requested=FALSE
WHERE id=%s""",
(hashed, user_id)
)
conn.commit()
if user:
await notify_discord_only(
title="🔑 임시 비밀번호 발급",
message=(
f"관리자 `{token['username']}` 이(가) 임시 비밀번호를 발급했습니다.\n"
f"대상 사용자: `{user['username']}`"
),
color=0x3498db
)
return {"ok": True, "temp_password": temp_pw}
# ─── Admin: 계정 잠금 해제 ───────────────────────────────
@app.post("/api/admin/users/{user_id}/unlock")
def unlock_user(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute(
"UPDATE users SET is_locked=FALSE, login_attempts=0 WHERE id=%s",
(user_id,)
)
conn.commit()
return {"ok": True}
# ─── Admin: User Access ──────────────────────────────────
@app.get("/api/admin/users/{user_id}/pages")
def get_user_pages(user_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
SELECT w.id, w.name, w.url, w.description,
(ua.user_id IS NOT NULL) as has_access
FROM webpages w
LEFT JOIN user_webpage_access ua ON w.id = ua.webpage_id AND ua.user_id = %s
ORDER BY w.name
""", (user_id,))
return cur.fetchall()
class AccessUpdate(BaseModel):
webpage_ids: List[int]
@app.put("/api/admin/users/{user_id}/pages")
def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute("DELETE FROM user_webpage_access WHERE user_id = %s", (user_id,))
for page_id in data.webpage_ids:
cur.execute(
"INSERT INTO user_webpage_access (user_id, webpage_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, page_id)
)
conn.commit()
return {"ok": True}
# ─── Admin: 알림 채널 관리 (다중 채널) ────────────────────
class NotifyChannel(BaseModel):
id: Optional[int] = None
name: str # 채널 이름 (식별용)
type: str # "discord" | "email"
discord_webhook_url: Optional[str] = ""
gmail_user: Optional[str] = ""
gmail_app_password: Optional[str] = ""
alert_email_to: Optional[str] = ""
enabled: Optional[bool] = True
def init_notify_channels_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS notify_channels (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL,
discord_webhook_url TEXT DEFAULT '',
gmail_user VARCHAR(200) DEFAULT '',
gmail_app_password TEXT DEFAULT '',
alert_email_to VARCHAR(200) DEFAULT '',
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
""")
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_notify_channels():
import time
for _ in range(10):
try:
init_notify_channels_db()
print("Notify channels DB initialized")
break
except Exception as e:
print(f"Notify channels DB not ready... {e}")
time.sleep(3)
def get_notify_config_from_db(conn) -> dict:
"""하위 호환: 첫 번째 활성 채널에서 설정 조회, 없으면 환경변수 fallback"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
try:
cur.execute("SELECT * FROM notify_channels WHERE enabled=TRUE ORDER BY id LIMIT 1")
row = cur.fetchone()
if row:
return {
"discord_webhook_url": row["discord_webhook_url"] or "",
"gmail_user": row["gmail_user"] or "",
"gmail_app_password": row["gmail_app_password"] or "",
"alert_email_to": row["alert_email_to"] or "",
}
except Exception:
pass
return {
"discord_webhook_url": os.getenv("DISCORD_WEBHOOK_URL", ""),
"gmail_user": os.getenv("GMAIL_USER", ""),
"gmail_app_password": os.getenv("GMAIL_APP_PASSWORD", ""),
"alert_email_to": os.getenv("ALERT_EMAIL_TO", ""),
}
@app.get("/api/admin/notify-channels")
def list_notify_channels(token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled, created_at FROM notify_channels ORDER BY id")
rows = cur.fetchall()
# 비밀번호 제외하고 반환
return [dict(r) for r in rows]
@app.post("/api/admin/notify-channels")
def create_notify_channel(data: NotifyChannel, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
INSERT INTO notify_channels (name, type, discord_webhook_url, gmail_user, gmail_app_password, alert_email_to, enabled)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled
""", (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "",
data.gmail_app_password or "", data.alert_email_to or "", data.enabled))
conn.commit()
return cur.fetchone()
@app.put("/api/admin/notify-channels/{channel_id}")
def update_notify_channel(channel_id: int, data: NotifyChannel, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# 비밀번호가 비어있으면 기존값 유지
if data.gmail_app_password and data.gmail_app_password.strip():
cur.execute("""
UPDATE notify_channels SET name=%s, type=%s, discord_webhook_url=%s,
gmail_user=%s, gmail_app_password=%s, alert_email_to=%s, enabled=%s
WHERE id=%s RETURNING id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled
""", (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "",
data.gmail_app_password, data.alert_email_to or "", data.enabled, channel_id))
else:
cur.execute("""
UPDATE notify_channels SET name=%s, type=%s, discord_webhook_url=%s,
gmail_user=%s, alert_email_to=%s, enabled=%s
WHERE id=%s RETURNING id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled
""", (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "",
data.alert_email_to or "", data.enabled, channel_id))
conn.commit()
return cur.fetchone()
@app.delete("/api/admin/notify-channels/{channel_id}")
def delete_notify_channel(channel_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute("DELETE FROM notify_channels WHERE id=%s", (channel_id,))
conn.commit()
return {"ok": True}
# ─── 하위호환: 기존 단일 설정 API ────────────────────────
def get_notify_config_from_db_legacy(conn) -> dict:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
try:
cur.execute("SELECT key, value FROM notify_config")
rows = cur.fetchall()
db_config = {r["key"]: r["value"] for r in rows}
return {
"discord_webhook_url": db_config.get("discord_webhook_url", os.getenv("DISCORD_WEBHOOK_URL", "")),
"gmail_user": db_config.get("gmail_user", os.getenv("GMAIL_USER", "")),
"gmail_app_password": db_config.get("gmail_app_password", os.getenv("GMAIL_APP_PASSWORD", "")),
"alert_email_to": db_config.get("alert_email_to", os.getenv("ALERT_EMAIL_TO", "")),
}
except Exception:
return {
"discord_webhook_url": os.getenv("DISCORD_WEBHOOK_URL", ""),
"gmail_user": os.getenv("GMAIL_USER", ""),
"gmail_app_password": os.getenv("GMAIL_APP_PASSWORD", ""),
"alert_email_to": os.getenv("ALERT_EMAIL_TO", ""),
}
@app.get("/api/admin/notify-test")
async def notify_test(token=Depends(require_admin)):
# Discord + Gmail (Pod 이상/복구 테스트)
await notify_both(
title="✅ [Discord+Gmail] 알림 테스트",
message=(
f"관리자 `{token['username']}` 이(가) 알림 테스트를 실행했습니다.\n"
f"Pod 이상/복구 알림 채널입니다."
),
color=0x2ecc71
)
# Gmail only (인증서 만료 테스트)
await notify_email_only(
title="✅ [Gmail 전용] 알림 테스트",
message=(
f"인증서 만료 임박 알림 채널입니다.\n"
f"Gmail로만 발송됩니다."
),
color=0xf39c12
)
# Discord only (계정 잠금/임시PW 테스트)
await notify_discord_only(
title="✅ [Discord 전용] 알림 테스트",
message=(
f"계정 잠금/임시 비밀번호 발급 알림 채널입니다.\n"
f"Discord로만 발송됩니다."
),
color=0x3498db
)
return {"ok": True, "message": "채널별 알림 테스트 완료 (Discord+Gmail / Gmail전용 / Discord전용)"}
@app.get("/health")
def health():
return {"status": "ok"}
# ─── 게시판 ──────────────────────────────────────────────
class PostCreate(BaseModel):
title: str
content: str
class ReplyCreate(BaseModel):
content: str
def init_board_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS board_posts (
id SERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS board_replies (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES board_posts(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
""")
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_board():
import time
for _ in range(10):
try:
init_board_db()
print("Board DB initialized")
break
except Exception as e:
print(f"Board DB not ready... {e}")
time.sleep(3)
@app.get("/api/board/posts")
def list_posts(token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, author_name, created_at FROM board_posts ORDER BY created_at DESC")
return cur.fetchall()
@app.post("/api/board/posts")
def create_post(data: PostCreate, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO board_posts (title, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(data.title, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.get("/api/board/posts/{post_id}")
def get_post(post_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM board_posts WHERE id = %s", (post_id,))
post = cur.fetchone()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
cur.execute("SELECT * FROM board_replies WHERE post_id = %s ORDER BY created_at ASC", (post_id,))
replies = cur.fetchall()
return {"post": post, "replies": replies}
@app.delete("/api/board/posts/{post_id}")
def delete_post(post_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM board_posts WHERE id = %s", (post_id,))
post = cur.fetchone()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if not token.get("is_admin") and post["author_id"] != int(token["sub"]):
raise HTTPException(status_code=403, detail="Permission denied")
cur.execute("DELETE FROM board_posts WHERE id = %s", (post_id,))
conn.commit()
return {"ok": True}
@app.post("/api/board/posts/{post_id}/replies")
def create_reply(post_id: int, data: ReplyCreate, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO board_replies (post_id, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(post_id, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.delete("/api/board/replies/{reply_id}")
def delete_reply(reply_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM board_replies WHERE id = %s", (reply_id,))
reply = cur.fetchone()
if not reply:
raise HTTPException(status_code=404, detail="Reply not found")
if not token.get("is_admin") and reply["author_id"] != int(token["sub"]):
raise HTTPException(status_code=403, detail="Permission denied")
cur.execute("DELETE FROM board_replies WHERE id = %s", (reply_id,))
conn.commit()
return {"ok": True}
# ─── 공지사항 ──────────────────────────────────────────────
class NoticeCreate(BaseModel):
title: str
content: str
class NoticeReplyCreate(BaseModel):
content: str
def init_notice_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS notice_posts (
id SERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS notice_replies (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES notice_posts(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
""")
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_notice():
import time
for _ in range(10):
try:
init_notice_db()
print("Notice DB initialized")
break
except Exception as e:
print(f"Notice DB not ready... {e}")
time.sleep(3)
@app.get("/api/notice/posts")
def list_notice_posts(token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, author_name, created_at FROM notice_posts ORDER BY created_at DESC")
return cur.fetchall()
@app.post("/api/notice/posts")
def create_notice_post(data: NoticeCreate, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO notice_posts (title, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(data.title, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.get("/api/notice/posts/{post_id}")
def get_notice_post(post_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM notice_posts WHERE id = %s", (post_id,))
post = cur.fetchone()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
cur.execute("SELECT * FROM notice_replies WHERE post_id = %s ORDER BY created_at ASC", (post_id,))
replies = cur.fetchall()
return {"post": post, "replies": replies}
@app.delete("/api/notice/posts/{post_id}")
def delete_notice_post(post_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute("DELETE FROM notice_posts WHERE id = %s", (post_id,))
conn.commit()
return {"ok": True}
@app.post("/api/notice/posts/{post_id}/replies")
def create_notice_reply(post_id: int, data: NoticeReplyCreate, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO notice_replies (post_id, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(post_id, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.delete("/api/notice/replies/{reply_id}")
def delete_notice_reply(reply_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM notice_replies WHERE id = %s", (reply_id,))
reply = cur.fetchone()
if not reply:
raise HTTPException(status_code=404, detail="Reply not found")
if not token.get("is_admin") and reply["author_id"] != int(token["sub"]):
raise HTTPException(status_code=403, detail="Permission denied")
cur.execute("DELETE FROM notice_replies WHERE id = %s", (reply_id,))
conn.commit()
return {"ok": True}
# ─── Homepage Cards ──────────────────────────────────────────────────────────
# 이 코드를 기존 main.py 맨 아래에 붙여넣으세요.
# init_db() 함수 안의 cur.execute("""...""") 블록에도 아래 테이블을 추가해야 합니다.
# (init_db 수정 방법은 아래 주석 참고)
#
# [init_db() 수정] 기존 CREATE TABLE ... 블록 마지막에 추가:
#
# CREATE TABLE IF NOT EXISTS homepage_cards (
# id SERIAL PRIMARY KEY,
# title VARCHAR(100) NOT NULL,
# subtitle VARCHAR(200),
# description TEXT,
# url VARCHAR(500) NOT NULL,
# tag VARCHAR(20) DEFAULT 'LIVE',
# sort_order INTEGER DEFAULT 0,
# visible BOOLEAN DEFAULT TRUE,
# created_at TIMESTAMP DEFAULT NOW()
# );
#
# ─────────────────────────────────────────────────────────────────────────────
def init_homepage_cards_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS homepage_cards (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
subtitle VARCHAR(200),
description TEXT,
url VARCHAR(500) NOT NULL,
tag VARCHAR(20) DEFAULT 'LIVE',
sort_order INTEGER DEFAULT 0,
visible BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
""")
# 기본 카드 초기 데이터 (최초 1회만)
cur.execute("SELECT COUNT(*) FROM homepage_cards")
count = cur.fetchone()[0]
if count == 0:
default_cards = [
("Web Portal", "내부 서비스 관리", "로그인 후 할당된 웹 서비스 목록을 확인하고 접속할 수 있는 통합 포털.", "/portal", "LIVE", 1),
("King's Cup", "멀티플레이 술게임", "디스코드 화면공유로 친구들과 함께 즐기는 킹컵 카드 게임. 실시간 WebSocket 기반.", "/kingscup", "NEW", 2),
("Gitea", "셀프호스티드 Git", "GitHub 대신 자체 서버에서 운영하는 Git 저장소. Container Registry 포함.", "https://gitea.cyanburu.com", "LIVE", 3),
("ArgoCD", "GitOps 배포 엔진", "Gitea 저장소를 감시해 K8s 클러스터에 자동으로 배포하는 GitOps 도구.", "https://argo.cyanburu.com", "LIVE", 4),
]
cur.executemany(
"INSERT INTO homepage_cards (title, subtitle, description, url, tag, sort_order) VALUES (%s,%s,%s,%s,%s,%s)",
default_cards
)
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_homepage_cards():
import time
for _ in range(10):
try:
init_homepage_cards_db()
print("Homepage cards DB initialized")
break
except Exception as e:
print(f"Homepage cards DB not ready... {e}")
time.sleep(3)
# ── Public API (인증 불필요 - 허브 홈페이지에서 호출) ──────────────────────
@app.get("/api/homepage/cards")
def get_homepage_cards(conn=Depends(get_db)):
"""허브 홈페이지(cyanburu.com/)에서 카드 목록을 가져옵니다. 인증 불필요."""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
SELECT id, title, subtitle, description, url, tag, sort_order
FROM homepage_cards
WHERE visible = TRUE
ORDER BY sort_order ASC, id ASC
""")
return cur.fetchall()
# ── Admin API (관리자 전용) ────────────────────────────────────────────────
class HomepageCardCreate(BaseModel):
title: str
subtitle: Optional[str] = ""
description: Optional[str] = ""
url: str
tag: Optional[str] = "LIVE"
sort_order: Optional[int] = 0
visible: Optional[bool] = True
@app.get("/api/admin/homepage-cards")
def list_homepage_cards(token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 전체 카드 목록 (비공개 포함)"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM homepage_cards ORDER BY sort_order ASC, id ASC")
return cur.fetchall()
@app.post("/api/admin/homepage-cards")
def create_homepage_card(data: HomepageCardCreate, token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 카드 추가"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO homepage_cards (title, subtitle, description, url, tag, sort_order, visible)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *""",
(data.title, data.subtitle, data.description, data.url, data.tag, data.sort_order, data.visible)
)
conn.commit()
return cur.fetchone()
@app.put("/api/admin/homepage-cards/{card_id}")
def update_homepage_card(card_id: int, data: HomepageCardCreate, token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 카드 수정"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE homepage_cards
SET title=%s, subtitle=%s, description=%s, url=%s, tag=%s, sort_order=%s, visible=%s
WHERE id=%s RETURNING *""",
(data.title, data.subtitle, data.description, data.url, data.tag, data.sort_order, data.visible, card_id)
)
result = cur.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Card not found")
conn.commit()
return result
@app.delete("/api/admin/homepage-cards/{card_id}")
def delete_homepage_card(card_id: int, token=Depends(require_admin), conn=Depends(get_db)):
"""관리자: 카드 삭제"""
cur = conn.cursor()
cur.execute("DELETE FROM homepage_cards WHERE id=%s", (card_id,))
conn.commit()
return {"ok": True}