3 Commits

Author SHA1 Message Date
6a1a2c4552 CI/CD 안정성 강화: 동시 배포 방지, 자기 재빌드 제거, 헬스체크 추가
- deploy.sh: flock으로 동시 배포 방지, deployer를 빌드 대상에서 제외
- deploy.sh: 배포 후 헬스체크 (4개 서비스 /health 확인)
- deploy.sh: 릴리즈 백업 최근 5개만 유지, 원자적 백업 (mv)
- deploy-nas.sh: .env 동기화 제거 (운영 시크릿 보호), __pycache__ 제외
- deployer: threading.Lock으로 동시 배포 방어, TimeoutExpired 개별 처리
- docker-compose: deployer 포트 localhost 바인딩, stock-lab 환경변수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:20:25 +09:00
ff975defbd AI Coach 백엔드 프록시 추가 및 trade 엔드포인트 인증 적용
- POST /api/stock/ai-coach: Anthropic API 프록시 (API 키 서버 보관)
- trade/balance, trade/order: ADMIN_API_KEY 헤더 인증 추가
- print() → logging 모듈 전환 (stock-lab)
- .env.example: ADMIN_API_KEY, ANTHROPIC_API_KEY, CORS_ALLOW_ORIGINS 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:12:31 +09:00
bc9ba3901e 보안 강화: CORS 제한, Path Traversal 방어, 헬스체크 추가
- travel-proxy: get_thumb NameError 수정 및 경로 조작 방어
- stock-lab, music-lab: CORS allow_origins=* → 환경변수 기반 도메인 제한
- travel-proxy, deployer: /health 엔드포인트 추가
- 전 서비스 .dockerignore 추가 (.git, __pycache__, .env 제외)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:08:39 +09:00
13 changed files with 209 additions and 82 deletions

View File

@@ -50,3 +50,12 @@ PGID=1000
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP) # Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000 WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
ADMIN_API_KEY=
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
ANTHROPIC_API_KEY=
# CORS 허용 도메인 (콤마 구분)
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080

6
backend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

6
deployer/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -1,14 +1,18 @@
import os, hmac, hashlib, subprocess import os, hmac, hashlib, subprocess, threading
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
import logging import logging
# 로깅 설정 logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("deployer") logger = logging.getLogger("deployer")
app = FastAPI() app = FastAPI()
SECRET = os.getenv("WEBHOOK_SECRET", "") SECRET = os.getenv("WEBHOOK_SECRET", "")
if not SECRET:
logger.warning("WEBHOOK_SECRET is not set! All webhooks will be rejected.")
_deploy_lock = threading.Lock()
def verify(sig: str, body: bytes) -> bool: def verify(sig: str, body: bytes) -> bool:
if not SECRET or not sig: if not SECRET or not sig:
return False return False
@@ -18,19 +22,30 @@ def verify(sig: str, body: bytes) -> bool:
return any(hmac.compare_digest(sig, c) for c in candidates) return any(hmac.compare_digest(sig, c) for c in candidates)
def run_deploy_script(): def run_deploy_script():
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김""" """배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
logger.info("Starting deployment script...") if not _deploy_lock.acquire(blocking=False):
logger.info("Deploy already in progress, skipping")
return
try: try:
# 타임아웃 10분 설정 logger.info("Starting deployment script...")
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600) p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
if p.returncode == 0: if p.returncode == 0:
logger.info(f"Deployment SUCCESS:\n{p.stdout}") logger.info(f"Deployment SUCCESS:\n{p.stdout}")
else: else:
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}") logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
except subprocess.TimeoutExpired:
logger.error("Deployment TIMEOUT (10 min exceeded)")
except Exception as e: except Exception as e:
logger.exception(f"Exception during deployment: {e}") logger.exception(f"Exception during deployment: {e}")
finally:
_deploy_lock.release()
@app.get("/health")
def health():
return {"status": "healthy", "service": "deployer"}
@app.post("/webhook") @app.post("/webhook")
async def webhook(req: Request, background_tasks: BackgroundTasks): async def webhook(req: Request, background_tasks: BackgroundTasks):

View File

