fix(security): CRITICAL 보안 이슈 13건 일괄 수정

- 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>
This commit is contained in:
Hyungi Ahn
2026-04-01 10:48:58 +09:00
parent 766cb90e8f
commit f09c86ee01
24 changed files with 215 additions and 305 deletions

View File

@@ -3,7 +3,9 @@ WORKDIR /app
RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --chown=appuser:appuser . .
RUN mkdir -p /app/data && chown appuser:appuser /app/data
EXPOSE 8000
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -2,6 +2,13 @@ import json
from services.ollama_client import ollama_client
def sanitize_user_input(text: str, max_length: int = 500) -> str:
"""사용자 입력 길이 제한 및 정리"""
if not text:
return ""
return str(text)[:max_length].strip()
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
@@ -35,10 +42,12 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
safe_text = sanitize_user_input(user_text)
prompt = f"""카테고리 목록:
{category_context}
사용자 입력: "{user_text}"
사용자 입력:
<user_input>{safe_text}</user_input>
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
@@ -71,12 +80,14 @@ async def analyze_user_input(user_text: str, categories: dict) -> dict:
async def summarize_report(data: dict) -> dict:
"""최종 신고 내용을 요약"""
prompt = f"""신고 정보:
- 설명: {data.get('description', '')}
- 유형: {data.get('type', '')}
- 카테고리: {data.get('category', '')}
- 항목: {data.get('item', '')}
- 위치: {data.get('location', '')}
- 프로젝트: {data.get('project', '')}
<user_input>
- 설명: {sanitize_user_input(data.get('description', ''))}
- 유형: {sanitize_user_input(data.get('type', ''))}
- 카테고리: {sanitize_user_input(data.get('category', ''))}
- 항목: {sanitize_user_input(data.get('item', ''))}
- 위치: {sanitize_user_input(data.get('location', ''))}
- 프로젝트: {sanitize_user_input(data.get('project', ''))}
</user_input>
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""

View File

@@ -19,7 +19,7 @@ services:
- mariadb_data:/var/lib/mysql
- ./scripts/migrate-users.sql:/docker-entrypoint-initdb.d/99-sso-users.sql
ports:
- "30306:3306"
- "127.0.0.1:30306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
timeout: 20s
@@ -608,7 +608,7 @@ services:
container_name: tk-phpmyadmin
restart: unless-stopped
ports:
- "30880:80"
- "127.0.0.1:30880:80"
environment:
- PMA_HOST=mariadb
- PMA_USER=${PMA_USER:-root}

View File

