diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile index d3a11d6..d7e1b91 100644 --- a/ai-service/Dockerfile +++ b/ai-service/Dockerfile @@ -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"] diff --git a/ai-service/services/chatbot_service.py b/ai-service/services/chatbot_service.py index 80c3cc7..233819a 100644 --- a/ai-service/services/chatbot_service.py +++ b/ai-service/services/chatbot_service.py @@ -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}" +사용자 입력: +{safe_text} 위 카테고리 목록을 참고하여 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', '')} + +- 설명: {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', ''))} + 위 정보를 보기 좋게 요약하여 JSON으로 응답하세요.""" diff --git a/docker-compose.yml b/docker-compose.yml index ba48541..4c7c9b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh deleted file mode 100755 index 805bbeb..0000000 --- a/scripts/deploy-remote.sh +++ /dev/null @@ -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= -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}" diff --git a/sso-auth-service/controllers/authController.js b/sso-auth-service/controllers/authController.js index e8be7d0..00b7d6e 100644 --- a/sso-auth-service/controllers/authController.js +++ b/sso-auth-service/controllers/authController.js @@ -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({ diff --git a/system1-factory/api/controllers/proxyInputController.js b/system1-factory/api/controllers/proxyInputController.js index 58ce45c..67a68d5 100644 --- a/system1-factory/api/controllers/proxyInputController.js +++ b/system1-factory/api/controllers/proxyInputController.js @@ -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: '조회 중 오류가 발생했습니다.' }); } } }; diff --git a/system1-factory/api/models/monthlyComparisonModel.js b/system1-factory/api/models/monthlyComparisonModel.js index 805eeec..6f19844 100644 --- a/system1-factory/api/models/monthlyComparisonModel.js +++ b/system1-factory/api/models/monthlyComparisonModel.js @@ -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; diff --git a/system1-factory/api/routes/pageAccessRoutes.js b/system1-factory/api/routes/pageAccessRoutes.js index 7f0e907..121834e 100644 --- a/system1-factory/api/routes/pageAccessRoutes.js +++ b/system1-factory/api/routes/pageAccessRoutes.js @@ -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: '페이지 접근 권한 요약을 불러오는데 실패했습니다.' }); } }); diff --git a/system1-factory/fastapi-bridge/Dockerfile b/system1-factory/fastapi-bridge/Dockerfile index 9d73f64..e26c4e4 100644 --- a/system1-factory/fastapi-bridge/Dockerfile +++ b/system1-factory/fastapi-bridge/Dockerfile @@ -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"] \ No newline at end of file diff --git a/system1-factory/fastapi-bridge/config.py b/system1-factory/fastapi-bridge/config.py index f8ac982..7c43d37 100644 --- a/system1-factory/fastapi-bridge/config.py +++ b/system1-factory/fastapi-bridge/config.py @@ -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") diff --git a/system1-factory/fastapi-bridge/main.py b/system1-factory/fastapi-bridge/main.py index 97c31f5..4645fea 100644 --- a/system1-factory/fastapi-bridge/main.py +++ b/system1-factory/fastapi-bridge/main.py @@ -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: diff --git a/system1-factory/fastapi-bridge/requirements.txt b/system1-factory/fastapi-bridge/requirements.txt index e643d5d..5f009c6 100644 --- a/system1-factory/fastapi-bridge/requirements.txt +++ b/system1-factory/fastapi-bridge/requirements.txt @@ -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 \ No newline at end of file +python-dotenv==1.0.0 +PyJWT==2.8.0 \ No newline at end of file diff --git a/system1-factory/web/js/my-monthly-confirm.js b/system1-factory/web/js/my-monthly-confirm.js index d23092d..082262b 100644 --- a/system1-factory/web/js/my-monthly-confirm.js +++ b/system1-factory/web/js/my-monthly-confirm.js @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); } // 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); }); diff --git a/system1-factory/web/js/tbm-create.js b/system1-factory/web/js/tbm-create.js index d535800..3f6b148 100644 --- a/system1-factory/web/js/tbm-create.js +++ b/system1-factory/web/js/tbm-create.js @@ -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 '
' + + return '
' + '
' + esc(p.project_name) + '
' + '
' + esc(p.job_no || '') + '
' + '
'; @@ -315,7 +315,7 @@ // 공정 pill 버튼 var pillHtml = workTypes.map(function(wt) { var selected = W.workTypeId === wt.id ? ' selected' : ''; - return ''; + return ''; }).join(''); pillHtml += ''; @@ -335,7 +335,7 @@ container.innerHTML = '
' + '
2프로젝트 선택 (선택사항)
' + - '
' + + '
' + '선택 안함' + '
' + (projects.length > 0 ? projectItems : '
등록된 프로젝트가 없습니다
') + @@ -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) { diff --git a/system1-factory/web/pages/work/tbm-create.html b/system1-factory/web/pages/work/tbm-create.html index 02ec37a..0ed0d6a 100644 --- a/system1-factory/web/pages/work/tbm-create.html +++ b/system1-factory/web/pages/work/tbm-create.html @@ -844,14 +844,14 @@ - + - - - - - - + + + + + + diff --git a/system1-factory/web/pages/work/tbm-mobile.html b/system1-factory/web/pages/work/tbm-mobile.html index 530b4b2..3e0b941 100644 --- a/system1-factory/web/pages/work/tbm-mobile.html +++ b/system1-factory/web/pages/work/tbm-mobile.html @@ -7,7 +7,7 @@ - + @@ -265,13 +265,13 @@ - - - + + + - - - + + + diff --git a/system1-factory/web/static/js/tkfb-core.js b/system1-factory/web/static/js/tkfb-core.js index 643c95d..a04f27f 100644 --- a/system1-factory/web/static/js/tkfb-core.js +++ b/system1-factory/web/static/js/tkfb-core.js @@ -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); } diff --git a/system3-nonconformance/api/Dockerfile b/system3-nonconformance/api/Dockerfile index 8eb9b91..1ff2ae4 100644 --- a/system3-nonconformance/api/Dockerfile +++ b/system3-nonconformance/api/Dockerfile @@ -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"] diff --git a/tkeg/api/Dockerfile b/tkeg/api/Dockerfile index d04e321..22c3e38 100644 --- a/tkeg/api/Dockerfile +++ b/tkeg/api/Dockerfile @@ -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"] diff --git a/tkeg/api/app/routers/files.py b/tkeg/api/app/routers/files.py index 27ffbd4..f461a77 100644 --- a/tkeg/api/app/routers/files.py +++ b/tkeg/api/app/routers/files.py @@ -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) ): """ 누락된 도면 처리 diff --git a/tkeg/api/app/routers/material_comparison.py b/tkeg/api/app/routers/material_comparison.py index d67d127..0c3812e 100644 --- a/tkeg/api/app/routers/material_comparison.py +++ b/tkeg/api/app/routers/material_comparison.py @@ -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) ): """ 발주 상태 조회 diff --git a/tksupport/api/controllers/vacationController.js b/tksupport/api/controllers/vacationController.js index d61b2c8..9f112df 100644 --- a/tksupport/api/controllers/vacationController.js +++ b/tksupport/api/controllers/vacationController.js @@ -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(); } }, diff --git a/tksupport/api/models/vacationBalanceModel.js b/tksupport/api/models/vacationBalanceModel.js index 03f30e5..66fbe7c 100644 --- a/tksupport/api/models/vacationBalanceModel.js +++ b/tksupport/api/models/vacationBalanceModel.js @@ -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; diff --git a/tksupport/api/models/vacationRequestModel.js b/tksupport/api/models/vacationRequestModel.js index dd5a013..c221825 100644 --- a/tksupport/api/models/vacationRequestModel.js +++ b/tksupport/api/models/vacationRequestModel.js @@ -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; },