zioinfo-mail/itsm/routers/attachments.py
DESKTOP-TKLFCPR\ython e228faabf5 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

212 lines
7.4 KiB
Python

"""
SR 첨부파일 업로드 · 목록 · 다운로드 · 삭제 엔드포인트.
보안 규칙:
- file_path (서버 내부 경로) 는 API 응답에 절대 포함하지 않음
- 허용 확장자 화이트리스트 적용
- 파일명 경로 순회(../etc) 방지
- 1파일 최대 20MB
- 인증된 사용자만 다운로드 가능
"""
import mimetypes
import os
import re
import time
from pathlib import Path
from typing import List
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import SRAttachment, SRAttachmentOut, SRRequest, User, UserRole
router = APIRouter(prefix="/api/tasks", tags=["attachments"])
# ── 설정 ──────────────────────────────────────────────────────────────────────
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "sr_files"
MAX_BYTES = 20 * 1024 * 1024 # 20 MB
ALLOWED_EXT = {
".pdf", ".txt", ".log", ".csv",
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp",
".xlsx", ".xls", ".docx", ".doc", ".pptx", ".ppt",
".zip", ".tar", ".gz",
".sh", ".sql", ".json", ".xml", ".yaml", ".yml",
".md",
}
def _safe_name(name: str) -> str:
"""파일명 정제: 경로 구분자·특수문자 제거, 길이 제한."""
name = Path(name).name # 경로 트래버설 방지
name = re.sub(r'[\\/:*?"<>|]', "_", name) # Windows 금지 문자 제거
return name[:200] or "file"
def _check_ext(name: str) -> None:
ext = Path(name).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(400, f"허용되지 않는 파일 형식입니다: {ext}")
async def _get_sr_or_404(sr_id: str, db: AsyncSession) -> SRRequest:
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = r.scalars().first()
if not sr:
raise HTTPException(404, "SR을 찾을 수 없습니다.")
return sr
# ── 업로드 ─────────────────────────────────────────────────────────────────────
@router.post("/{sr_id}/attachments", response_model=List[SRAttachmentOut], status_code=201)
async def upload_attachments(
sr_id: str,
files: List[UploadFile] = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""SR에 파일 첨부 (최대 10개, 파일당 20 MB)."""
await _get_sr_or_404(sr_id, db)
if len(files) > 10:
raise HTTPException(400, "한 번에 최대 10개 파일까지 업로드할 수 있습니다.")
sr_dir = UPLOAD_ROOT / sr_id
sr_dir.mkdir(parents=True, exist_ok=True)
saved: list[SRAttachment] = []
for f in files:
orig = _safe_name(f.filename or "unnamed")
_check_ext(orig)
content = await f.read()
if len(content) > MAX_BYTES:
raise HTTPException(400, f"'{orig}' 파일 크기가 20 MB를 초과합니다.")
if len(content) == 0:
raise HTTPException(400, f"'{orig}' 빈 파일은 업로드할 수 없습니다.")
# 충돌 방지: 타임스탬프 접두사
stored = f"{int(time.time())}_{orig}"
dest = sr_dir / stored
dest.write_bytes(content)
ct = f.content_type or mimetypes.guess_type(orig)[0] or "application/octet-stream"
att = SRAttachment(
sr_id = sr_id,
original_name = orig,
stored_name = stored,
file_path = str(dest), # 내부 저장 전용, 응답에 포함 안 됨
file_size = len(content),
content_type = ct,
uploaded_by = current_user.username,
)
db.add(att)
saved.append(att)
await db.commit()
for a in saved:
await db.refresh(a)
return saved
# ── 목록 ───────────────────────────────────────────────────────────────────────
@router.get("/{sr_id}/attachments", response_model=List[SRAttachmentOut])
async def list_attachments(
sr_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
await _get_sr_or_404(sr_id, db)
r = await db.execute(
select(SRAttachment)
.where(SRAttachment.sr_id == sr_id)
.order_by(SRAttachment.created_at)
)
return r.scalars().all()
# ── 다운로드 ───────────────────────────────────────────────────────────────────
@router.get("/{sr_id}/attachments/{att_id}/download")
async def download_attachment(
sr_id: str,
att_id: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
r = await db.execute(
select(SRAttachment).where(
SRAttachment.id == att_id,
SRAttachment.sr_id == sr_id,
)
)
att = r.scalars().first()
if not att:
raise HTTPException(404, "첨부파일을 찾을 수 없습니다.")
p = Path(att.file_path)
if not p.exists():
raise HTTPException(404, "파일이 서버에 존재하지 않습니다.")
# 경로 순회 검증
try:
p.resolve().relative_to(UPLOAD_ROOT.resolve())
except ValueError:
raise HTTPException(403, "접근이 거부되었습니다.")
encoded = quote(att.original_name, safe="")
ct = att.content_type or "application/octet-stream"
def _iter():
with open(p, "rb") as fh:
while chunk := fh.read(65536):
yield chunk
return StreamingResponse(
_iter(),
media_type=ct,
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"},
)
# ── 삭제 ───────────────────────────────────────────────────────────────────────
@router.delete("/{sr_id}/attachments/{att_id}", status_code=204)
async def delete_attachment(
sr_id: str,
att_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
r = await db.execute(
select(SRAttachment).where(
SRAttachment.id == att_id,
SRAttachment.sr_id == sr_id,
)
)
att = r.scalars().first()
if not att:
raise HTTPException(404, "첨부파일을 찾을 수 없습니다.")
# 본인 업로드 또는 ADMIN/PM만 삭제 가능
if (current_user.username != att.uploaded_by
and current_user.role not in (UserRole.ADMIN, UserRole.PM)):
raise HTTPException(403, "삭제 권한이 없습니다.")
# 파일 삭제
p = Path(att.file_path)
if p.exists():
try:
p.unlink()
except OSError:
pass # 파일 삭제 실패해도 DB에서 제거
await db.delete(att)
await db.commit()