@@ -1,208 +0,0 @@
#!/bin/bash
# ===================================================================
# TK Factory Services - 원격 배포 스크립트 (맥북에서 실행)
# ===================================================================
# 사용법: ./scripts/deploy-remote.sh
# 설정: ~/.tk-deploy-config
# ===================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="$HOME/.tk-deploy-config"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# === 설정 로드 ===
if [ ! -f "$CONFIG_FILE" ]; then
echo -e "${RED}ERROR: 설정 파일이 없습니다: $CONFIG_FILE${NC}"
cat <<'EXAMPLE'
다음 내용으로 생성하세요:
NAS_HOST=100.71.132.52
NAS_USER=hyungi
NAS_DEPLOY_PATH=/volume1/docker_1/tk-factory-services
NAS_SUDO_PASS=<sudo 비밀번호>
EXAMPLE
exit 1
fi
source "$CONFIG_FILE"
for var in NAS_HOST NAS_USER NAS_DEPLOY_PATH NAS_SUDO_PASS; do
if [ -z "${!var}" ]; then
echo -e "${RED}ERROR: $CONFIG_FILE에 $var 가 설정되지 않았습니다${NC}"
exit 1
fi
done
DOCKER="/usr/local/bin/docker"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
# === 헬퍼 함수 ===
ssh_cmd() {
ssh -o ConnectTimeout=10 "${NAS_USER}@${NAS_HOST}" "$@"
}
nas_docker() {
ssh_cmd "cd ${NAS_DEPLOY_PATH} && echo '${NAS_SUDO_PASS}' | sudo -S ${DOCKER} $*" 2>&1
}
# === Phase 1: Pre-flight 체크 ===
echo "=== TK Factory Services - 원격 배포 ==="
echo ""
echo -e "${CYAN}[1/5] Pre-flight 체크${NC}"
cd "$PROJECT_DIR"
# Working tree clean 확인
if [ -n "$(git status --porcelain)" ]; then
echo -e "${RED}ERROR: 로컬에 커밋되지 않은 변경사항이 있습니다${NC}"
echo ""
git status --short
echo ""
echo "먼저 커밋하거나 stash하세요."
exit 1
fi
# 로컬 커밋 정보
LOCAL_HASH=$(git rev-parse HEAD)
LOCAL_SHORT=$(git rev-parse --short HEAD)
LOCAL_MSG=$(git log -1 --format='%s')
LOCAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
# origin 동기화 확인
git fetch origin --quiet
ORIGIN_HASH=$(git rev-parse "origin/${LOCAL_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_HASH" != "$ORIGIN_HASH" ]; then
echo -e "${RED}ERROR: 로컬 커밋이 origin에 push되지 않았습니다${NC}"
echo " 로컬: ${LOCAL_SHORT} (${LOCAL_MSG})"
echo " 원격: $(git rev-parse --short "origin/${LOCAL_BRANCH}" 2>/dev/null || echo 'N/A')"
echo ""
echo "먼저 push하세요: git push origin ${LOCAL_BRANCH}"
exit 1
fi
echo -e " 로컬 HEAD: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
echo -e " 브랜치: ${LOCAL_BRANCH}"
# === Phase 2: NAS 상태 비교 ===
echo ""
echo -e "${CYAN}[2/5] NAS 배포 상태 확인${NC}"
NAS_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H'" 2>/dev/null || echo "")
if [ -z "$NAS_HASH" ]; then
echo -e "${RED}ERROR: NAS에서 git 정보를 가져올 수 없습니다${NC}"
echo " 경로: ${NAS_DEPLOY_PATH}"
echo " NAS에 git clone이 완료되었는지 확인하세요."
exit 1
fi
NAS_SHORT="${NAS_HASH:0:7}"
NAS_MSG=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%s'" 2>/dev/null)
if [ "$LOCAL_HASH" = "$NAS_HASH" ]; then
echo -e " ${GREEN}이미 최신 버전입니다!${NC} (${NAS_SHORT} - ${NAS_MSG})"
exit 0
fi
echo -e " NAS 현재: ${YELLOW}${NAS_SHORT}${NC} - ${NAS_MSG}"
echo -e " 배포 대상: ${GREEN}${LOCAL_SHORT}${NC} - ${LOCAL_MSG}"
# 배포될 커밋 목록
COMMIT_COUNT=$(git log "${NAS_HASH}..${LOCAL_HASH}" --oneline | wc -l | tr -d ' ')
echo ""
echo "=== 배포될 커밋 (${COMMIT_COUNT}개) ==="
git log "${NAS_HASH}..${LOCAL_HASH}" --oneline --no-decorate
echo ""
read -p "배포를 진행하시겠습니까? [y/N] " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "배포가 취소되었습니다."
exit 0
fi
# === Phase 3: 배포 실행 ===
echo ""
echo -e "${CYAN}[3/5] NAS 코드 업데이트${NC}"
ssh_cmd "cd ${NAS_DEPLOY_PATH} && git fetch origin && git reset --hard origin/${DEPLOY_BRANCH}"
UPDATED_HASH=$(ssh_cmd "cd ${NAS_DEPLOY_PATH} && git log -1 --format='%H %s'" 2>/dev/null)
echo -e " ${GREEN}완료${NC}: ${UPDATED_HASH}"
echo ""
echo -e "${CYAN}[4/5] Docker 컨테이너 빌드 및 재시작${NC}"
echo " (빌드에 시간이 걸릴 수 있습니다...)"
echo ""
nas_docker "compose up -d --build"
echo ""
echo " nginx 프록시 컨테이너 재시작 (IP 캐시 갱신)..."
nas_docker "restart tk-gateway tk-system2-web tk-system3-web"
# === Phase 4: 배포 후 검증 ===
echo ""
echo -e "${CYAN}[5/5] 배포 검증${NC} (15초 대기 후 health check)"
sleep 15
echo ""
echo "=== Container Status ==="
nas_docker "compose ps --format 'table {{.Name}}\t{{.Status}}'" || true
echo ""
echo "=== HTTP Health Check ==="
HEALTH_PASS=0
HEALTH_FAIL=0
check_remote() {
local name="$1"
local path="$2"
local status
status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 http://localhost:${path}" 2>/dev/null || echo "000")
if [ "$status" -ge 200 ] 2>/dev/null && [ "$status" -lt 400 ] 2>/dev/null; then
printf " %-25s ${GREEN}OK${NC} (%s)\n" "$name" "$status"
((HEALTH_PASS++))
else
printf " %-25s ${RED}FAIL${NC} (%s)\n" "$name" "$status"
((HEALTH_FAIL++))
fi
}
check_remote "Gateway" "30000/"
check_remote "SSO Auth" "30050/health"
check_remote "System 1 API" "30005/api/health"
check_remote "System 1 Web" "30080/"
check_remote "System 1 FastAPI" "30008/health"
check_remote "System 2 API" "30105/api/health"
check_remote "System 2 Web" "30180/"
check_remote "System 3 API" "30200/api/health"
check_remote "System 3 Web" "30280/"
check_remote "tkuser API" "30300/api/health"
check_remote "tkuser Web" "30380/"
check_remote "phpMyAdmin" "30880/"
echo ""
echo " Health: PASS=${HEALTH_PASS} FAIL=${HEALTH_FAIL}"
# === Phase 5: 배포 로그 기록 ===
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
ssh_cmd "echo '${TIMESTAMP} | ${LOCAL_SHORT} | ${LOCAL_MSG}' >> ${NAS_DEPLOY_PATH}/DEPLOY_LOG"
echo ""
if [ "$HEALTH_FAIL" -gt 0 ]; then
echo -e "${YELLOW}배포 완료 (일부 서비스 health check 실패)${NC}"
echo " 로그 확인: ssh ${NAS_USER}@${NAS_HOST} \"cd ${NAS_DEPLOY_PATH} && echo '...' | sudo -S ${DOCKER} compose logs --tail=50\""
else
echo -e "${GREEN}배포 완료!${NC}"
fi
echo " 버전: ${LOCAL_SHORT} - ${LOCAL_MSG}"