@@ -31,6 +31,9 @@ services:
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000} - WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
- GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:
- ${STOCK_DATA_PATH:-./data/stock}:/app/data - ${STOCK_DATA_PATH:-./data/stock}:/app/data
@@ -45,6 +48,7 @@ services:
- TZ=${TZ:-Asia/Seoul} - TZ=${TZ:-Asia/Seoul}
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-} - MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music} - MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:
- ${MUSIC_DATA_PATH:-./data/music}:/app/data - ${MUSIC_DATA_PATH:-./data/music}:/app/data
@@ -88,7 +92,7 @@ services:
container_name: webpage-deployer container_name: webpage-deployer
restart: unless-stopped restart: unless-stopped
ports: ports:
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천) - "127.0.0.1:19010:9000" # localhost만 허용 (nginx /webhook 프록시 경유)
environment: environment:
- WEBHOOK_SECRET=${WEBHOOK_SECRET} - WEBHOOK_SECRET=${WEBHOOK_SECRET}
volumes: volumes:

6
music-lab/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -16,11 +16,13 @@ from .db import (
app = FastAPI() app = FastAPI()
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[o.strip() for o in _cors_origins],
allow_methods=["*"], allow_credentials=False,
allow_headers=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
) )
MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "") MUSIC_AI_SERVER_URL = os.getenv("MUSIC_AI_SERVER_URL", "")

View File

@@ -30,33 +30,14 @@ cd "$SRC"
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기) # 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등 # backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
rsync -a --delete \ RSYNC_EXCLUDES="--exclude .git --exclude __pycache__ --exclude *.pyc --exclude data/"
--exclude ".git" \
--exclude ".releases" \
"$SRC/backend/" "$DST/backend/"
rsync -a --delete \
--exclude ".git" \
"$SRC/travel-proxy/" "$DST/travel-proxy/"
rsync -a --delete \
--exclude ".git" \
"$SRC/deployer/" "$DST/deployer/"
rsync -a --delete \
--exclude ".git" \
"$SRC/stock-lab/" "$DST/stock-lab/"
rsync -a --delete \
--exclude ".git" \
"$SRC/music-lab/" "$DST/music-lab/"
rsync -a --delete \
--exclude ".git" \
"$SRC/nginx/" "$DST/nginx/"
rsync -a --delete \
--exclude ".git" \
"$SRC/scripts/" "$DST/scripts/"
# compose 파일 / env 파일 for dir in backend travel-proxy deployer stock-lab music-lab nginx scripts; do
rsync -a --delete $RSYNC_EXCLUDES \
"$SRC/$dir/" "$DST/$dir/"
done
# compose 파일만 동기화 (.env는 절대 동기화하지 않음 — 운영 시크릿 보호)
rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml" rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml"
if [ -f "$SRC/.env" ]; then
rsync -a "$SRC/.env" "$DST/.env"
fi
echo "SYNC_OK" echo "SYNC_OK"

View File

@@ -1,6 +1,10 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# ── 동시 배포 방지 (flock) ──
exec 200>/tmp/deploy.lock
flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
# 1. 자동 감지: Docker 컨테이너 내부인가? # 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then if [ -d "/repo" ] && [ -d "/runtime" ]; then
echo "Detected Docker Container environment." echo "Detected Docker Container environment."
@@ -13,10 +17,9 @@ else
set -a; source .env; set +a set -a; source .env; set +a
fi fi
# 환경변수가 없으면 현재 디렉토리를 SRC로
SRC="${REPO_PATH:-$(pwd)}" SRC="${REPO_PATH:-$(pwd)}"
DST="${RUNTIME_PATH:-/volume1/docker/webpage}" # 기본값 설정 DST="${RUNTIME_PATH:-/volume1/docker/webpage}"
if [ -z "$DST" ]; then if [ -z "$DST" ]; then
echo "Error: RUNTIME_PATH is not set." echo "Error: RUNTIME_PATH is not set."
exit 1 exit 1
@@ -32,19 +35,40 @@ cd "$SRC"
git fetch --all --prune git fetch --all --prune
git pull --ff-only git pull --ff-only
# 릴리즈 백업(롤백용): 아래 5번과 연결 # ── 릴리즈 백업 (롤백용) ──
TAG="$(date +%Y%m%d-%H%M%S)" TAG="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DST/.releases/$TAG" BACKUP_DIR="$DST/.releases/$TAG"
mkdir -p "$BACKUP_DIR.tmp"
rsync -a --delete \ rsync -a --delete \
--exclude ".releases" \ --exclude ".releases" \
"$DST/" "$DST/.releases/$TAG/" "$DST/" "$BACKUP_DIR.tmp/"
mv "$BACKUP_DIR.tmp" "$BACKUP_DIR"
# 소스 → 운영 반영 (네가 이미 만든 deploy-nas.sh가 있으면 그걸 호출해도 됨) # 오래된 릴리즈 정리 (최근 5개만 유지)
# 예: repo/scripts/deploy-nas.sh가 운영으로 복사/동기화하는 로직이라면: ls -dt "$DST/.releases"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
# ── 소스 → 운영 반영 ──
bash "$SRC/scripts/deploy-nas.sh" bash "$SRC/scripts/deploy-nas.sh"
# ── 컨테이너 재빌드 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단됨) ──
cd "$DST" cd "$DST"
docker compose up -d --build backend travel-proxy stock-lab frontend deployer docker compose up -d --build backend travel-proxy stock-lab music-lab frontend
docker exec lotto-frontend nginx -s reload 2>/dev/null || true docker exec lotto-frontend nginx -s reload 2>/dev/null || true
echo "DEPLOY_OK $TAG" # ── 배포 후 헬스체크 ──
echo "Waiting for services to start..."
sleep 5
HEALTH_OK=true
for endpoint in "http://backend:8000/health" "http://stock-lab:8000/health" "http://travel-proxy:8000/health" "http://music-lab:8000/health"; do
if ! curl -sf --max-time 5 "$endpoint" > /dev/null 2>&1; then
echo "HEALTH_FAIL: $endpoint"
HEALTH_OK=false
fi
done
if [ "$HEALTH_OK" = false ]; then
echo "DEPLOY_WARN: Some services failed health check. Consider rollback: $TAG"
else
echo "DEPLOY_OK $TAG"
fi

