- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
212 lines
7.4 KiB
Python
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()
|