View File

@@ -83,11 +83,11 @@ async function login(req, res, next) {
await userModel.updateLastLogin(user.user_id);
const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
const refresh_token = jwt.sign(
{ user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
);
// SSO 쿠키는 클라이언트(login.html)에서 domain=.technicalkorea.net으로 설정
@@ -159,7 +159,7 @@ async function loginForm(req, res, next) {
await userModel.updateLastLogin(user.user_id);
const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
res.json({
access_token,
@@ -187,7 +187,8 @@ async function validate(req, res, next) {
return res.status(401).json({ success: false, error: '토큰이 필요합니다' });
}
const decoded = jwt.verify(token, JWT_SECRET);
// TODO: issuer/audience 클레임 검증 추가 검토
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
const user = await userModel.findById(decoded.user_id || decoded.id);
if (!user || !user.is_active) {
return res.status(401).json({ success: false, error: '유효하지 않은 사용자입니다' });
@@ -229,7 +230,7 @@ async function me(req, res, next) {
return res.status(401).json({ detail: 'Not authenticated' });
}
const decoded = jwt.verify(token, JWT_SECRET);
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
const user = await userModel.findById(decoded.user_id || decoded.id);
if (!user || !user.is_active) {
return res.status(401).json({ detail: 'User not found or inactive' });
@@ -261,7 +262,7 @@ async function refresh(req, res, next) {
return res.status(400).json({ success: false, error: 'Refresh 토큰이 필요합니다' });
}
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET);
const decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
if (decoded.type !== 'refresh') {
return res.status(401).json({ success: false, error: '유효하지 않은 Refresh 토큰입니다' });
}
@@ -272,11 +273,11 @@ async function refresh(req, res, next) {
}
const payload = createTokenPayload(user);
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const access_token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' });
const new_refresh_token = jwt.sign(
{ user_id: user.user_id, type: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
{ expiresIn: JWT_REFRESH_EXPIRES_IN, algorithm: 'HS256' }
);
res.json({

View File

@@ -163,7 +163,7 @@ const ProxyInputController = {
} catch (err) {
try { await conn.rollback(); } catch (e) {}
logger.error('대리입력 오류:', err);
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.', error: err.message });
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.' });
} finally {
conn.release();
}
@@ -182,7 +182,7 @@ const ProxyInputController = {
res.json({ success: true, data });
} catch (err) {
logger.error('일별 현황 조회 오류:', err);
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.', error: err.message });
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
}
},
@@ -202,7 +202,7 @@ const ProxyInputController = {
res.json({ success: true, data });
} catch (err) {
logger.error('일별 상세 조회 오류:', err);
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.', error: err.message });
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
}
}
};

View File

@@ -88,7 +88,7 @@ const MonthlyComparisonModel = {
// 기존 상태 체크 + 전환 검증
const [existing] = await conn.query(
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?',
'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ? FOR UPDATE',
[data.user_id, data.year, data.month]
);
const currentStatus = existing.length > 0 ? existing[0].status : null;

View File

@@ -24,7 +24,7 @@ router.get('/pages', requireAuth, async (req, res) => {
res.json({ success: true, data: pages });
} catch (error) {
console.error('페이지 목록 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 목록을 불러오는데 실패했습니다.' });
res.status(500).json({ success: false, message: '페이지 목록을 불러오는데 실패했습니다.' });
}
});
@@ -43,7 +43,7 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
`, [userId]);
if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
}
const user = userRows[0];
@@ -99,7 +99,7 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
res.json({ success: true, data: { user, pageAccess } });
} catch (error) {
console.error('페이지 접근 권한 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });
res.status(500).json({ success: false, message: '페이지 접근 권한을 불러오는데 실패했습니다.' });
}
});
@@ -111,7 +111,7 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
try {
if (!isAdminRole(req.user.role)) {
return res.status(403).json({ success: false, error: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
return res.status(403).json({ success: false, message: '권한이 없습니다. Admin 계정만 사용자 권한을 관리할 수 있습니다.' });
}
const { userId } = req.params;
@@ -123,7 +123,7 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
// 사용자 존재 확인
const [userRows] = await db.query('SELECT user_id FROM sso_users WHERE user_id = ?', [userId]);
if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다.' });
}
// 페이지 접근 권한 업데이트
@@ -138,7 +138,7 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
res.json({ success: true, message: '페이지 접근 권한이 업데이트되었습니다.' });
} catch (error) {
console.error('페이지 접근 권한 부여 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
res.status(500).json({ success: false, message: '페이지 접근 권한을 업데이트하는데 실패했습니다.' });
}
});
@@ -149,7 +149,7 @@ router.post('/users/:userId/page-access', requireAuth, async (req, res) => {
router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res) => {
try {
if (!isAdminRole(req.user.role)) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
}
const { userId, pageId } = req.params;
@@ -163,7 +163,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
res.json({ success: true, message: '페이지 접근 권한이 회수되었습니다.' });
} catch (error) {
console.error('페이지 접근 권한 회수 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 회수하는데 실패했습니다.' });
res.status(500).json({ success: false, message: '페이지 접근 권한을 회수하는데 실패했습니다.' });
}
});
@@ -174,7 +174,7 @@ router.delete('/users/:userId/page-access/:pageId', requireAuth, async (req, res
router.get('/page-access/summary', requireAuth, async (req, res) => {
try {
if (!isAdminRole(req.user.role)) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
}
const db = await getDb();
@@ -195,7 +195,7 @@ router.get('/page-access/summary', requireAuth, async (req, res) => {
res.json({ success: true, data: summary });
} catch (error) {
console.error('페이지 접근 권한 요약 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
res.status(500).json({ success: false, message: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' });
}
});

View File

@@ -11,11 +11,15 @@ RUN apt-get update && apt-get install -y \
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# non-root user 생성
RUN groupadd -r appuser && useradd -r -g appuser appuser
# 애플리케이션 코드 복사
COPY . .
COPY --chown=appuser:appuser . .
# 포트 노출
EXPOSE 8000
# 애플리케이션 실행
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -8,6 +8,7 @@ class Settings:
# 기본 설정
FASTAPI_PORT: int = int(os.getenv("FASTAPI_PORT", "8000"))
EXPRESS_API_URL: str = os.getenv("EXPRESS_API_URL", "http://system1-api:3005")
JWT_SECRET: str = os.getenv("JWT_SECRET", "")
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
NODE_ENV: str = os.getenv("NODE_ENV", "development")

View File

@@ -7,6 +7,7 @@ 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
@@ -206,22 +207,41 @@ async def analytics_dashboard():
}
}
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 요청에 대해서만 캐싱 적용
# GET 요청에 대해서만 캐싱 적용 (user_id 포함하여 사용자 간 캐시 격리)
if request.method == "GET":
cache_key = cache_manager._generate_key("api", path, **params)
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:

View File

@@ -3,4 +3,5 @@ uvicorn[standard]==0.24.0
aiohttp==3.9.1
python-multipart==0.0.6
redis==5.0.1
python-dotenv==1.0.0
python-dotenv==1.0.0
PyJWT==2.8.0

View File

@@ -395,4 +395,6 @@ async function submitReject() {
function fmtNum(v) { var n = parseFloat(v) || 0; return n % 1 === 0 ? n.toString() : n.toFixed(1); }
function escHtml(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeRejectModal(); });
function handleEscKey(e) { if (e.key === 'Escape') closeRejectModal(); }
document.addEventListener('keydown', handleEscKey);
window.addEventListener('beforeunload', function() { document.removeEventListener('keydown', handleEscKey); });

View File

@@ -306,7 +306,7 @@
var skipSelected = W.projectId === null ? ' selected' : '';
var projectItems = projects.map(function(p) {
var selected = W.projectId === p.project_id ? ' selected' : '';
return '<div class="list-item' + selected + '" onclick="selectProject(' + p.project_id + ', \'' + esc(p.project_name).replace(/'/g, "\\'") + '\')">' +
return '<div class="list-item' + selected + '" data-action="selectProject" data-project-id="' + p.project_id + '" data-project-name="' + esc(p.project_name) + '">' +
'<div class="item-title">' + esc(p.project_name) + '</div>' +
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
'</div>';
@@ -315,7 +315,7 @@
// 공정 pill 버튼
var pillHtml = workTypes.map(function(wt) {
var selected = W.workTypeId === wt.id ? ' selected' : '';
return '<button type="button" class="pill-btn' + selected + '" onclick="selectWorkType(' + wt.id + ', \'' + esc(wt.name).replace(/'/g, "\\'") + '\')">' + esc(wt.name) + '</button>';
return '<button type="button" class="pill-btn' + selected + '" data-action="selectWorkType" data-wt-id="' + wt.id + '" data-wt-name="' + esc(wt.name) + '">' + esc(wt.name) + '</button>';
}).join('');
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
@@ -335,7 +335,7 @@
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
'<div class="list-item-skip' + skipSelected + '" onclick="selectProject(null, \'\')">' +
'<div class="list-item-skip' + skipSelected + '" data-action="selectProject" data-project-id="" data-project-name="">' +
'선택 안함' +
'</div>' +
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
@@ -357,6 +357,19 @@
};
}
}
// Event delegation for project/workType selection
container.onclick = function(e) {
var el = e.target.closest('[data-action]');
if (!el) return;
var action = el.getAttribute('data-action');
if (action === 'selectProject') {
var pid = el.getAttribute('data-project-id');
selectProject(pid ? parseInt(pid) : null, el.getAttribute('data-project-name') || '');
} else if (action === 'selectWorkType') {
selectWorkType(parseInt(el.getAttribute('data-wt-id')), el.getAttribute('data-wt-name') || '');
}
};
}
window.selectProject = function(projectId, projectName) {

View File

@@ -844,14 +844,14 @@
<!-- Scripts -->
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<!-- 공통 모듈 -->
<script src="/js/common/utils.js?v=2026031401"></script>
<script src="/js/common/base-state.js?v=2026031401"></script>
<script src="/js/tbm/state.js?v=2026031401"></script>
<script src="/js/tbm/utils.js?v=2026031401"></script>
<script src="/js/tbm/api.js?v=2026031401"></script>
<script src="/js/tbm-create.js?v=2026031401"></script>
<script src="/js/common/utils.js?v=2026040101"></script>
<script src="/js/common/base-state.js?v=2026040101"></script>
<script src="/js/tbm/state.js?v=2026040101"></script>
<script src="/js/tbm/utils.js?v=2026040101"></script>
<script src="/js/tbm/api.js?v=2026040101"></script>
<script src="/js/tbm-create.js?v=2026040101"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -7,7 +7,7 @@
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026033108">
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026031401">
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026040101">
</head>
<body class="bg-gray-50">
@@ -265,13 +265,13 @@
<!-- 공통 모듈 -->
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="/js/common/utils.js?v=2026031401"></script>
<script src="/js/common/base-state.js?v=2026031401"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<script src="/js/common/utils.js?v=2026040101"></script>
<script src="/js/common/base-state.js?v=2026040101"></script>
<script src="/js/tbm/state.js?v=2026031401"></script>
<script src="/js/tbm/utils.js?v=2026031401"></script>
<script src="/js/tbm/api.js?v=2026031401"></script>
<script src="/js/tbm/state.js?v=2026040101"></script>
<script src="/js/tbm/utils.js?v=2026040101"></script>
<script src="/js/tbm/api.js?v=2026040101"></script>
<script src="/js/tbm-mobile.js?v=2026033102"></script>
<script>initAuth();</script>
</body>

View File

@@ -64,6 +64,7 @@ function showToast(msg, type = 'success') {
/* ===== Escape ===== */
function escapeHtml(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
const escHtml = escapeHtml;
/* ===== Helpers ===== */
function formatDate(d) { if (!d) return ''; return String(d).substring(0, 10); }

View File

@@ -11,14 +11,18 @@ RUN apt-get update && apt-get install -y \
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# non-root user 생성
RUN groupadd -r appuser && useradd -r -g appuser appuser
# 애플리케이션 파일 복사
COPY . .
COPY --chown=appuser:appuser . .
# uploads 디렉토리 생성
RUN mkdir -p /app/uploads
RUN mkdir -p /app/uploads && chown appuser:appuser /app/uploads
# 포트 노출
EXPOSE 8000
# 실행 명령
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -9,10 +9,12 @@ RUN apt-get update && apt-get install -y \
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --chown=appuser:appuser . .
ENV PYTHONPATH=/app
EXPOSE 8000
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -164,11 +164,11 @@ ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
# API 정보는 /info 엔드포인트로 이동됨
@router.get("/test")
async def test_endpoint():
async def test_endpoint(current_user: dict = Depends(get_current_user)):
return {"status": "파일 API가 정상 작동합니다!"}
@router.post("/add-missing-columns")
async def add_missing_columns(db: Session = Depends(get_db)):
async def add_missing_columns(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
"""누락된 컬럼들 추가"""
try:
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
@@ -602,7 +602,8 @@ async def upload_file(
@router.get("/")
async def get_files(
job_no: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""파일 목록 조회"""
try:
@@ -646,7 +647,8 @@ async def get_files(
@router.get("/list")
async def get_files_list(
job_no: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""파일 목록 조회 (리비전 모드 확인용)"""
try:
@@ -696,7 +698,8 @@ async def get_files_list(
@router.get("/project/{project_code}")
async def get_files_by_project(
project_code: str,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""프로젝트별 파일 목록 조회"""
try:
@@ -733,7 +736,7 @@ async def get_files_by_project(
raise HTTPException(status_code=500, detail=f"프로젝트 파일 조회 실패: {str(e)}")
@router.get("/stats")
async def get_files_stats(db: Session = Depends(get_db)):
async def get_files_stats(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
"""파일 및 자재 통계 조회"""
try:
# 총 파일 수
@@ -774,7 +777,7 @@ async def get_files_stats(db: Session = Depends(get_db)):
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}")
@router.delete("/delete/{file_id}")
async def delete_file(file_id: int, db: Session = Depends(get_db)):
async def delete_file(file_id: int, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
"""파일 삭제"""
try:
# 자재 먼저 삭제
@@ -814,7 +817,8 @@ async def get_materials(
sort_by: Optional[str] = None,
exclude_requested: bool = True, # 구매신청된 자재 제외 여부
group_by_spec: bool = False, # 같은 사양끼리 그룹화
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능) - 신버전
@@ -1511,7 +1515,8 @@ async def get_materials(
async def get_materials_summary(
project_id: Optional[int] = None,
file_id: Optional[int] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""자재 요약 통계"""
try:
@@ -1566,7 +1571,8 @@ async def compare_revisions(
filename: str,
old_revision: str,
new_revision: str,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
리비전 간 자재 비교
@@ -1846,7 +1852,8 @@ async def compare_revisions(
async def get_pipe_details(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
PIPE 상세 정보 조회
@@ -1905,7 +1912,8 @@ async def get_pipe_details(
async def get_fitting_details(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
FITTING 상세 정보 조회
@@ -1960,7 +1968,8 @@ async def get_fitting_details(
async def get_valve_details(
file_id: Optional[int] = None,
job_no: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
VALVE 상세 정보 조회
@@ -2016,7 +2025,8 @@ async def get_user_requirements(
file_id: int,
job_no: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
사용자 요구사항 조회
@@ -2090,7 +2100,8 @@ class UserRequirementCreate(BaseModel):
@router.post("/user-requirements")
async def create_user_requirement(
requirement: UserRequirementCreate,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
사용자 요구사항 생성
@@ -2137,7 +2148,8 @@ async def create_user_requirement(
async def delete_user_requirements(
file_id: Optional[int] = None,
material_id: Optional[int] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
사용자 요구사항 삭제 (파일별 또는 자재별)
@@ -2622,7 +2634,8 @@ async def process_missing_drawings(
file_id: int,
action: str = "delete",
drawings: List[str] = [],
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
누락된 도면 처리

View File

@@ -15,6 +15,7 @@ from typing import List, Optional, Dict
from datetime import datetime
from ..database import get_db
from ..auth.middleware import get_current_user
logger = logging.getLogger(__name__)
@@ -26,7 +27,8 @@ async def compare_material_revisions(
current_revision: str,
previous_revision: Optional[str] = None,
save_result: bool = True,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
리비전간 자재 비교 및 추가 발주 필요량 계산
@@ -82,7 +84,8 @@ async def compare_material_revisions(
async def get_comparison_history(
job_no: str = Query(..., description="Job 번호"),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
자재 비교 이력 조회
@@ -127,7 +130,8 @@ async def get_comparison_history(
async def get_material_inventory_status(
job_no: str = Query(..., description="Job 번호"),
material_hash: Optional[str] = Query(None, description="특정 자재 해시"),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
자재별 누적 재고 현황 조회
@@ -148,8 +152,8 @@ async def confirm_material_purchase(
job_no: str,
revision: str,
confirmations: List[Dict],
confirmed_by: str = "system",
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
자재 발주 확정 처리
@@ -162,6 +166,7 @@ async def confirm_material_purchase(
}
]
"""
confirmed_by = current_user.get('username', current_user.get('name', 'unknown'))
try:
# 입력 데이터 검증
if not job_no or not revision:
@@ -265,7 +270,8 @@ async def get_purchase_status(
job_no: str = Query(..., description="Job 번호"),
revision: Optional[str] = Query(None, description="리비전 (전체 조회시 생략)"),
status: Optional[str] = Query(None, description="발주 상태 필터"),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
발주 상태 조회

View File

@@ -128,6 +128,8 @@ const vacationController = {
},
async cancelRequest(req, res) {
const db = getPool();
const conn = await db.getConnection();
try {
const { id } = req.params;
const results = await vacationRequestModel.getById(id);
@@ -145,11 +147,13 @@ const vacationController = {
return res.status(400).json({ success: false, error: '이미 취소된 신청입니다' });
}
await conn.beginTransaction();
// 승인된 건 취소 시 잔여일 복구
if (existing.status === 'approved') {
const year = new Date(existing.start_date).getFullYear();
await vacationBalanceModel.restoreDays(
existing.user_id, existing.vacation_type_id, year, parseFloat(existing.days_used)
existing.user_id, existing.vacation_type_id, year, parseFloat(existing.days_used), conn
);
}
@@ -157,11 +161,16 @@ const vacationController = {
status: 'cancelled',
reviewed_by: userId,
review_note: '취소됨'
});
}, conn);
await conn.commit();
res.json({ success: true, message: '휴가 신청이 취소되었습니다' });
} catch (error) {
await conn.rollback();
console.error('휴가 취소 오류:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
} finally {
conn.release();
}
},
@@ -178,6 +187,8 @@ const vacationController = {
},
async approveRequest(req, res) {
const db = getPool();
const conn = await db.getConnection();
try {
const { id } = req.params;
const { review_note } = req.body;
@@ -192,18 +203,22 @@ const vacationController = {
}
const request = results[0];
// 잔여일 차감
const year = new Date(request.start_date).getFullYear();
await vacationBalanceModel.deductDays(
request.user_id, request.vacation_type_id, year, parseFloat(request.days_used)
);
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note });
await conn.beginTransaction();
await vacationBalanceModel.deductDays(
request.user_id, request.vacation_type_id, year, parseFloat(request.days_used), conn
);
await vacationRequestModel.updateStatus(id, { status: 'approved', reviewed_by, review_note }, conn);
await conn.commit();
res.json({ success: true, message: '휴가 신청이 승인되었습니다' });
} catch (error) {
await conn.rollback();
console.error('휴가 승인 오류:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
} finally {
conn.release();
}
},

View File

@@ -92,14 +92,15 @@ const vacationBalanceModel = {
// 2단계: 남은 차감분을 우선순위 순서로 (이미 차감한 행 제외)
if (remaining > 0) {
const deductedIds = exactMatch.map(b => b.id);
const excludeClause = deductedIds.length > 0 ? `AND id NOT IN (${deductedIds.join(',')})` : '';
const excludeClause = deductedIds.length > 0 ? `AND id NOT IN (${Array(deductedIds.length).fill('?').join(',')})` : '';
const queryParams = [userId, year, ...deductedIds];
const [balances] = await c.query(`
SELECT id, total_days, used_days, (total_days - used_days) AS remaining_days, balance_type
FROM sp_vacation_balances
WHERE user_id = ? AND year = ? AND (total_days - used_days) > 0 ${excludeClause}
ORDER BY FIELD(balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
FOR UPDATE
`, [userId, year]);
`, queryParams);
for (const b of balances) {
if (remaining <= 0) break;

View File

@@ -1,9 +1,22 @@
const { getPool } = require('../middleware/auth');
const ALLOWED_CREATE_COLUMNS = ['user_id', 'vacation_type_id', 'start_date', 'end_date', 'days_used', 'reason', 'status', 'reviewed_by', 'review_note'];
const ALLOWED_UPDATE_COLUMNS = ['vacation_type_id', 'start_date', 'end_date', 'days_used', 'reason'];
const vacationRequestModel = {
async create(data, conn) {
const db = conn || getPool();
const [result] = await db.query('INSERT INTO sp_vacation_requests SET ?', data);
const filtered = {};
for (const key of ALLOWED_CREATE_COLUMNS) {
if (data[key] !== undefined) filtered[key] = data[key];
}
const columns = Object.keys(filtered);
const placeholders = columns.map(() => '?').join(', ');
const values = columns.map(c => filtered[c]);
const [result] = await db.query(
`INSERT INTO sp_vacation_requests (${columns.join(', ')}) VALUES (${placeholders})`,
values
);
return result;
},
@@ -81,7 +94,15 @@ const vacationRequestModel = {
async update(requestId, data) {
const db = getPool();
const [result] = await db.query('UPDATE sp_vacation_requests SET ? WHERE request_id = ?', [data, requestId]);
const filtered = {};
for (const key of ALLOWED_UPDATE_COLUMNS) {
if (data[key] !== undefined) filtered[key] = data[key];
}
const columns = Object.keys(filtered);
if (columns.length === 0) return { affectedRows: 0 };
const setClause = columns.map(c => `${c} = ?`).join(', ');
const values = [...columns.map(c => filtered[c]), requestId];
const [result] = await db.query(`UPDATE sp_vacation_requests SET ${setClause} WHERE request_id = ?`, values);
return result;
},