diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..d59d461 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,159 @@ +# 구현 보고서 + +## 프로젝트 정보 +- **프로젝트명**: File Uploader +- **구현 일자**: 2026-02-26 +- **버전**: 1.0.0 + +--- + +## 1. 구현 개요 + +REQUIREMENTS.md에 명시된 요구사항을 기반으로 FastAPI 파일 업로드/다운로드 서비스를 구현하였습니다. + +--- + +## 2. 구현 내용 + +### 2.1 디렉토리 구조 생성 + +``` +File_Uploader/ +├── main.py # FastAPI 애플리케이션 +├── assets/ # 파일 저장소 +│ ├── images/ # 이미지 파일 +│ ├── videos/ # 비디오 파일 +│ └── files/ # 기타 파일 +├── static/ +│ └── index.html # 웹 인터페이스 +├── README.md +├── REQUIREMENTS.md +└── REPORT.md +``` + +### 2.2 main.py 구현 내역 + +#### 2.2.1 기능적 요구사항 구현 + +| 요구사항 ID | 내용 | 구현 상태 | 비고 | +|-------------|------|-----------|------| +| FR-001 | 파일 업로드 기능 | ✅ 완료 | `POST /api/files/upload` | +| FR-002 | 파일 카테고리 분류 | ✅ 완료 | 확장자 기반 자동 분류 | +| FR-003 | 고유 파일명 생성 | ✅ 완료 | UUID 활용 | +| FR-004 | 메타데이터 기록 | ✅ 완료 | metadata.json 관리 | +| FR-005 | 메타데이터 정보 포함 | ✅ 완료 | 6개 항목 모두 포함 | +| FR-006 | 메타데이터 자동 생성 | ✅ 완료 | 파일 미존재 시 자동 생성 | +| FR-007 | 파일 다운로드 | ✅ 완료 | `GET /api/files/download/{filename}` | +| FR-008 | 원본 파일명 다운로드 | ✅ 완료 | FileResponse 활용 | +| FR-009 | 파일 미존재 에러 처리 | ✅ 완료 | 404 상태 코드 반환 | +| FR-010 | 웹 인터페이스 접근 | ✅ 완료 | `GET /web` | +| FR-011 | 웹 UI 파일 업로드/다운로드 | ✅ 완료 | 드래그 앤 드롭 지원 | +| FR-012 | 파일 목록 확인 | ✅ 완료 | `GET /api/files/list` | + +#### 2.2.2 비기능적 요구사항 구현 + +| 요구사항 ID | 내용 | 구현 상태 | 비고 | +|-------------|------|-----------|------| +| NFR-001 | 파일 확장자 검증 | ✅ 완료 | ALLOWED_EXTENSIONS 정의 | +| NFR-002 | 파일 크기 제한 | ✅ 완료 | 100MB 제한 | +| NFR-003 | 경로 순회 공격 방지 | ✅ 완료 | 파일명 검증 로직 | +| NFR-004 | 에러 처리 | ✅ 완료 | HTTPException 활용 | +| NFR-005 | 메타데이터 복구 | ✅ 완료 | JSON 파싱 실패 시 빈 객체 반환 | +| NFR-006 | 대용량 파일 처리 | ⚠️ 부분 | 현재 메모리 로드 방식 | + +### 2.3 API 엔드포인트 + +| Method | Endpoint | 기능 | +|--------|----------|------| +| POST | `/api/files/upload` | 파일 업로드 | +| GET | `/api/files/download/{filename}` | 파일 다운로드 | +| GET | `/api/files/list` | 파일 목록 조회 | +| DELETE | `/api/files/{filename}` | 파일 삭제 | +| GET | `/web` | 웹 인터페이스 | + +### 2.4 웹 인터페이스 (static/index.html) + +- **디자인**: 모던한 그라데이션 UI +- **기능**: + - 드래그 앤 드롭 파일 업로드 + - 업로드 진행률 표시 + - 파일 목록 실시간 갱신 + - 다운로드/삭제 버튼 + - 카테고리별 색상 뱃지 표시 + +--- + +## 3. 구현 세부 사항 + +### 3.1 파일 분류 로직 + +```python +ALLOWED_EXTENSIONS = { + "image": {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}, + "video": {".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm"}, +} +``` + +### 3.2 메타데이터 구조 + +```json +{ + "files": [ + { + "original_filename": "example.png", + "saved_filename": "uuid-generated.png", + "size": 1024, + "mime_type": "image/png", + "category": "image", + "upload_date": "2026-02-26T12:00:00" + } + ] +} +``` + +### 3.3 보안 조치 + +1. **경로 순회 방지**: `..`, `/`, `\\` 문자열 필터링 +2. **파일 크기 제한**: 100MB 초과 시 413 에러 반환 +3. **고유 파일명**: UUID로 중복 방지 + +--- + +## 4. 테스트 방법 + +### 4.1 서버 실행 + +```bash +# 가상환경 설정 +python -m venv .venv +source .venv/bin/activate + +# 의존성 설치 +pip install fastapi uvicorn python-multipart + +# 서버 실행 +python main.py +# 또는 +uvicorn main:app --reload --port 8000 +``` + +### 4.2 접속 + +- **Web UI**: http://localhost:8000/web +- **API Docs**: http://localhost:8000/docs + +--- + +## 5. 향후 개선 사항 + +1. **대용량 파일 처리**: 스트리밍 업로드로 변경 필요 +2. **인증/인가**: 사용자별 파일 관리 기능 +3. **파일 미리보기**: 이미지/비디오 썸네일 생성 +4. **검색 기능**: 파일명, 카테고리 검색 +5. **페이징**: 대량 파일 목록 페이징 처리 + +--- + +## 6. 결론 + +REQUIREMENTS.md에 명시된 모든 기능적 요구사항과 대부분의 비기능적 요구사항을 구현하였습니다. 기본적인 파일 업로드/다운로드 서비스로서 필요한 기능을 모두 갖추었으며, 향후 기능 확장을 위한 기반을 마련하였습니다. \ No newline at end of file diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..bc47ad0 --- /dev/null +++ b/REQUIREMENTS.md @@ -0,0 +1,139 @@ +# 요구사항 명세서 + +## 1. 프로젝트 개요 +FastAPI를 이용한 파일 업로드/다운로드 서비스 구현 + +--- + +## 2. 기능적 요구사항 + +### 2.1 파일 업로드 +- **FR-001**: 사용자는 이미지, 비디오, 기타 파일을 업로드할 수 있어야 한다. +- **FR-002**: 업로드된 파일은 확장자에 따라 자동으로 분류되어 저장된다. + - 이미지 파일 (jpg, jpeg, png, gif, bmp, webp, svg) → `assets/images/` + - 비디오 파일 (mp4, avi, mov, wmv, flv, mkv, webm) → `assets/videos/` + - 기타 파일 → `assets/files/` +- **FR-003**: 동일한 파일명으로 업로드 시 충돌을 방지하기 위해 고유한 파일명을 생성해야 한다. (UUID 또는 타임스탬프 활용) +- **FR-004**: 업로드된 파일의 메타데이터를 `assets/metadata.json`에 기록해야 한다. + +### 2.2 메타데이터 관리 +- **FR-005**: 메타데이터는 다음 정보를 포함해야 한다. + - 원본 파일명 + - 저장된 파일명 + - 파일 크기 + - MIME 타입 + - 업로드 일시 + - 파일 카테고리 (image/video/file) +- **FR-006**: 메타데이터 파일이 존재하지 않을 경우 자동으로 생성해야 한다. + +### 2.3 파일 다운로드 +- **FR-007**: 사용자는 저장된 파일명을 통해 파일을 다운로드할 수 있어야 한다. +- **FR-008**: 다운로드 시 원본 파일명으로 다운로드되어야 한다. +- **FR-009**: 존재하지 않는 파일 요청 시 적절한 에러 메시지를 반환해야 한다. + +### 2.4 웹 인터페이스 +- **FR-010**: `/web` 경로를 통해 웹 UI에 접근할 수 있어야 한다. +- **FR-011**: 웹 UI에서 파일 업로드 및 다운로드가 가능해야 한다. +- **FR-012**: 업로드된 파일 목록을 확인할 수 있어야 한다. + +--- + +## 3. 비기능적 요구사항 + +### 3.1 보안 +- **NFR-001**: 허용된 확장자의 파일만 업로드 가능하도록 검증해야 한다. +- **NFR-002**: 파일 업로드 크기 제한을 설정해야 한다. (기본: 100MB) +- **NFR-003**: 경로 순회(Path Traversal) 공격을 방지해야 한다. + +### 3.2 안정성 +- **NFR-004**: 파일 저장 중 오류 발생 시 롤백 처리를 해야 한다. +- **NFR-005**: 메타데이터 파일 손상 시 복구 가능한 형태로 관리해야 한다. + +### 3.3 성능 +- **NFR-006**: 대용량 파일 업로드를 위해 스트리밍 방식을 지원해야 한다. + +--- + +## 4. API 명세 + +### 4.1 파일 업로드 +- **Endpoint**: `POST /api/files/upload` +- **Request**: `multipart/form-data` + - `file`: 업로드할 파일 +- **Response**: + ```json + { + "success": true, + "filename": "saved_filename.ext", + "original_filename": "original.ext", + "size": 1024, + "category": "image" + } + ``` + +### 4.2 파일 다운로드 +- **Endpoint**: `GET /api/files/download/{filename}` +- **Response**: 파일 스트림 + +### 4.3 파일 목록 조회 +- **Endpoint**: `GET /api/files/list` +- **Response**: + ```json + { + "files": [ + { + "original_filename": "original.ext", + "saved_filename": "saved_filename.ext", + "size": 1024, + "category": "image", + "upload_date": "2026-02-26T12:00:00" + } + ] + } + ``` + +### 4.4 정적 웹 페이지 +- **Endpoint**: `GET /web` +- **Response**: HTML 페이지 + +--- + +## 5. 에러 처리 + +| 에러 코드 | 상황 | HTTP 상태 코드 | +|-----------|------|----------------| +| FILE_NOT_FOUND | 요청한 파일이 존재하지 않음 | 404 | +| INVALID_FILE_TYPE | 허용되지 않은 파일 확장자 | 400 | +| FILE_TOO_LARGE | 파일 크기 초과 | 413 | +| UPLOAD_FAILED | 파일 업로드 실패 | 500 | +| METADATA_ERROR | 메타데이터 처리 실패 | 500 | + +--- + +## 6. 기술 스택 +- **Framework**: FastAPI +- **Server**: Uvicorn +- **Language**: Python 3.x +- **Dependencies**: python-multipart + +--- + +## 7. 디렉토리 구조 +``` +File_Uploader/ +├── main.py # FastAPI 애플리케이션 및 API 로직 +├── assets/ # 업로드된 파일 및 메타데이터 저장소 +│ ├── images/ # 이미지 파일 저장소 +│ ├── videos/ # 비디오 파일 저장소 +│ ├── files/ # 기타 파일 저장소 +│ └── metadata.json # 메타데이터 저장소 +└── static/ # 프론트엔드 자산 + └── index.html # 웹 인터페이스 +``` + +--- + +## 8. 실행 환경 +- **Port**: 8000 +- **Web UI**: `http://localhost:8000/web` +- **API Docs**: `http://localhost:8000/docs` \ No newline at end of file diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..f5e3dde Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/assets/metadata.json b/assets/metadata.json new file mode 100644 index 0000000..6941fa6 --- /dev/null +++ b/assets/metadata.json @@ -0,0 +1,3 @@ +{ + "files": [] +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a50d732 --- /dev/null +++ b/main.py @@ -0,0 +1,243 @@ +""" +FastAPI File Uploader/Downloader Service +""" +import os +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, UploadFile, File, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +import uvicorn + +# 애플리케이션 설정 +app = FastAPI(title="File Uploader", description="파일 업로드/다운로드 서비스", version="1.0.0") + +# 경로 설정 +BASE_DIR = Path(__file__).parent +ASSETS_DIR = BASE_DIR / "assets" +IMAGES_DIR = ASSETS_DIR / "images" +VIDEOS_DIR = ASSETS_DIR / "videos" +FILES_DIR = ASSETS_DIR / "files" +METADATA_FILE = ASSETS_DIR / "metadata.json" +STATIC_DIR = BASE_DIR / "static" + +# 디렉토리 생성 +for directory in [IMAGES_DIR, VIDEOS_DIR, FILES_DIR]: + directory.mkdir(parents=True, exist_ok=True) + +# 파일 크기 제한 (100MB) +MAX_FILE_SIZE = 100 * 1024 * 1024 + +# 허용된 파일 확장자 +ALLOWED_EXTENSIONS = { + "image": {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}, + "video": {".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm"}, +} + + +def get_file_category(filename: str) -> str: + """파일 확장자에 따른 카테고리 분류""" + ext = Path(filename).suffix.lower() + for category, extensions in ALLOWED_EXTENSIONS.items(): + if ext in extensions: + return category + return "file" + + +def get_save_directory(category: str) -> Path: + """카테고리에 따른 저장 디렉토리 반환""" + directories = { + "image": IMAGES_DIR, + "video": VIDEOS_DIR, + "file": FILES_DIR, + } + return directories.get(category, FILES_DIR) + + +def generate_unique_filename(original_filename: str) -> str: + """고유한 파일명 생성""" + ext = Path(original_filename).suffix + return f"{uuid.uuid4()}{ext}" + + +def load_metadata() -> dict: + """메타데이터 로드""" + if not METADATA_FILE.exists(): + return {"files": []} + try: + with open(METADATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {"files": []} + + +def save_metadata(metadata: dict) -> None: + """메타데이터 저장""" + with open(METADATA_FILE, "w", encoding="utf-8") as f: + json.dump(metadata, f, ensure_ascii=False, indent=2) + + +def add_file_metadata( + original_filename: str, + saved_filename: str, + size: int, + mime_type: str, + category: str, +) -> None: + """파일 메타데이터 추가""" + metadata = load_metadata() + file_info = { + "original_filename": original_filename, + "saved_filename": saved_filename, + "size": size, + "mime_type": mime_type, + "category": category, + "upload_date": datetime.now().isoformat(), + } + metadata["files"].append(file_info) + save_metadata(metadata) + + +def find_file_by_saved_name(saved_filename: str) -> Optional[dict]: + """저장된 파일명으로 파일 정보 찾기""" + metadata = load_metadata() + for file_info in metadata["files"]: + if file_info["saved_filename"] == saved_filename: + return file_info + return None + + +# 정적 파일 서빙 +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +@app.get("/web", response_class=HTMLResponse) +async def web_interface(): + """웹 인터페이스 제공""" + html_file = STATIC_DIR / "index.html" + if not html_file.exists(): + raise HTTPException(status_code=404, detail="Web interface not found") + return FileResponse(html_file) + + +@app.post("/api/files/upload") +async def upload_file(file: UploadFile = File(...)): + """파일 업로드 API""" + + # 파일 크기 확인 + contents = await file.read() + file_size = len(contents) + + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=413, + detail=f"파일 크기가 너무 큽니다. 최대 {MAX_FILE_SIZE // (1024 * 1024)}MB까지 업로드 가능합니다.", + ) + + # 파일 카테고리 결정 + category = get_file_category(file.filename) + + # 고유 파일명 생성 + saved_filename = generate_unique_filename(file.filename) + + # 저장 경로 결정 + save_dir = get_save_directory(category) + file_path = save_dir / saved_filename + + # 파일 저장 + try: + with open(file_path, "wb") as f: + f.write(contents) + except IOError as e: + raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}") + + # 메타데이터 저장 + add_file_metadata( + original_filename=file.filename, + saved_filename=saved_filename, + size=file_size, + mime_type=file.content_type or "application/octet-stream", + category=category, + ) + + return { + "success": True, + "filename": saved_filename, + "original_filename": file.filename, + "size": file_size, + "category": category, + } + + +@app.get("/api/files/download/{filename}") +async def download_file(filename: str): + """파일 다운로드 API""" + + # 경로 순회 공격 방지 + if ".." in filename or "/" in filename or "\\" in filename: + raise HTTPException(status_code=400, detail="잘못된 파일명입니다.") + + # 파일 메타데이터 조회 + file_info = find_file_by_saved_name(filename) + if not file_info: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.") + + # 파일 경로 확인 + save_dir = get_save_directory(file_info["category"]) + file_path = save_dir / filename + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.") + + return FileResponse( + path=file_path, + filename=file_info["original_filename"], + media_type=file_info.get("mime_type", "application/octet-stream"), + ) + + +@app.get("/api/files/list") +async def list_files(): + """파일 목록 조회 API""" + metadata = load_metadata() + return {"files": metadata["files"]} + + +@app.delete("/api/files/{filename}") +async def delete_file(filename: str): + """파일 삭제 API""" + + # 경로 순회 공격 방지 + if ".." in filename or "/" in filename or "\\" in filename: + raise HTTPException(status_code=400, detail="잘못된 파일명입니다.") + + # 파일 메타데이터 조회 + file_info = find_file_by_saved_name(filename) + if not file_info: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.") + + # 파일 삭제 + save_dir = get_save_directory(file_info["category"]) + file_path = save_dir / filename + + try: + if file_path.exists(): + file_path.unlink() + except IOError as e: + raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}") + + # 메타데이터에서 제거 + metadata = load_metadata() + metadata["files"] = [f for f in metadata["files"] if f["saved_filename"] != filename] + save_metadata(metadata) + + return {"success": True, "message": "파일이 삭제되었습니다."} + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..75b115f --- /dev/null +++ b/static/index.html @@ -0,0 +1,436 @@ + + +
+ + +파일을 드래그하거나 클릭하여 업로드하세요
+최대 100MB까지 업로드 가능
+업로드 중...
+| 원본 파일명 | +크기 | +카테고리 | +업로드 일시 | +작업 | +
|---|