- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳) - SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩 - SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지) - SEC-39: Python Dockerfile 4개 non-root user + chown - SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함) - SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized - QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑 - SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by - SEC-33: files.py 17개 미인증 엔드포인트 인증 추가 - SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자) - SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함 - SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리 - SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가 - SEC-63: proxyInputController 에러 메시지 노출 제거 - QA-103: pageAccessRoutes error→message 통일 - SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation - QA-99: tbm-mobile/create 캐시 버스팅 갱신 - QA-100,101: ESC 키 리스너 cleanup 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
314 lines
10 KiB
Python
314 lines
10 KiB
Python
"""
|
|
FastAPI 브릿지 메인 애플리케이션
|
|
Phase 1: 기본 프록시 기능
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from typing import Any, Dict
|
|
|
|
import aiohttp
|
|
import jwt as pyjwt
|
|
import uvicorn
|
|
from fastapi import FastAPI, Request, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse, FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from config import settings
|
|
from cache import cache_manager, cached
|
|
from analytics import analytics_manager, AnalyticsMiddleware
|
|
|
|
# 로깅 설정
|
|
logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL))
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# FastAPI 앱 생성
|
|
app = FastAPI(
|
|
title=settings.PROJECT_NAME,
|
|
version=settings.VERSION,
|
|
description="Technical Korea FastAPI Bridge - Express.js API 프록시"
|
|
)
|
|
|
|
# CORS 설정
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.CORS_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# 분석 미들웨어 추가
|
|
app.add_middleware(AnalyticsMiddleware)
|
|
|
|
# 정적 파일 서빙 설정
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
app.mount("/css", StaticFiles(directory="static/css"), name="css")
|
|
app.mount("/js", StaticFiles(directory="static/js"), name="js")
|
|
app.mount("/img", StaticFiles(directory="static/img"), name="img")
|
|
app.mount("/pages", StaticFiles(directory="static/pages"), name="pages")
|
|
app.mount("/components", StaticFiles(directory="static/components"), name="components")
|
|
|
|
# HTTP 클라이언트 세션 (전역)
|
|
http_session: aiohttp.ClientSession = None
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
"""앱 시작시 초기화"""
|
|
global http_session
|
|
|
|
# 캐시 매니저 연결
|
|
await cache_manager.connect()
|
|
|
|
# HTTP 클라이언트 세션 생성
|
|
connector = aiohttp.TCPConnector(
|
|
limit=settings.MAX_CONNECTIONS,
|
|
ttl_dns_cache=300,
|
|
use_dns_cache=True,
|
|
)
|
|
|
|
timeout = aiohttp.ClientTimeout(total=settings.PROXY_TIMEOUT)
|
|
http_session = aiohttp.ClientSession(
|
|
connector=connector,
|
|
timeout=timeout
|
|
)
|
|
|
|
logger.info(f"🚀 {settings.PROJECT_NAME} v{settings.VERSION} 시작됨")
|
|
logger.info(f"📍 포트: {settings.FASTAPI_PORT}")
|
|
logger.info(f"🔗 Express.js API: {settings.EXPRESS_API_URL}")
|
|
|
|
# 캐시 상태 로깅
|
|
cache_stats = cache_manager.get_stats()
|
|
logger.info(f"💾 캐시: {cache_stats['cache_type']} ({'연결됨' if cache_stats['redis_available'] else '메모리 모드'})")
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown_event():
|
|
"""앱 종료시 정리"""
|
|
global http_session
|
|
|
|
# 캐시 매니저 연결 종료
|
|
await cache_manager.disconnect()
|
|
|
|
if http_session:
|
|
await http_session.close()
|
|
|
|
logger.info("✅ FastAPI 브릿지가 정상적으로 종료되었습니다")
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
"""루트 경로 - index.html 서빙"""
|
|
return FileResponse("static/index.html")
|
|
|
|
@app.get("/status")
|
|
async def status():
|
|
"""서비스 상태 정보"""
|
|
return {
|
|
"service": settings.PROJECT_NAME,
|
|
"version": settings.VERSION,
|
|
"status": "healthy",
|
|
"express_api": settings.EXPRESS_API_URL
|
|
}
|
|
|
|
@app.get("/health")
|
|
@cached(prefix="health", ttl=settings.CACHE_HEALTH_TTL)
|
|
async def health_check():
|
|
"""헬스체크 엔드포인트 (캐싱 적용)"""
|
|
try:
|
|
# Express.js API 헬스체크
|
|
async with http_session.get(f"{settings.EXPRESS_API_URL}/api/health") as response:
|
|
express_status = "healthy" if response.status == 200 else "unhealthy"
|
|
express_data = await response.json() if response.status == 200 else None
|
|
except Exception as e:
|
|
logger.error(f"Express.js 헬스체크 실패: {e}")
|
|
express_status = "unhealthy"
|
|
express_data = None
|
|
|
|
import datetime
|
|
return {
|
|
"fastapi_bridge": "healthy",
|
|
"express_api": express_status,
|
|
"express_data": express_data,
|
|
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"cached": True
|
|
}
|
|
|
|
@app.get("/cache/stats")
|
|
async def cache_stats():
|
|
"""캐시 통계 조회"""
|
|
stats = cache_manager.get_stats()
|
|
return {
|
|
"cache_stats": stats,
|
|
"settings": {
|
|
"default_ttl": settings.CACHE_DEFAULT_TTL,
|
|
"health_ttl": settings.CACHE_HEALTH_TTL,
|
|
"auth_ttl": settings.CACHE_AUTH_TTL,
|
|
"api_ttl": settings.CACHE_API_TTL,
|
|
"static_ttl": settings.CACHE_STATIC_TTL
|
|
}
|
|
}
|
|
|
|
@app.delete("/cache/clear")
|
|
async def clear_cache():
|
|
"""캐시 전체 삭제"""
|
|
cleared_count = await cache_manager.clear_pattern("*")
|
|
return {
|
|
"message": "캐시가 삭제되었습니다",
|
|
"cleared_keys": cleared_count
|
|
}
|
|
|
|
# ==========================================
|
|
# Phase 4: 분석 및 모니터링 API
|
|
# ==========================================
|
|
|
|
@app.get("/analytics/summary")
|
|
async def analytics_summary():
|
|
"""분석 요약 통계"""
|
|
return {
|
|
"analytics": analytics_manager.get_summary_stats(),
|
|
"cache": cache_manager.get_stats(),
|
|
"prediction": analytics_manager.predict_load()
|
|
}
|
|
|
|
@app.get("/analytics/endpoints")
|
|
async def analytics_endpoints(limit: int = 20):
|
|
"""엔드포인트별 성능 통계"""
|
|
return {
|
|
"top_endpoints": analytics_manager.get_top_endpoints(limit),
|
|
"total_tracked": len(analytics_manager.endpoint_stats)
|
|
}
|
|
|
|
@app.get("/analytics/trends")
|
|
async def analytics_trends(hours: int = 24):
|
|
"""시간대별 트렌드 분석"""
|
|
return {
|
|
"trends": analytics_manager.get_hourly_trends(hours),
|
|
"period_hours": hours
|
|
}
|
|
|
|
@app.get("/analytics/clients")
|
|
async def analytics_clients():
|
|
"""클라이언트 분석"""
|
|
return analytics_manager.get_client_analysis()
|
|
|
|
@app.get("/analytics/dashboard")
|
|
async def analytics_dashboard():
|
|
"""종합 대시보드 데이터"""
|
|
return {
|
|
"summary": analytics_manager.get_summary_stats(),
|
|
"top_endpoints": analytics_manager.get_top_endpoints(10),
|
|
"trends_6h": analytics_manager.get_hourly_trends(6),
|
|
"clients": analytics_manager.get_client_analysis(),
|
|
"cache_stats": cache_manager.get_stats(),
|
|
"prediction": analytics_manager.predict_load(),
|
|
"server_info": {
|
|
"version": settings.VERSION,
|
|
"environment": settings.NODE_ENV,
|
|
"redis_available": cache_manager.is_redis_available
|
|
}
|
|
}
|
|
|
|
def _verify_proxy_token(request: Request) -> dict:
|
|
"""프록시 요청의 JWT 토큰을 검증하여 사용자 정보 반환"""
|
|
auth_header = request.headers.get("authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
raise HTTPException(status_code=401, detail="Missing or invalid authorization")
|
|
token = auth_header.split(" ", 1)[1]
|
|
if not settings.JWT_SECRET:
|
|
logger.warning("JWT_SECRET이 설정되지 않아 토큰 검증을 건너뜁니다")
|
|
return {}
|
|
try:
|
|
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
|
return payload
|
|
except pyjwt.InvalidTokenError:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
async def proxy_to_express(path: str, request: Request) -> Dict[str, Any]:
|
|
"""Express.js API로 모든 요청을 프록시 (GET 요청은 캐싱 적용)"""
|
|
|
|
# JWT 검증 (defense in depth — Express 백엔드도 자체 검증함)
|
|
user_payload = _verify_proxy_token(request)
|
|
user_id = user_payload.get("user_id", user_payload.get("id", "anon"))
|
|
|
|
# Express.js API URL 구성
|
|
target_url = f"{settings.EXPRESS_API_URL}/api/{path}"
|
|
|
|
# 요청 데이터 준비
|
|
headers = dict(request.headers)
|
|
headers.pop("host", None) # host 헤더 제거
|
|
|
|
params = dict(request.query_params)
|
|
|
|
# GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
|
|
if request.method == "GET":
|
|
cache_key = cache_manager._generate_key("api", path, _uid=str(user_id), **params)
|
|
cached_result = await cache_manager.get(cache_key)
|
|
|
|
if cached_result is not None:
|
|
logger.info(f"🟢 캐시 히트: GET {target_url}")
|
|
return JSONResponse(
|
|
content=cached_result,
|
|
status_code=200
|
|
)
|
|
|
|
try:
|
|
# 요청 바디 읽기 (있는 경우)
|
|
body = None
|
|
if request.method in ["POST", "PUT", "PATCH"]:
|
|
body = await request.body()
|
|
if body:
|
|
body = body.decode("utf-8") if isinstance(body, bytes) else body
|
|
|
|
logger.info(f"🔗 프록시: {request.method} {target_url}")
|
|
|
|
# Express.js API로 요청 전달
|
|
async with http_session.request(
|
|
method=request.method,
|
|
url=target_url,
|
|
headers=headers,
|
|
params=params,
|
|
data=body
|
|
) as response:
|
|
|
|
# 응답 데이터 읽기
|
|
response_data = await response.text()
|
|
|
|
# JSON으로 파싱 시도
|
|
try:
|
|
response_json = await response.json()
|
|
except:
|
|
response_json = {"data": response_data}
|
|
|
|
logger.info(f"✅ 응답: {response.status} ({len(response_data)} bytes)")
|
|
|
|
# GET 요청 성공시 캐시에 저장
|
|
if request.method == "GET" and response.status == 200:
|
|
await cache_manager.set(cache_key, response_json, settings.CACHE_API_TTL)
|
|
logger.info(f"💾 캐시 저장: GET {target_url}")
|
|
|
|
return JSONResponse(
|
|
content=response_json,
|
|
status_code=response.status,
|
|
headers=dict(response.headers)
|
|
)
|
|
|
|
except aiohttp.ClientTimeout:
|
|
logger.error(f"⏰ 타임아웃: {target_url}")
|
|
raise HTTPException(status_code=504, detail="Gateway Timeout")
|
|
|
|
except aiohttp.ClientError as e:
|
|
logger.error(f"🚫 클라이언트 오류: {e}")
|
|
raise HTTPException(status_code=502, detail="Bad Gateway")
|
|
|
|
except Exception as e:
|
|
logger.error(f"💥 예상치 못한 오류: {e}")
|
|
raise HTTPException(status_code=500, detail="Internal Server Error")
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(
|
|
"main:app",
|
|
host="0.0.0.0",
|
|
port=settings.FASTAPI_PORT,
|
|
reload=settings.NODE_ENV == "development",
|
|
log_level=settings.LOG_LEVEL.lower()
|
|
) |