6
stock-lab/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -1,14 +1,18 @@
import os import os
import json import json
import logging
from datetime import date as date_type from datetime import date as date_type
from typing import Optional from typing import Optional
from fastapi import FastAPI, Query from fastapi import FastAPI, Query, Header, Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import requests import requests
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from pydantic import BaseModel from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("stock-lab")
from .db import ( from .db import (
init_db, save_articles, get_latest_articles, init_db, save_articles, get_latest_articles,
add_portfolio_item, get_all_portfolio, get_portfolio_item, add_portfolio_item, get_all_portfolio, get_portfolio_item,
@@ -23,12 +27,13 @@ from .price_fetcher import get_current_prices
app = FastAPI() app = FastAPI()
# CORS 설정 (프론트엔드 접근 허용) # CORS 설정 (프론트엔드 접근 허용)
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # 운영 시에는 구체적인 도메인으로 제한하는 것이 좋음 allow_origins=[o.strip() for o in _cors_origins],
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type"],
) )
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
@@ -36,6 +41,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
# Windows AI Server URL (NAS .env에서 설정) # Windows AI Server URL (NAS .env에서 설정)
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000") WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
# Admin API Key 인증
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
def verify_admin(x_admin_key: str = Header(None)):
"""admin/trade 엔드포인트 보호용 API 키 검증"""
if not ADMIN_API_KEY:
return # 키 미설정 시 인증 비활성화 (개발 환경)
if x_admin_key != ADMIN_API_KEY:
raise HTTPException(status_code=401, detail="Unauthorized")
# Anthropic API 프록시용 키 (서버 측 보관)
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
# 공휴일 목록 로드 # 공휴일 목록 로드
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json") _HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
try: try:
@@ -123,53 +141,87 @@ def trigger_scrap():
run_scraping_job() run_scraping_job()
return {"ok": True} return {"ok": True}
# --- Trading API (Windows Proxy) --- # --- Trading API (Windows Proxy, 인증 필요) ---
@app.get("/api/trade/balance") @app.get("/api/trade/balance", dependencies=[Depends(verify_admin)])
def get_balance(): def get_balance():
"""계좌 잔고 조회 (Windows AI Server Proxy)""" """계좌 잔고 조회 (Windows AI Server Proxy)"""
print(f"[Proxy] Requesting Balance from {WINDOWS_AI_SERVER_URL}...") logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
try: try:
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5) resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
if resp.status_code != 200: if resp.status_code != 200:
print(f"[ProxyError] Balance Error: {resp.status_code} {resp.text}") logger.error(f"Balance Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json()) return JSONResponse(status_code=resp.status_code, content=resp.json())
print("[Proxy] Balance Success")
return resp.json() return resp.json()
except requests.JSONDecodeError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
except Exception as e: except Exception as e:
print(f"[ProxyError] Connection Failed: {e}") logger.error(f"Balance Connection Failed: {e}")
return JSONResponse( return JSONResponse(status_code=500, content={"error": "Connection Failed"})
status_code=500,
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
)
class OrderRequest(BaseModel): class OrderRequest(BaseModel):
ticker: str # 종목 코드 (예: "005930") ticker: str
action: str # "BUY" or "SELL" action: str
quantity: int # 주문 수량 quantity: int
price: int = 0 # 0이면 시장가 price: int = 0
reason: Optional[str] = "Manual Order" # 주문 사유 (AI 기록용) reason: Optional[str] = "Manual Order"
@app.post("/api/trade/order") @app.post("/api/trade/order", dependencies=[Depends(verify_admin)])
def order_stock(req: OrderRequest): def order_stock(req: OrderRequest):
"""주식 매수/매도 주문 (Windows AI Server Proxy)""" """주식 매수/매도 주문 (Windows AI Server Proxy)"""
print(f"[Proxy] Order Request: {req.dict()} to {WINDOWS_AI_SERVER_URL}...") logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
try: try:
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10) resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
if resp.status_code != 200: if resp.status_code != 200:
print(f"[ProxyError] Order Error: {resp.status_code} {resp.text}") logger.error(f"Order Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json()) return JSONResponse(status_code=resp.status_code, content=resp.json())
return resp.json() return resp.json()
except requests.JSONDecodeError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
except Exception as e: except Exception as e:
print(f"[ProxyError] Order Connection Failed: {e}") logger.error(f"Order Connection Failed: {e}")
return JSONResponse( return JSONResponse(status_code=500, content={"error": "Connection Failed"})
status_code=500,
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL} # --- AI Coach 프록시 (API 키를 서버에 보관) ---
class AiCoachRequest(BaseModel):
model: str = "claude-haiku-4-5-20251001"
prompt: str
max_tokens: int = 1024
@app.post("/api/stock/ai-coach")
def ai_coach(req: AiCoachRequest):
"""AI 포트폴리오 코치 — Anthropic API 프록시 (API 키 서버 보관)"""
if not ANTHROPIC_API_KEY:
raise HTTPException(503, "AI Coach not configured (ANTHROPIC_API_KEY missing)")
allowed_models = {"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}
model = req.model if req.model in allowed_models else "claude-haiku-4-5-20251001"
try:
resp = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"Content-Type": "application/json",
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
json={
"model": model,
"max_tokens": req.max_tokens,
"messages": [{"role": "user", "content": req.prompt}],
},
timeout=30,
) )
if resp.status_code != 200:
logger.error(f"Anthropic API error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content={"error": "AI API error"})
return resp.json()
except requests.Timeout:
return JSONResponse(status_code=504, content={"error": "AI API timeout"})
except Exception as e:
logger.error(f"AI Coach error: {e}")
return JSONResponse(status_code=500, content={"error": "AI Coach failed"})

View File

@@ -0,0 +1,6 @@
.git
__pycache__
*.pyc
.env
.env.*
*.md

View File

@@ -168,6 +168,10 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
# ----------------------------- # -----------------------------
# Routes # Routes
# ----------------------------- # -----------------------------
@app.get("/health")
def health():
return {"status": "healthy", "service": "travel-proxy"}
@app.get("/api/travel/regions") @app.get("/api/travel/regions")
def regions(): def regions():
_meta_changed_invalidate_cache() _meta_changed_invalidate_cache()
@@ -239,12 +243,18 @@ def photos(
@app.get("/media/travel/.thumb/{album}/{filename}") @app.get("/media/travel/.thumb/{album}/{filename}")
def get_thumb(album: str, filename: str): def get_thumb(album: str, filename: str):
if ".." in album or ".." in filename:
raise HTTPException(400, "Invalid path")
src = (ROOT / album / filename).resolve() src = (ROOT / album / filename).resolve()
if not str(src).startswith(str(ROOT)):
raise HTTPException(403, "Access denied")
if not src.exists() or not src.is_file():
raise HTTPException(404, "Source not found")
p = ensure_thumb(src, album)
if not p.exists() or not p.is_file(): if not p.exists() or not p.is_file():
raise HTTPException(404, "Thumbnail not found") raise HTTPException(404, "Thumbnail not found")
# src로부터 thumb 생성/확인 (원본 확장자 유지)
p = ensure_thumb(src, album)
return FileResponse(str(p)) return FileResponse(str(p))
@app.get("/api/version") @app.get("/api/version")