From 3036b8f0fb51d758480c606f823c7b71d34f21cb Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 21 Aug 2025 16:09:17 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Initial=20commit:=20Document=20S?= =?UTF-8?q?erver=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit โœจ Features implemented: - FastAPI backend with JWT authentication - PostgreSQL database with async SQLAlchemy - HTML document viewer with smart highlighting - Note system connected to highlights (1:1 relationship) - Bookmark system for quick navigation - Integrated search (documents + notes) - Tag system for document organization - Docker containerization with Nginx ๐Ÿ”ง Technical stack: - Backend: FastAPI + PostgreSQL + Redis - Frontend: Alpine.js + Tailwind CSS - Authentication: JWT tokens - File handling: HTML + PDF support - Search: Full-text search with relevance scoring ๐Ÿ“‹ Core functionality: - Text selection โ†’ Highlight creation - Highlight โ†’ Note attachment - Note management with search/filtering - Bookmark creation at scroll positions - Document upload with metadata - User management (admin creates accounts) --- .gitignore | 111 +++++ README.md | 283 ++++++++++++ backend/Dockerfile | 38 ++ backend/Dockerfile.dev | 35 ++ backend/pyproject.toml | 85 ++++ backend/src/__init__.py | 3 + backend/src/api/__init__.py | 3 + backend/src/api/dependencies.py | 88 ++++ backend/src/api/routes/__init__.py | 3 + backend/src/api/routes/auth.py | 190 ++++++++ backend/src/api/routes/bookmarks.py | 300 +++++++++++++ backend/src/api/routes/documents.py | 359 +++++++++++++++ backend/src/api/routes/highlights.py | 340 ++++++++++++++ backend/src/api/routes/notes.py | 404 +++++++++++++++++ backend/src/api/routes/search.py | 354 +++++++++++++++ backend/src/api/routes/users.py | 176 ++++++++ backend/src/core/config.py | 52 +++ backend/src/core/database.py | 94 ++++ backend/src/core/security.py | 87 ++++ backend/src/main.py | 72 +++ backend/src/models/__init__.py | 17 + backend/src/models/bookmark.py | 42 ++ backend/src/models/document.py | 81 ++++ backend/src/models/highlight.py | 47 ++ backend/src/models/note.py | 47 ++ backend/src/models/user.py | 34 ++ backend/src/schemas/auth.py | 56 +++ docker-compose.dev.yml | 68 +++ docker-compose.yml | 75 ++++ frontend/index.html | 243 ++++++++++ frontend/static/css/main.css | 269 +++++++++++ frontend/static/css/viewer.css | 455 +++++++++++++++++++ frontend/static/js/api.js | 265 +++++++++++ frontend/static/js/auth.js | 90 ++++ frontend/static/js/main.js | 286 ++++++++++++ frontend/static/js/viewer.js | 641 +++++++++++++++++++++++++++ frontend/viewer.html | 363 +++++++++++++++ nginx/Dockerfile | 15 + nginx/default.conf | 75 ++++ nginx/nginx.conf | 57 +++ 40 files changed, 6303 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.dev create mode 100644 backend/pyproject.toml create mode 100644 backend/src/__init__.py create mode 100644 backend/src/api/__init__.py create mode 100644 backend/src/api/dependencies.py create mode 100644 backend/src/api/routes/__init__.py create mode 100644 backend/src/api/routes/auth.py create mode 100644 backend/src/api/routes/bookmarks.py create mode 100644 backend/src/api/routes/documents.py create mode 100644 backend/src/api/routes/highlights.py create mode 100644 backend/src/api/routes/notes.py create mode 100644 backend/src/api/routes/search.py create mode 100644 backend/src/api/routes/users.py create mode 100644 backend/src/core/config.py create mode 100644 backend/src/core/database.py create mode 100644 backend/src/core/security.py create mode 100644 backend/src/main.py create mode 100644 backend/src/models/__init__.py create mode 100644 backend/src/models/bookmark.py create mode 100644 backend/src/models/document.py create mode 100644 backend/src/models/highlight.py create mode 100644 backend/src/models/note.py create mode 100644 backend/src/models/user.py create mode 100644 backend/src/schemas/auth.py create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/static/css/main.css create mode 100644 frontend/static/css/viewer.css create mode 100644 frontend/static/js/api.js create mode 100644 frontend/static/js/auth.js create mode 100644 frontend/static/js/main.js create mode 100644 frontend/static/js/viewer.js create mode 100644 frontend/viewer.html create mode 100644 nginx/Dockerfile create mode 100644 nginx/default.conf create mode 100644 nginx/nginx.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7451b86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,111 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# FastAPI +.pytest_cache/ +.coverage +htmlcov/ + +# Database +*.db +*.sqlite3 + +# Docker +.dockerignore + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Uploads (์‹ค์ œ ํŒŒ์ผ๋“ค) +uploads/documents/ +uploads/thumbnails/ + +# Poetry +poetry.lock + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe062a9 --- /dev/null +++ b/README.md @@ -0,0 +1,283 @@ +# Document Server + +HTML ๋ฌธ์„œ ๊ด€๋ฆฌ ๋ฐ ๋ทฐ์–ด ์‹œ์Šคํ…œ + +## ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +PDF ๋ฌธ์„œ๋ฅผ OCR ์ฒ˜๋ฆฌํ•˜๊ณ  AI๋กœ HTML๋กœ ๋ณ€ํ™˜ํ•œ ํ›„, ์›น์—์„œ ํšจ์œจ์ ์œผ๋กœ ๊ด€๋ฆฌํ•˜๊ณ  ์—ด๋žŒํ•  ์ˆ˜ ์žˆ๋Š” ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค. + +### ๋ฌธ์„œ ์ฒ˜๋ฆฌ ์›Œํฌํ”Œ๋กœ์šฐ +1. PDF ์Šค์บ” ํ›„ OCR ์ฒ˜๋ฆฌ +2. AI๋ฅผ ํ†ตํ•œ HTML ๋ณ€ํ™˜ (ํ•„์š”์‹œ ๋ฒˆ์—ญ ํฌํ•จ) +3. PDF ์›๋ณธ์€ Paperless์— ์—…๋กœ๋“œ +4. HTML ํŒŒ์ผ์€ Document Server์—์„œ ๊ด€๋ฆฌ + +## ์ฃผ์š” ๊ธฐ๋Šฅ + +### ํ•ต์‹ฌ ๊ธฐ๋Šฅ +- **์‚ฌ์šฉ์ž ์ธ์ฆ**: ๋กœ๊ทธ์ธ (๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ), JWT ๊ธฐ๋ฐ˜ ์„ธ์…˜ ๊ด€๋ฆฌ +- **HTML ๋ฌธ์„œ ๋ทฐ์–ด**: ๋ณ€ํ™˜๋œ HTML ๋ฌธ์„œ๋ฅผ ์›น์—์„œ ์—ด๋žŒ +- **์Šค๋งˆํŠธ ํ•˜์ด๋ผ์ดํŠธ**: ํ…์ŠคํŠธ ์„ ํƒ ํ›„ ๋ฐ‘์ค„/ํ•˜์ด๋ผ์ดํŠธ ํ‘œ์‹œ +- **์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ**: ํ•˜์ด๋ผ์ดํŠธ์— ์ง์ ‘ ๋ฉ”๋ชจ ์ถ”๊ฐ€ ๋ฐ ํŽธ์ง‘ +- **๋ฉ”๋ชจ ๊ด€๋ฆฌ**: ๋ฉ”๋ชจ๋งŒ ๋”ฐ๋กœ ๋ณด๊ธฐ, ๊ฒ€์ƒ‰, ์ •๋ ฌ ๊ธฐ๋Šฅ +- **๋น ๋ฅธ ๋„ค๋น„๊ฒŒ์ด์…˜**: ๋ฉ”๋ชจ์—์„œ ์›๋ฌธ ์œ„์น˜๋กœ ์ฆ‰์‹œ ์ด๋™ +- **์ฑ…๊ฐˆํ”ผ ๊ธฐ๋Šฅ**: ํŽ˜์ด์ง€ ๋ถ๋งˆํฌ ๋ฐ ๋น ๋ฅธ ์ด๋™ +- **ํ†ตํ•ฉ ๊ฒ€์ƒ‰**: ๋ฌธ์„œ ๋‚ด์šฉ + ๋ฉ”๋ชจ ๋‚ด์šฉ ํ†ตํ•ฉ ๊ฒ€์ƒ‰ + +### ์ถ”๊ฐ€ ๊ธฐ๋Šฅ +- **๋ฌธ์„œ ๊ด€๋ฆฌ**: HTML + PDF ์›๋ณธ ํ†ตํ•ฉ ๊ด€๋ฆฌ (Paperless ์Šคํƒ€์ผ) +- **ํƒœ๊ทธ ์‹œ์Šคํ…œ**: ๋ฌธ์„œ ๋ถ„๋ฅ˜ ๋ฐ ์กฐ์งํ™” +- **๋ฌธ์„œ ์—…๋กœ๋“œ**: ๋“œ๋ž˜๊ทธ&๋“œ๋กญ, ์ผ๊ด„ ์—…๋กœ๋“œ +- **์‚ฌ์šฉ์ž ๊ด€๋ฆฌ**: ๊ฐœ์ธ๋ณ„ ๋ฉ”๋ชจ, ๋ถ๋งˆํฌ, ๊ถŒํ•œ ๊ด€๋ฆฌ +- **๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ**: ์‚ฌ์šฉ์ž ์ƒ์„ฑ, ๋ฌธ์„œ ๊ด€๋ฆฌ, ์‹œ์Šคํ…œ ์„ค์ • +- **๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ**: ์ œ๋ชฉ, ๋‚ ์งœ, ์นดํ…Œ๊ณ ๋ฆฌ, ์ปค์Šคํ…€ ํ•„๋“œ + +## ๊ธฐ์ˆ  ์Šคํƒ + +### Backend +- **์–ธ์–ด**: Python 3.11+ +- **ํ”„๋ ˆ์ž„์›Œํฌ**: FastAPI 0.104+ +- **ORM**: SQLAlchemy 2.0+ +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL 15+ +- **์บ์‹ฑ**: Redis 7+ +- **๋น„๋™๊ธฐ**: asyncio, asyncpg +- **์ธ์ฆ**: JWT (python-jose) +- **ํŒŒ์ผ ์ฒ˜๋ฆฌ**: python-multipart, Pillow +- **๊ฒ€์ƒ‰**: Elasticsearch 8+ (๋˜๋Š” Whoosh) + +### Frontend +- **๊ธฐ๋ณธ**: HTML5, CSS3, JavaScript (ES6+) +- **CSS ํ”„๋ ˆ์ž„์›Œํฌ**: Tailwind CSS 3+ +- **UI ์ปดํฌ๋„ŒํŠธ**: Alpine.js 3+ (๊ฒฝ๋Ÿ‰ ๋ฐ˜์‘ํ˜•) +- **๊ฒ€์ƒ‰ UI**: Fuse.js (ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ๊ฒ€์ƒ‰) +- **์—๋””ํ„ฐ**: Quill.js 1.3+ (๋ฉ”๋ชจ ๊ธฐ๋Šฅ) +- **ํ•˜์ด๋ผ์ดํŠธ**: Rangy.js (ํ…์ŠคํŠธ ์„ ํƒ/ํ•˜์ด๋ผ์ดํŠธ) +- **์•„์ด์ฝ˜**: Heroicons / Lucide + +### ์›น์„œ๋ฒ„ & ํ”„๋ก์‹œ +- **๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ**: Nginx 1.24+ +- **ASGI ์„œ๋ฒ„**: Uvicorn 0.24+ +- **์ •์  ํŒŒ์ผ**: Nginx (์ง์ ‘ ์„œ๋น™) + +### ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค & ์ €์žฅ์†Œ +- **์ฃผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL 15+ (๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ, ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ) +- **์ „๋ฌธ ๊ฒ€์ƒ‰**: PostgreSQL Full-Text Search + Elasticsearch (์„ ํƒ) +- **์บ์‹ฑ**: Redis 7+ (์„ธ์…˜, ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์บ์‹ฑ) +- **ํŒŒ์ผ ์ €์žฅ์†Œ**: ๋กœ์ปฌ ํŒŒ์ผ์‹œ์Šคํ…œ (ํ–ฅํ›„ S3 ํ˜ธํ™˜ ์Šคํ† ๋ฆฌ์ง€) + +### ๊ฐœ๋ฐœ ๋„๊ตฌ +- **ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ**: Poetry (Python ์˜์กด์„ฑ) +- **์ฝ”๋“œ ํฌ๋งทํŒ…**: Black, isort +- **๋ฆฐํŒ…**: Flake8, mypy (ํƒ€์ž… ์ฒดํ‚น) +- **ํ…Œ์ŠคํŒ…**: pytest, pytest-asyncio +- **API ๋ฌธ์„œ**: FastAPI ์ž๋™ ์ƒ์„ฑ (Swagger/OpenAPI) + +### ์ธํ”„๋ผ & ๋ฐฐํฌ +- **์ปจํ…Œ์ด๋„ˆ**: Docker 24+ & Docker Compose +- **๋ฐฐํฌ ํ™˜๊ฒฝ**: Mac Mini / Synology NAS +- **ํ”„๋กœ์„ธ์Šค ๊ด€๋ฆฌ**: Docker (์ปจํ…Œ์ด๋„ˆ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜) +- **๋กœ๊ทธ ๊ด€๋ฆฌ**: Python logging + ํŒŒ์ผ ๋กœํ…Œ์ด์…˜ +- **๋ชจ๋‹ˆํ„ฐ๋ง**: ๊ธฐ๋ณธ ํ—ฌ์Šค์ฒดํฌ (ํ–ฅํ›„ Prometheus + Grafana) + +### ์™ธ๋ถ€ ์—ฐ๋™ +- **Paperless-ngx**: REST API ์—ฐ๋™ (์›๋ณธ PDF ๋‹ค์šด๋กœ๋“œ) +- **OCR**: Tesseract (ํ•„์š”์‹œ ์ถ”๊ฐ€ OCR ์ฒ˜๋ฆฌ) +- **AI ๋ฒˆ์—ญ**: OpenAI API / Google Translate API (์„ ํƒ) + +## ํฌํŠธ ํ• ๋‹น + +- **24100**: Nginx (๋ฉ”์ธ ์›น์„œ๋ฒ„) +- **24101**: Database (PostgreSQL/SQLite) +- **24102**: Backend API ์„œ๋ฒ„ +- **24103**: ์ถ”๊ฐ€ ์„œ๋น„์Šค์šฉ ์˜ˆ์•ฝ + +## ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ + +``` +document-server/ +โ”œโ”€โ”€ README.md +โ”œโ”€โ”€ docker-compose.yml +โ”œโ”€โ”€ nginx/ +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ””โ”€โ”€ nginx.conf +โ”œโ”€โ”€ backend/ +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ”œโ”€โ”€ requirements.txt (Python) / package.json (Node.js) +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ main.py / app.js +โ”‚ โ”‚ โ”œโ”€โ”€ models/ +โ”‚ โ”‚ โ”œโ”€โ”€ routes/ +โ”‚ โ”‚ โ””โ”€โ”€ services/ +โ”‚ โ””โ”€โ”€ uploads/ +โ”œโ”€โ”€ frontend/ +โ”‚ โ”œโ”€โ”€ static/ +โ”‚ โ”‚ โ”œโ”€โ”€ css/ +โ”‚ โ”‚ โ”œโ”€โ”€ js/ +โ”‚ โ”‚ โ””โ”€โ”€ assets/ +โ”‚ โ””โ”€โ”€ templates/ +โ”œโ”€โ”€ database/ +โ”‚ โ”œโ”€โ”€ init/ +โ”‚ โ””โ”€โ”€ migrations/ +โ””โ”€โ”€ docs/ + โ””โ”€โ”€ api.md +``` + +## ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ (์˜ˆ์ƒ) + +### ์ฃผ์š” ํ…Œ์ด๋ธ” +- **users**: ์‚ฌ์šฉ์ž ์ •๋ณด (์ด๋ฉ”์ผ, ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹œ, ๊ถŒํ•œ, ์ƒ์„ฑ์ผ) +- **documents**: ๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (์ œ๋ชฉ, HTML/PDF ๊ฒฝ๋กœ, ์—…๋กœ๋“œ์ž, ์ƒ์„ฑ์ผ) +- **document_tags**: ๋ฌธ์„œ ํƒœ๊ทธ (๋‹ค๋Œ€๋‹ค ๊ด€๊ณ„) +- **tags**: ํƒœ๊ทธ ์ •๋ณด (์ด๋ฆ„, ์ƒ‰์ƒ, ์„ค๋ช…) +- **highlights**: ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด (์‚ฌ์šฉ์ž๋ณ„, ๋ฌธ์„œ๋ณ„, ํ…์ŠคํŠธ ๋ฒ”์œ„, ์ƒ‰์ƒ) +- **notes**: ๋ฉ”๋ชจ ์ •๋ณด (ํ•˜์ด๋ผ์ดํŠธ ์—ฐ๊ฒฐ, ๋ฉ”๋ชจ ๋‚ด์šฉ, ์ƒ์„ฑ/์ˆ˜์ •์ผ) +- **bookmarks**: ์ฑ…๊ฐˆํ”ผ ์ •๋ณด (์‚ฌ์šฉ์ž๋ณ„, ๋ฌธ์„œ๋ณ„, ํŽ˜์ด์ง€ ์œ„์น˜) +- **user_sessions**: ์‚ฌ์šฉ์ž ์„ธ์…˜ ๊ด€๋ฆฌ (JWT ํ† ํฐ, ๋งŒ๋ฃŒ์ผ) +- **user_preferences**: ์‚ฌ์šฉ์ž ์„ค์ • (ํ…Œ๋งˆ, ์–ธ์–ด, ๋ทฐ์–ด ์„ค์ •) + +### ํ•˜์ด๋ผ์ดํŠธ & ๋ฉ”๋ชจ ์Šคํ‚ค๋งˆ ์ƒ์„ธ +```sql +-- ํ•˜์ด๋ผ์ดํŠธ ํ…Œ์ด๋ธ” +highlights ( + id: UUID PRIMARY KEY, + user_id: UUID REFERENCES users(id), + document_id: UUID REFERENCES documents(id), + start_offset: INTEGER, -- ํ…์ŠคํŠธ ์‹œ์ž‘ ์œ„์น˜ + end_offset: INTEGER, -- ํ…์ŠคํŠธ ๋ ์œ„์น˜ + selected_text: TEXT, -- ์„ ํƒ๋œ ํ…์ŠคํŠธ (๊ฒ€์ƒ‰์šฉ) + highlight_color: VARCHAR(7), -- ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ (#FFFF00) + element_selector: TEXT, -- DOM ์š”์†Œ ์„ ํƒ์ž + created_at: TIMESTAMP, + updated_at: TIMESTAMP +) + +-- ๋ฉ”๋ชจ ํ…Œ์ด๋ธ” (ํ•˜์ด๋ผ์ดํŠธ์™€ 1:1 ๊ด€๊ณ„) +notes ( + id: UUID PRIMARY KEY, + highlight_id: UUID REFERENCES highlights(id) ON DELETE CASCADE, + content: TEXT NOT NULL, -- ๋ฉ”๋ชจ ๋‚ด์šฉ + is_private: BOOLEAN DEFAULT true, + tags: TEXT[], -- ๋ฉ”๋ชจ ํƒœ๊ทธ + created_at: TIMESTAMP, + updated_at: TIMESTAMP +) +``` + +## ๊ฐœ๋ฐœ ๋‹จ๊ณ„ + +### Phase 1: ๊ธฐ๋ณธ ๊ตฌ์กฐ โœ… +- [x] ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์„ค์ • +- [x] Docker ํ™˜๊ฒฝ ๊ตฌ์„ฑ +- [x] ๊ธฐ๋ณธ ์›น์„œ๋ฒ„ ์„ค์ • (Nginx + FastAPI) + +### Phase 2: ์ธ์ฆ ์‹œ์Šคํ…œ โœ… +- [x] ์‚ฌ์šฉ์ž ๋ชจ๋ธ ๋ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ +- [x] ๋กœ๊ทธ์ธ API (๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ) +- [x] JWT ํ† ํฐ ๊ด€๋ฆฌ +- [x] ๊ถŒํ•œ ๋ฏธ๋“ค์›จ์–ด + +### Phase 3: ํ•ต์‹ฌ ๊ธฐ๋Šฅ โœ… +- [x] HTML ๋ฌธ์„œ ๋ทฐ์–ด (ํ•˜์ด๋ผ์ดํŠธ, ๋ฉ”๋ชจ ๊ธฐ๋Šฅ ํฌํ•จ) +- [x] ๋ฌธ์„œ ์—…๋กœ๋“œ ๊ธฐ๋Šฅ +- [x] ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ (๋ฌธ์„œ + ๋ฉ”๋ชจ) + +### Phase 4: ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ โœ… +- [x] ์Šค๋งˆํŠธ ํ•˜์ด๋ผ์ดํŠธ (ํ…์ŠคํŠธ ์„ ํƒ โ†’ ํ•˜์ด๋ผ์ดํŠธ) +- [x] ์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ (ํ•˜์ด๋ผ์ดํŠธ โ†” ๋ฉ”๋ชจ 1:1 ์—ฐ๊ฒฐ) +- [x] ์ฑ…๊ฐˆํ”ผ ์‹œ์Šคํ…œ (์œ„์น˜ ์ €์žฅ ๋ฐ ๋น ๋ฅธ ์ด๋™) +- [x] ๋ฉ”๋ชจ ๊ด€๋ฆฌ (๊ฒ€์ƒ‰, ํ•„ํ„ฐ๋ง, ํƒœ๊ทธ) +- [x] ๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ (๋ฌธ์„œ + ๋ฉ”๋ชจ ํ†ตํ•ฉ ๊ฒ€์ƒ‰) + +### Phase 5: ์ถ”๊ฐ€ ๊ธฐ๋Šฅ (์˜ˆ์ •) +- [ ] ๋ฌธ์„œ ํƒœ๊ทธ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +- [ ] ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ UI +- [ ] ๊ด€๋ฆฌ์ž ๋Œ€์‹œ๋ณด๋“œ +- [ ] ๋ฌธ์„œ ํ†ต๊ณ„ ๋ฐ ๋ถ„์„ +- [ ] ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ์ตœ์ ํ™” + +## ์„ค์น˜ ๋ฐ ์‹คํ–‰ + +### ๊ฐœ๋ฐœ ํ™˜๊ฒฝ +```bash +# ํ”„๋กœ์ ํŠธ ํด๋ก  +git clone +cd document-server + +# Docker ํ™˜๊ฒฝ ์‹คํ–‰ +docker-compose up -d + +# ๊ฐœ๋ฐœ ๋ชจ๋“œ ์‹คํ–‰ +docker-compose -f docker-compose.dev.yml up +``` + +### ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ +```bash +# ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ +docker-compose -f docker-compose.prod.yml up -d +``` + +## API ์—”๋“œํฌ์ธํŠธ (์˜ˆ์ƒ) + +### ์ธ์ฆ ๊ด€๋ฆฌ +- `POST /api/auth/register` - ํšŒ์›๊ฐ€์ž… +- `POST /api/auth/login` - ๋กœ๊ทธ์ธ +- `POST /api/auth/logout` - ๋กœ๊ทธ์•„์›ƒ +- `POST /api/auth/refresh` - ํ† ํฐ ๊ฐฑ์‹  +- `GET /api/auth/me` - ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด + +### ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ +- `GET /api/users/profile` - ํ”„๋กœํ•„ ์กฐํšŒ +- `PUT /api/users/profile` - ํ”„๋กœํ•„ ์ˆ˜์ • +- `PUT /api/users/password` - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ +- `GET /api/users/preferences` - ์‚ฌ์šฉ์ž ์„ค์ • +- `PUT /api/users/preferences` - ์‚ฌ์šฉ์ž ์„ค์ • ๋ณ€๊ฒฝ + +### ๋ฌธ์„œ ๊ด€๋ฆฌ +- `GET /api/documents` - ๋ฌธ์„œ ๋ชฉ๋ก (์‚ฌ์šฉ์ž๋ณ„ ๊ถŒํ•œ ์ ์šฉ) +- `POST /api/documents` - ๋ฌธ์„œ ์—…๋กœ๋“œ +- `GET /api/documents/:id` - ๋ฌธ์„œ ์ƒ์„ธ +- `DELETE /api/documents/:id` - ๋ฌธ์„œ ์‚ญ์ œ + +### ๊ฒ€์ƒ‰ +- `GET /api/search?q=keyword` - ๋ฌธ์„œ ๊ฒ€์ƒ‰ +- `GET /api/search/advanced` - ๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ + +### ์‚ฌ์šฉ์ž ๊ธฐ๋Šฅ (์ธ์ฆ ํ•„์š”) +- `POST /api/annotations` - ๋ฐ‘์ค„/ํ•˜์ด๋ผ์ดํŠธ ์ €์žฅ +- `GET /api/annotations/:document_id` - ๋ฌธ์„œ๋ณ„ ์ฃผ์„ ์กฐํšŒ +- `GET /api/bookmarks` - ์ฑ…๊ฐˆํ”ผ ๋ชฉ๋ก +- `POST /api/bookmarks` - ์ฑ…๊ฐˆํ”ผ ์ถ”๊ฐ€ +- `POST /api/notes` - ๋ฉ”๋ชจ ์ €์žฅ +- `GET /api/notes/:document_id` - ๋ฌธ์„œ๋ณ„ ๋ฉ”๋ชจ ์กฐํšŒ + +### ๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ +- `GET /api/admin/users` - ์‚ฌ์šฉ์ž ๋ชฉ๋ก +- `PUT /api/admin/users/:id` - ์‚ฌ์šฉ์ž ๊ถŒํ•œ ๋ณ€๊ฒฝ +- `GET /api/admin/documents` - ์ „์ฒด ๋ฌธ์„œ ๊ด€๋ฆฌ + +### Paperless ์—ฐ๋™ +- `GET /api/paperless/download/:id` - ์›๋ณธ PDF ๋‹ค์šด๋กœ๋“œ +- `GET /api/paperless/sync` - Paperless ๋™๊ธฐํ™” + +## ๋ณด์•ˆ ๊ณ ๋ ค์‚ฌํ•ญ + +- ํŒŒ์ผ ์—…๋กœ๋“œ ๊ฒ€์ฆ +- XSS ๋ฐฉ์ง€ +- CSRF ํ† ํฐ +- ์‚ฌ์šฉ์ž ์ธ์ฆ/๊ถŒํ•œ +- ํŒŒ์ผ ์ ‘๊ทผ ์ œ์–ด + +## ์„ฑ๋Šฅ ์ตœ์ ํ™” + +- HTML ๋ฌธ์„œ ์บ์‹ฑ +- ๊ฒ€์ƒ‰ ์ธ๋ฑ์‹ฑ +- ์ด๋ฏธ์ง€ ์ตœ์ ํ™” +- CDN ํ™œ์šฉ (ํ•„์š”์‹œ) + +## ํ–ฅํ›„ ๊ณ„ํš + +- ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ์ง€์› +- ๋‹ค๊ตญ์–ด ์ง€์› +- ํ˜‘์—… ๊ธฐ๋Šฅ (๊ณต์œ , ๋Œ“๊ธ€) +- AI ๊ธฐ๋ฐ˜ ๋ฌธ์„œ ์š”์•ฝ +- ๋ฌธ์„œ ๋ฒ„์ „ ๊ด€๋ฆฌ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ad8a484 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.11-slim + +# ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ • +WORKDIR /app + +# ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์—…๋ฐ์ดํŠธ ๋ฐ ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜ +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Poetry ์„ค์น˜ +RUN pip install poetry + +# Poetry ์„ค์ • +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VENV_IN_PROJECT=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache + +# ์˜์กด์„ฑ ํŒŒ์ผ ๋ณต์‚ฌ +COPY pyproject.toml poetry.lock* ./ + +# ์˜์กด์„ฑ ์„ค์น˜ +RUN poetry install --only=main && rm -rf $POETRY_CACHE_DIR + +# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ ๋ณต์‚ฌ +COPY src/ ./src/ + +# ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +RUN mkdir -p /app/uploads + +# ํฌํŠธ ๋…ธ์ถœ +EXPOSE 8000 + +# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ +CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..d5271da --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ • +WORKDIR /app + +# ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์—…๋ฐ์ดํŠธ ๋ฐ ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜ +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Poetry ์„ค์น˜ +RUN pip install poetry + +# Poetry ์„ค์ • (๊ฐœ๋ฐœ ๋ชจ๋“œ) +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VENV_IN_PROJECT=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache + +# ์˜์กด์„ฑ ํŒŒ์ผ ๋ณต์‚ฌ +COPY pyproject.toml poetry.lock* ./ + +# ๊ฐœ๋ฐœ ์˜์กด์„ฑ ํฌํ•จํ•˜์—ฌ ์„ค์น˜ +RUN poetry install && rm -rf $POETRY_CACHE_DIR + +# ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +RUN mkdir -p /app/uploads + +# ํฌํŠธ ๋…ธ์ถœ +EXPOSE 8000 + +# ๊ฐœ๋ฐœ ๋ชจ๋“œ๋กœ ์‹คํ–‰ (ํ•ซ ๋ฆฌ๋กœ๋“œ) +CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..c27cc9f --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,85 @@ +[tool.poetry] +name = "document-server" +version = "0.1.0" +description = "HTML Document Management and Viewer System" +authors = ["Your Name "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.104.0" +uvicorn = {extras = ["standard"], version = "^0.24.0"} +sqlalchemy = "^2.0.0" +asyncpg = "^0.29.0" +alembic = "^1.12.0" +python-jose = {extras = ["cryptography"], version = "^3.3.0"} +passlib = {extras = ["bcrypt"], version = "^1.7.4"} +python-multipart = "^0.0.6" +pillow = "^10.0.0" +redis = "^5.0.0" +pydantic = {extras = ["email"], version = "^2.4.0"} +pydantic-settings = "^2.0.0" +python-dotenv = "^1.0.0" +httpx = "^0.25.0" +aiofiles = "^23.2.0" +jinja2 = "^3.1.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.0" +pytest-asyncio = "^0.21.0" +black = "^23.9.0" +isort = "^5.12.0" +flake8 = "^6.1.0" +mypy = "^1.6.0" +pre-commit = "^3.5.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["src"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[[tool.mypy.overrides]] +module = [ + "passlib.*", + "jose.*", + "redis.*", +] +ignore_missing_imports = true diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..f69247b --- /dev/null +++ b/backend/src/__init__.py @@ -0,0 +1,3 @@ +""" +Document Server Backend Package +""" diff --git a/backend/src/api/__init__.py b/backend/src/api/__init__.py new file mode 100644 index 0000000..18e93ac --- /dev/null +++ b/backend/src/api/__init__.py @@ -0,0 +1,3 @@ +""" +API ํŒจํ‚ค์ง€ ์ดˆ๊ธฐํ™” +""" diff --git a/backend/src/api/dependencies.py b/backend/src/api/dependencies.py new file mode 100644 index 0000000..9972ca5 --- /dev/null +++ b/backend/src/api/dependencies.py @@ -0,0 +1,88 @@ +""" +API ์˜์กด์„ฑ +""" +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional + +from src.core.database import get_db +from src.core.security import verify_token, get_user_id_from_token +from src.models.user import User + + +# HTTP Bearer ํ† ํฐ ์Šคํ‚ค๋งˆ +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db) +) -> User: + """ํ˜„์žฌ ๋กœ๊ทธ์ธ๋œ ์‚ฌ์šฉ์ž ๊ฐ€์ ธ์˜ค๊ธฐ""" + try: + # ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ID ์ถ”์ถœ + user_id = get_user_id_from_token(credentials.credentials) + + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ฌ์šฉ์ž ์กฐํšŒ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user" + ) + + return user + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + +async def get_current_active_user( + current_user: User = Depends(get_current_user) +) -> User: + """ํ™œ์„ฑ ์‚ฌ์šฉ์ž ํ™•์ธ""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + return current_user + + +async def get_current_admin_user( + current_user: User = Depends(get_current_active_user) +) -> User: + """๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ™•์ธ""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + + +async def get_optional_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db) +) -> Optional[User]: + """์„ ํƒ์  ์‚ฌ์šฉ์ž ์ธ์ฆ (ํ† ํฐ์ด ์—†์–ด๋„ ๋จ)""" + if not credentials: + return None + + try: + return await get_current_user(credentials, db) + except HTTPException: + return None diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py new file mode 100644 index 0000000..c7db491 --- /dev/null +++ b/backend/src/api/routes/__init__.py @@ -0,0 +1,3 @@ +""" +API ๋ผ์šฐํ„ฐ ํŒจํ‚ค์ง€ ์ดˆ๊ธฐํ™” +""" diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py new file mode 100644 index 0000000..30b727e --- /dev/null +++ b/backend/src/api/routes/auth.py @@ -0,0 +1,190 @@ +""" +์ธ์ฆ ๊ด€๋ จ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from datetime import datetime + +from src.core.database import get_db +from src.core.security import verify_password, create_access_token, create_refresh_token, get_password_hash +from src.core.config import settings +from src.models.user import User +from src.schemas.auth import ( + LoginRequest, TokenResponse, RefreshTokenRequest, + UserInfo, ChangePasswordRequest, CreateUserRequest +) +from src.api.dependencies import get_current_active_user, get_current_admin_user + + +router = APIRouter() + + +@router.post("/login", response_model=TokenResponse) +async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ""" + # ์‚ฌ์šฉ์ž ์กฐํšŒ + result = await db.execute( + select(User).where(User.email == login_data.email) + ) + user = result.scalar_one_or_none() + + # ์‚ฌ์šฉ์ž ์กด์žฌ ๋ฐ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ + if not user or not verify_password(login_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + # ๋น„ํ™œ์„ฑ ์‚ฌ์šฉ์ž ํ™•์ธ + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user" + ) + + # ํ† ํฐ ์ƒ์„ฑ + access_token = create_access_token(data={"sub": str(user.id)}) + refresh_token = create_refresh_token(data={"sub": str(user.id)}) + + # ๋งˆ์ง€๋ง‰ ๋กœ๊ทธ์ธ ์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ + await db.execute( + update(User) + .where(User.id == user.id) + .values(last_login=datetime.utcnow()) + ) + await db.commit() + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + refresh_data: RefreshTokenRequest, + db: AsyncSession = Depends(get_db) +): + """ํ† ํฐ ๊ฐฑ์‹ """ + from src.core.security import verify_token + + try: + # ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ๊ฒ€์ฆ + payload = verify_token(refresh_data.refresh_token, token_type="refresh") + user_id = payload.get("sub") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + # ์‚ฌ์šฉ์ž ์กด์žฌ ํ™•์ธ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive" + ) + + # ์ƒˆ ํ† ํฐ ์ƒ์„ฑ + access_token = create_access_token(data={"sub": str(user.id)}) + new_refresh_token = create_refresh_token(data={"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + +@router.get("/me", response_model=UserInfo) +async def get_current_user_info( + current_user: User = Depends(get_current_active_user) +): + """ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ""" + return UserInfo.from_orm(current_user) + + +@router.put("/change-password") +async def change_password( + password_data: ChangePasswordRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ""" + # ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ + if not verify_password(password_data.current_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect current password" + ) + + # ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ ๋ฐ ์—…๋ฐ์ดํŠธ + new_hashed_password = get_password_hash(password_data.new_password) + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(hashed_password=new_hashed_password) + ) + await db.commit() + + return {"message": "Password changed successfully"} + + +@router.post("/create-user", response_model=UserInfo) +async def create_user( + user_data: CreateUserRequest, + admin_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + # ์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ + result = await db.execute( + select(User).where(User.email == user_data.email) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # ์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ + new_user = User( + email=user_data.email, + hashed_password=get_password_hash(user_data.password), + full_name=user_data.full_name, + is_admin=user_data.is_admin, + is_active=True + ) + + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return UserInfo.from_orm(new_user) + + +@router.post("/logout") +async def logout( + current_user: User = Depends(get_current_active_user) +): + """๋กœ๊ทธ์•„์›ƒ (ํด๋ผ์ด์–ธํŠธ์—์„œ ํ† ํฐ ์‚ญ์ œ)""" + # ์‹ค์ œ๋กœ๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ ํ† ํฐ์„ ์‚ญ์ œํ•˜๋ฉด ๋จ + # ํ•„์š”์‹œ ํ† ํฐ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ๊ตฌํ˜„ ๊ฐ€๋Šฅ + return {"message": "Logged out successfully"} diff --git a/backend/src/api/routes/bookmarks.py b/backend/src/api/routes/bookmarks.py new file mode 100644 index 0000000..4bfd4b9 --- /dev/null +++ b/backend/src/api/routes/bookmarks.py @@ -0,0 +1,300 @@ +""" +์ฑ…๊ฐˆํ”ผ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, and_ +from sqlalchemy.orm import joinedload +from typing import List, Optional +from datetime import datetime + +from src.core.database import get_db +from src.models.user import User +from src.models.document import Document +from src.models.bookmark import Bookmark +from src.api.dependencies import get_current_active_user +from pydantic import BaseModel + + +class CreateBookmarkRequest(BaseModel): + """์ฑ…๊ฐˆํ”ผ ์ƒ์„ฑ ์š”์ฒญ""" + document_id: str + title: str + description: Optional[str] = None + page_number: Optional[int] = None + scroll_position: int = 0 + element_id: Optional[str] = None + element_selector: Optional[str] = None + + +class UpdateBookmarkRequest(BaseModel): + """์ฑ…๊ฐˆํ”ผ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + title: Optional[str] = None + description: Optional[str] = None + page_number: Optional[int] = None + scroll_position: Optional[int] = None + element_id: Optional[str] = None + element_selector: Optional[str] = None + + +class BookmarkResponse(BaseModel): + """์ฑ…๊ฐˆํ”ผ ์‘๋‹ต""" + id: str + document_id: str + title: str + description: Optional[str] + page_number: Optional[int] + scroll_position: int + element_id: Optional[str] + element_selector: Optional[str] + created_at: datetime + updated_at: Optional[datetime] + document_title: str + + class Config: + from_attributes = True + + +router = APIRouter() + + +@router.post("/", response_model=BookmarkResponse) +async def create_bookmark( + bookmark_data: CreateBookmarkRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์ƒ์„ฑ""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == bookmark_data.document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ์ฑ…๊ฐˆํ”ผ ์ƒ์„ฑ + bookmark = Bookmark( + user_id=current_user.id, + document_id=bookmark_data.document_id, + title=bookmark_data.title, + description=bookmark_data.description, + page_number=bookmark_data.page_number, + scroll_position=bookmark_data.scroll_position, + element_id=bookmark_data.element_id, + element_selector=bookmark_data.element_selector + ) + + db.add(bookmark) + await db.commit() + await db.refresh(bookmark) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + response_data = BookmarkResponse.from_orm(bookmark) + response_data.document_title = document.title + + return response_data + + +@router.get("/", response_model=List[BookmarkResponse]) +async def list_user_bookmarks( + skip: int = 0, + limit: int = 50, + document_id: Optional[str] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ์ฑ…๊ฐˆํ”ผ ์กฐํšŒ""" + query = ( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where(Bookmark.user_id == current_user.id) + ) + + if document_id: + query = query.where(Bookmark.document_id == document_id) + + query = query.order_by(Bookmark.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + bookmarks = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for bookmark in bookmarks: + bookmark_data = BookmarkResponse.from_orm(bookmark) + bookmark_data.document_title = bookmark.document.title + response_data.append(bookmark_data) + + return response_data + + +@router.get("/document/{document_id}", response_model=List[BookmarkResponse]) +async def get_document_bookmarks( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ๋ฌธ์„œ์˜ ์ฑ…๊ฐˆํ”ผ ๋ชฉ๋ก ์กฐํšŒ""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ์‚ฌ์šฉ์ž์˜ ์ฑ…๊ฐˆํ”ผ๋งŒ ์กฐํšŒ + result = await db.execute( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where( + and_( + Bookmark.document_id == document_id, + Bookmark.user_id == current_user.id + ) + ) + .order_by(Bookmark.page_number, Bookmark.scroll_position) + ) + bookmarks = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for bookmark in bookmarks: + bookmark_data = BookmarkResponse.from_orm(bookmark) + bookmark_data.document_title = bookmark.document.title + response_data.append(bookmark_data) + + return response_data + + +@router.get("/{bookmark_id}", response_model=BookmarkResponse) +async def get_bookmark( + bookmark_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where(Bookmark.id == bookmark_id) + ) + bookmark = result.scalar_one_or_none() + + if not bookmark: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Bookmark not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if bookmark.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + response_data = BookmarkResponse.from_orm(bookmark) + response_data.document_title = bookmark.document.title + + return response_data + + +@router.put("/{bookmark_id}", response_model=BookmarkResponse) +async def update_bookmark( + bookmark_id: str, + bookmark_data: UpdateBookmarkRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์—…๋ฐ์ดํŠธ""" + result = await db.execute( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where(Bookmark.id == bookmark_id) + ) + bookmark = result.scalar_one_or_none() + + if not bookmark: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Bookmark not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if bookmark.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์—…๋ฐ์ดํŠธ + if bookmark_data.title is not None: + bookmark.title = bookmark_data.title + if bookmark_data.description is not None: + bookmark.description = bookmark_data.description + if bookmark_data.page_number is not None: + bookmark.page_number = bookmark_data.page_number + if bookmark_data.scroll_position is not None: + bookmark.scroll_position = bookmark_data.scroll_position + if bookmark_data.element_id is not None: + bookmark.element_id = bookmark_data.element_id + if bookmark_data.element_selector is not None: + bookmark.element_selector = bookmark_data.element_selector + + await db.commit() + await db.refresh(bookmark) + + response_data = BookmarkResponse.from_orm(bookmark) + response_data.document_title = bookmark.document.title + + return response_data + + +@router.delete("/{bookmark_id}") +async def delete_bookmark( + bookmark_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์‚ญ์ œ""" + result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id)) + bookmark = result.scalar_one_or_none() + + if not bookmark: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Bookmark not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if bookmark.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์ฑ…๊ฐˆํ”ผ ์‚ญ์ œ + await db.execute(delete(Bookmark).where(Bookmark.id == bookmark_id)) + await db.commit() + + return {"message": "Bookmark deleted successfully"} diff --git a/backend/src/api/routes/documents.py b/backend/src/api/routes/documents.py new file mode 100644 index 0000000..299ee17 --- /dev/null +++ b/backend/src/api/routes/documents.py @@ -0,0 +1,359 @@ +""" +๋ฌธ์„œ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, and_, or_ +from sqlalchemy.orm import selectinload +from typing import List, Optional +import os +import uuid +import aiofiles +from pathlib import Path + +from src.core.database import get_db +from src.core.config import settings +from src.models.user import User +from src.models.document import Document, Tag +from src.api.dependencies import get_current_active_user, get_current_admin_user +from pydantic import BaseModel +from datetime import datetime + + +class DocumentResponse(BaseModel): + """๋ฌธ์„œ ์‘๋‹ต""" + id: str + title: str + description: Optional[str] + html_path: str + pdf_path: Optional[str] + thumbnail_path: Optional[str] + file_size: Optional[int] + page_count: Optional[int] + language: str + is_public: bool + is_processed: bool + created_at: datetime + updated_at: Optional[datetime] + document_date: Optional[datetime] + uploader_name: Optional[str] + tags: List[str] = [] + + class Config: + from_attributes = True + + +class TagResponse(BaseModel): + """ํƒœ๊ทธ ์‘๋‹ต""" + id: str + name: str + color: str + description: Optional[str] + document_count: int = 0 + + class Config: + from_attributes = True + + +class CreateTagRequest(BaseModel): + """ํƒœ๊ทธ ์ƒ์„ฑ ์š”์ฒญ""" + name: str + color: str = "#3B82F6" + description: Optional[str] = None + + +router = APIRouter() + + +@router.get("/", response_model=List[DocumentResponse]) +async def list_documents( + skip: int = 0, + limit: int = 50, + tag: Optional[str] = None, + search: Optional[str] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ๋ชฉ๋ก ์กฐํšŒ""" + query = select(Document).options( + selectinload(Document.uploader), + selectinload(Document.tags) + ) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง (๊ด€๋ฆฌ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ๊ณต๊ฐœ ๋ฌธ์„œ + ์ž์‹ ์ด ์—…๋กœ๋“œํ•œ ๋ฌธ์„œ๋งŒ) + if not current_user.is_admin: + query = query.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + + # ํƒœ๊ทธ ํ•„ํ„ฐ๋ง + if tag: + query = query.join(Document.tags).where(Tag.name == tag) + + # ๊ฒ€์ƒ‰ ํ•„ํ„ฐ๋ง + if search: + query = query.where( + or_( + Document.title.ilike(f"%{search}%"), + Document.description.ilike(f"%{search}%") + ) + ) + + query = query.order_by(Document.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + documents = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for doc in documents: + doc_data = DocumentResponse.from_orm(doc) + doc_data.uploader_name = doc.uploader.full_name or doc.uploader.email + doc_data.tags = [tag.name for tag in doc.tags] + response_data.append(doc_data) + + return response_data + + +@router.post("/", response_model=DocumentResponse) +async def upload_document( + title: str = Form(...), + description: Optional[str] = Form(None), + document_date: Optional[str] = Form(None), + is_public: bool = Form(False), + tags: Optional[str] = Form(None), # ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„๋œ ํƒœ๊ทธ + html_file: UploadFile = File(...), + pdf_file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์—…๋กœ๋“œ""" + # ํŒŒ์ผ ํ™•์žฅ์ž ํ™•์ธ + if not html_file.filename.lower().endswith(('.html', '.htm')): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only HTML files are allowed for the main document" + ) + + if pdf_file and not pdf_file.filename.lower().endswith('.pdf'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only PDF files are allowed for the original document" + ) + + # ๊ณ ์œ  ํŒŒ์ผ๋ช… ์ƒ์„ฑ + doc_id = str(uuid.uuid4()) + html_filename = f"{doc_id}.html" + pdf_filename = f"{doc_id}.pdf" if pdf_file else None + + # ํŒŒ์ผ ์ €์žฅ ๊ฒฝ๋กœ + html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename) + pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None + + try: + # HTML ํŒŒ์ผ ์ €์žฅ + async with aiofiles.open(html_path, 'wb') as f: + content = await html_file.read() + await f.write(content) + + # PDF ํŒŒ์ผ ์ €์žฅ (์žˆ๋Š” ๊ฒฝ์šฐ) + if pdf_file and pdf_path: + async with aiofiles.open(pdf_path, 'wb') as f: + content = await pdf_file.read() + await f.write(content) + + # ๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ + document = Document( + id=doc_id, + title=title, + description=description, + html_path=html_path, + pdf_path=pdf_path, + file_size=len(await html_file.read()) if html_file else None, + uploaded_by=current_user.id, + original_filename=html_file.filename, + is_public=is_public, + document_date=datetime.fromisoformat(document_date) if document_date else None + ) + + db.add(document) + await db.flush() # ID ์ƒ์„ฑ์„ ์œ„ํ•ด + + # ํƒœ๊ทธ ์ฒ˜๋ฆฌ + if tags: + tag_names = [tag.strip() for tag in tags.split(',') if tag.strip()] + for tag_name in tag_names: + # ๊ธฐ์กด ํƒœ๊ทธ ์ฐพ๊ธฐ ๋˜๋Š” ์ƒ์„ฑ + result = await db.execute(select(Tag).where(Tag.name == tag_name)) + tag = result.scalar_one_or_none() + + if not tag: + tag = Tag( + name=tag_name, + created_by=current_user.id + ) + db.add(tag) + await db.flush() + + document.tags.append(tag) + + await db.commit() + await db.refresh(document) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + response_data = DocumentResponse.from_orm(document) + response_data.uploader_name = current_user.full_name or current_user.email + response_data.tags = [tag.name for tag in document.tags] + + return response_data + + except Exception as e: + # ํŒŒ์ผ ์ •๋ฆฌ + if os.path.exists(html_path): + os.remove(html_path) + if pdf_path and os.path.exists(pdf_path): + os.remove(pdf_path) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to upload document: {str(e)}" + ) + + +@router.get("/{document_id}", response_model=DocumentResponse) +async def get_document( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute( + select(Document) + .options(selectinload(Document.uploader), selectinload(Document.tags)) + .where(Document.id == document_id) + ) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + response_data = DocumentResponse.from_orm(document) + response_data.uploader_name = document.uploader.full_name or document.uploader.email + response_data.tags = [tag.name for tag in document.tags] + + return response_data + + +@router.delete("/{document_id}") +async def delete_document( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์‚ญ์ œ""" + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ (์—…๋กœ๋” ๋˜๋Š” ๊ด€๋ฆฌ์ž๋งŒ) + if document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ํŒŒ์ผ ์‚ญ์ œ + if document.html_path and os.path.exists(document.html_path): + os.remove(document.html_path) + if document.pdf_path and os.path.exists(document.pdf_path): + os.remove(document.pdf_path) + if document.thumbnail_path and os.path.exists(document.thumbnail_path): + os.remove(document.thumbnail_path) + + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ญ์ œ + await db.execute(delete(Document).where(Document.id == document_id)) + await db.commit() + + return {"message": "Document deleted successfully"} + + +@router.get("/tags/", response_model=List[TagResponse]) +async def list_tags( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ""" + result = await db.execute(select(Tag).order_by(Tag.name)) + tags = result.scalars().all() + + # ๊ฐ ํƒœ๊ทธ์˜ ๋ฌธ์„œ ์ˆ˜ ๊ณ„์‚ฐ + response_data = [] + for tag in tags: + tag_data = TagResponse.from_orm(tag) + # ๋ฌธ์„œ ์ˆ˜ ๊ณ„์‚ฐ (๊ถŒํ•œ ๊ณ ๋ ค) + doc_query = select(Document).join(Document.tags).where(Tag.id == tag.id) + if not current_user.is_admin: + doc_query = doc_query.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + doc_result = await db.execute(doc_query) + tag_data.document_count = len(doc_result.scalars().all()) + response_data.append(tag_data) + + return response_data + + +@router.post("/tags/", response_model=TagResponse) +async def create_tag( + tag_data: CreateTagRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํƒœ๊ทธ ์ƒ์„ฑ""" + # ์ค‘๋ณต ํ™•์ธ + result = await db.execute(select(Tag).where(Tag.name == tag_data.name)) + existing_tag = result.scalar_one_or_none() + + if existing_tag: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tag already exists" + ) + + # ํƒœ๊ทธ ์ƒ์„ฑ + tag = Tag( + name=tag_data.name, + color=tag_data.color, + description=tag_data.description, + created_by=current_user.id + ) + + db.add(tag) + await db.commit() + await db.refresh(tag) + + response_data = TagResponse.from_orm(tag) + response_data.document_count = 0 + + return response_data diff --git a/backend/src/api/routes/highlights.py b/backend/src/api/routes/highlights.py new file mode 100644 index 0000000..03efbc2 --- /dev/null +++ b/backend/src/api/routes/highlights.py @@ -0,0 +1,340 @@ +""" +ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, and_ +from sqlalchemy.orm import selectinload +from typing import List, Optional +from datetime import datetime + +from src.core.database import get_db +from src.models.user import User +from src.models.document import Document +from src.models.highlight import Highlight +from src.models.note import Note +from src.api.dependencies import get_current_active_user +from pydantic import BaseModel + + +class CreateHighlightRequest(BaseModel): + """ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ ์š”์ฒญ""" + document_id: str + start_offset: int + end_offset: int + selected_text: str + element_selector: Optional[str] = None + start_container_xpath: Optional[str] = None + end_container_xpath: Optional[str] = None + highlight_color: str = "#FFFF00" + highlight_type: str = "highlight" + note_content: Optional[str] = None # ๋ฐ”๋กœ ๋ฉ”๋ชจ ์ถ”๊ฐ€ + + +class UpdateHighlightRequest(BaseModel): + """ํ•˜์ด๋ผ์ดํŠธ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + highlight_color: Optional[str] = None + highlight_type: Optional[str] = None + + +class HighlightResponse(BaseModel): + """ํ•˜์ด๋ผ์ดํŠธ ์‘๋‹ต""" + id: str + document_id: str + start_offset: int + end_offset: int + selected_text: str + element_selector: Optional[str] + start_container_xpath: Optional[str] + end_container_xpath: Optional[str] + highlight_color: str + highlight_type: str + created_at: datetime + updated_at: Optional[datetime] + note: Optional[dict] = None # ์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ ์ •๋ณด + + class Config: + from_attributes = True + + +router = APIRouter() + + +@router.post("/", response_model=HighlightResponse) +async def create_highlight( + highlight_data: CreateHighlightRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ (๋ฉ”๋ชจ ํฌํ•จ ๊ฐ€๋Šฅ)""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == highlight_data.document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + highlight = Highlight( + user_id=current_user.id, + document_id=highlight_data.document_id, + start_offset=highlight_data.start_offset, + end_offset=highlight_data.end_offset, + selected_text=highlight_data.selected_text, + element_selector=highlight_data.element_selector, + start_container_xpath=highlight_data.start_container_xpath, + end_container_xpath=highlight_data.end_container_xpath, + highlight_color=highlight_data.highlight_color, + highlight_type=highlight_data.highlight_type + ) + + db.add(highlight) + await db.flush() # ID ์ƒ์„ฑ์„ ์œ„ํ•ด + + # ๋ฉ”๋ชจ๊ฐ€ ์žˆ์œผ๋ฉด ํ•จ๊ป˜ ์ƒ์„ฑ + note = None + if highlight_data.note_content: + note = Note( + highlight_id=highlight.id, + content=highlight_data.note_content + ) + db.add(note) + + await db.commit() + await db.refresh(highlight) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + response_data = HighlightResponse.from_orm(highlight) + if note: + response_data.note = { + "id": str(note.id), + "content": note.content, + "tags": note.tags, + "created_at": note.created_at.isoformat(), + "updated_at": note.updated_at.isoformat() if note.updated_at else None + } + + return response_data + + +@router.get("/document/{document_id}", response_model=List[HighlightResponse]) +async def get_document_highlights( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ชฉ๋ก ์กฐํšŒ""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ์‚ฌ์šฉ์ž์˜ ํ•˜์ด๋ผ์ดํŠธ๋งŒ ์กฐํšŒ + result = await db.execute( + select(Highlight) + .options(selectinload(Highlight.note)) + .where( + and_( + Highlight.document_id == document_id, + Highlight.user_id == current_user.id + ) + ) + .order_by(Highlight.start_offset) + ) + highlights = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for highlight in highlights: + highlight_data = HighlightResponse.from_orm(highlight) + if highlight.note: + highlight_data.note = { + "id": str(highlight.note.id), + "content": highlight.note.content, + "tags": highlight.note.tags, + "created_at": highlight.note.created_at.isoformat(), + "updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None + } + response_data.append(highlight_data) + + return response_data + + +@router.get("/{highlight_id}", response_model=HighlightResponse) +async def get_highlight( + highlight_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute( + select(Highlight) + .options(selectinload(Highlight.note)) + .where(Highlight.id == highlight_id) + ) + highlight = result.scalar_one_or_none() + + if not highlight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Highlight not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + response_data = HighlightResponse.from_orm(highlight) + if highlight.note: + response_data.note = { + "id": str(highlight.note.id), + "content": highlight.note.content, + "tags": highlight.note.tags, + "created_at": highlight.note.created_at.isoformat(), + "updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None + } + + return response_data + + +@router.put("/{highlight_id}", response_model=HighlightResponse) +async def update_highlight( + highlight_id: str, + highlight_data: UpdateHighlightRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์—…๋ฐ์ดํŠธ""" + result = await db.execute( + select(Highlight) + .options(selectinload(Highlight.note)) + .where(Highlight.id == highlight_id) + ) + highlight = result.scalar_one_or_none() + + if not highlight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Highlight not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์—…๋ฐ์ดํŠธ + if highlight_data.highlight_color: + highlight.highlight_color = highlight_data.highlight_color + if highlight_data.highlight_type: + highlight.highlight_type = highlight_data.highlight_type + + await db.commit() + await db.refresh(highlight) + + response_data = HighlightResponse.from_orm(highlight) + if highlight.note: + response_data.note = { + "id": str(highlight.note.id), + "content": highlight.note.content, + "tags": highlight.note.tags, + "created_at": highlight.note.created_at.isoformat(), + "updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None + } + + return response_data + + +@router.delete("/{highlight_id}") +async def delete_highlight( + highlight_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ (์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ๋„ ํ•จ๊ป˜ ์‚ญ์ œ)""" + result = await db.execute(select(Highlight).where(Highlight.id == highlight_id)) + highlight = result.scalar_one_or_none() + + if not highlight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Highlight not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ (CASCADE๋กœ ๋ฉ”๋ชจ๋„ ํ•จ๊ป˜ ์‚ญ์ œ๋จ) + await db.execute(delete(Highlight).where(Highlight.id == highlight_id)) + await db.commit() + + return {"message": "Highlight deleted successfully"} + + +@router.get("/", response_model=List[HighlightResponse]) +async def list_user_highlights( + skip: int = 0, + limit: int = 50, + document_id: Optional[str] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ์กฐํšŒ""" + query = select(Highlight).options(selectinload(Highlight.note)).where( + Highlight.user_id == current_user.id + ) + + if document_id: + query = query.where(Highlight.document_id == document_id) + + query = query.order_by(Highlight.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + highlights = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for highlight in highlights: + highlight_data = HighlightResponse.from_orm(highlight) + if highlight.note: + highlight_data.note = { + "id": str(highlight.note.id), + "content": highlight.note.content, + "tags": highlight.note.tags, + "created_at": highlight.note.created_at.isoformat(), + "updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None + } + response_data.append(highlight_data) + + return response_data diff --git a/backend/src/api/routes/notes.py b/backend/src/api/routes/notes.py new file mode 100644 index 0000000..74dce17 --- /dev/null +++ b/backend/src/api/routes/notes.py @@ -0,0 +1,404 @@ +""" +๋ฉ”๋ชจ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, and_, or_ +from sqlalchemy.orm import selectinload, joinedload +from typing import List, Optional +from datetime import datetime + +from src.core.database import get_db +from src.models.user import User +from src.models.highlight import Highlight +from src.models.note import Note +from src.models.document import Document +from src.api.dependencies import get_current_active_user +from pydantic import BaseModel + + +class CreateNoteRequest(BaseModel): + """๋ฉ”๋ชจ ์ƒ์„ฑ ์š”์ฒญ""" + highlight_id: str + content: str + tags: Optional[List[str]] = None + + +class UpdateNoteRequest(BaseModel): + """๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + content: Optional[str] = None + tags: Optional[List[str]] = None + is_private: Optional[bool] = None + + +class NoteResponse(BaseModel): + """๋ฉ”๋ชจ ์‘๋‹ต""" + id: str + highlight_id: str + content: str + is_private: bool + tags: Optional[List[str]] + created_at: datetime + updated_at: Optional[datetime] + # ์—ฐ๊ฒฐ๋œ ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด + highlight: dict + # ๋ฌธ์„œ ์ •๋ณด + document: dict + + class Config: + from_attributes = True + + +router = APIRouter() + + +@router.post("/", response_model=NoteResponse) +async def create_note( + note_data: CreateNoteRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ์ƒ์„ฑ (ํ•˜์ด๋ผ์ดํŠธ์— ์—ฐ๊ฒฐ)""" + # ํ•˜์ด๋ผ์ดํŠธ ์กด์žฌ ๋ฐ ์†Œ์œ ๊ถŒ ํ™•์ธ + result = await db.execute( + select(Highlight) + .options(joinedload(Highlight.document)) + .where(Highlight.id == note_data.highlight_id) + ) + highlight = result.scalar_one_or_none() + + if not highlight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Highlight not found" + ) + + # ํ•˜์ด๋ผ์ดํŠธ ์†Œ์œ ์ž ํ™•์ธ + if highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์ด๋ฏธ ๋ฉ”๋ชจ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + existing_note = await db.execute( + select(Note).where(Note.highlight_id == note_data.highlight_id) + ) + if existing_note.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Note already exists for this highlight" + ) + + # ๋ฉ”๋ชจ ์ƒ์„ฑ + note = Note( + highlight_id=note_data.highlight_id, + content=note_data.content, + tags=note_data.tags or [] + ) + + db.add(note) + await db.commit() + await db.refresh(note) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + response_data = NoteResponse.from_orm(note) + response_data.highlight = { + "id": str(highlight.id), + "selected_text": highlight.selected_text, + "highlight_color": highlight.highlight_color, + "start_offset": highlight.start_offset, + "end_offset": highlight.end_offset + } + response_data.document = { + "id": str(highlight.document.id), + "title": highlight.document.title + } + + return response_data + + +@router.get("/", response_model=List[NoteResponse]) +async def list_user_notes( + skip: int = 0, + limit: int = 50, + document_id: Optional[str] = None, + tag: Optional[str] = None, + search: Optional[str] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ๋ฉ”๋ชจ ์กฐํšŒ (๊ฒ€์ƒ‰ ๊ฐ€๋Šฅ)""" + query = ( + select(Note) + .options( + joinedload(Note.highlight).joinedload(Highlight.document) + ) + .join(Highlight) + .where(Highlight.user_id == current_user.id) + ) + + # ๋ฌธ์„œ ํ•„ํ„ฐ๋ง + if document_id: + query = query.where(Highlight.document_id == document_id) + + # ํƒœ๊ทธ ํ•„ํ„ฐ๋ง + if tag: + query = query.where(Note.tags.contains([tag])) + + # ๊ฒ€์ƒ‰ ํ•„ํ„ฐ๋ง (๋ฉ”๋ชจ ๋‚ด์šฉ + ํ•˜์ด๋ผ์ดํŠธ๋œ ํ…์ŠคํŠธ) + if search: + query = query.where( + or_( + Note.content.ilike(f"%{search}%"), + Highlight.selected_text.ilike(f"%{search}%") + ) + ) + + query = query.order_by(Note.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + notes = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for note in notes: + note_data = NoteResponse.from_orm(note) + note_data.highlight = { + "id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "highlight_color": note.highlight.highlight_color, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset + } + note_data.document = { + "id": str(note.highlight.document.id), + "title": note.highlight.document.title + } + response_data.append(note_data) + + return response_data + + +@router.get("/{note_id}", response_model=NoteResponse) +async def get_note( + note_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute( + select(Note) + .options( + joinedload(Note.highlight).joinedload(Highlight.document) + ) + .where(Note.id == note_id) + ) + note = result.scalar_one_or_none() + + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if note.highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + response_data = NoteResponse.from_orm(note) + response_data.highlight = { + "id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "highlight_color": note.highlight.highlight_color, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset + } + response_data.document = { + "id": str(note.highlight.document.id), + "title": note.highlight.document.title + } + + return response_data + + +@router.put("/{note_id}", response_model=NoteResponse) +async def update_note( + note_id: str, + note_data: UpdateNoteRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ""" + result = await db.execute( + select(Note) + .options( + joinedload(Note.highlight).joinedload(Highlight.document) + ) + .where(Note.id == note_id) + ) + note = result.scalar_one_or_none() + + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if note.highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์—…๋ฐ์ดํŠธ + if note_data.content is not None: + note.content = note_data.content + if note_data.tags is not None: + note.tags = note_data.tags + if note_data.is_private is not None: + note.is_private = note_data.is_private + + await db.commit() + await db.refresh(note) + + response_data = NoteResponse.from_orm(note) + response_data.highlight = { + "id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "highlight_color": note.highlight.highlight_color, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset + } + response_data.document = { + "id": str(note.highlight.document.id), + "title": note.highlight.document.title + } + + return response_data + + +@router.delete("/{note_id}") +async def delete_note( + note_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ์‚ญ์ œ (ํ•˜์ด๋ผ์ดํŠธ๋Š” ์œ ์ง€)""" + result = await db.execute( + select(Note) + .options(joinedload(Note.highlight)) + .where(Note.id == note_id) + ) + note = result.scalar_one_or_none() + + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if note.highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ๋ฉ”๋ชจ๋งŒ ์‚ญ์ œ (ํ•˜์ด๋ผ์ดํŠธ๋Š” ์œ ์ง€) + await db.execute(delete(Note).where(Note.id == note_id)) + await db.commit() + + return {"message": "Note deleted successfully"} + + +@router.get("/document/{document_id}", response_model=List[NoteResponse]) +async def get_document_notes( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ๋ฌธ์„œ์˜ ๋ชจ๋“  ๋ฉ”๋ชจ ์กฐํšŒ""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ํ•ด๋‹น ๋ฌธ์„œ์˜ ์‚ฌ์šฉ์ž ๋ฉ”๋ชจ ์กฐํšŒ + result = await db.execute( + select(Note) + .options( + joinedload(Note.highlight).joinedload(Highlight.document) + ) + .join(Highlight) + .where( + and_( + Highlight.document_id == document_id, + Highlight.user_id == current_user.id + ) + ) + .order_by(Highlight.start_offset) + ) + notes = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for note in notes: + note_data = NoteResponse.from_orm(note) + note_data.highlight = { + "id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "highlight_color": note.highlight.highlight_color, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset + } + note_data.document = { + "id": str(note.highlight.document.id), + "title": note.highlight.document.title + } + response_data.append(note_data) + + return response_data + + +@router.get("/tags/popular") +async def get_popular_note_tags( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ธ๊ธฐ ๋ฉ”๋ชจ ํƒœ๊ทธ ์กฐํšŒ""" + # ์‚ฌ์šฉ์ž์˜ ๋ฉ”๋ชจ์—์„œ ํƒœ๊ทธ ๋นˆ๋„ ๊ณ„์‚ฐ + result = await db.execute( + select(Note) + .join(Highlight) + .where(Highlight.user_id == current_user.id) + ) + notes = result.scalars().all() + + # ํƒœ๊ทธ ๋นˆ๋„ ๊ณ„์‚ฐ + tag_counts = {} + for note in notes: + if note.tags: + for tag in note.tags: + tag_counts[tag] = tag_counts.get(tag, 0) + 1 + + # ๋นˆ๋„์ˆœ ์ •๋ ฌ + popular_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10] + + return [{"tag": tag, "count": count} for tag, count in popular_tags] diff --git a/backend/src/api/routes/search.py b/backend/src/api/routes/search.py new file mode 100644 index 0000000..beff629 --- /dev/null +++ b/backend/src/api/routes/search.py @@ -0,0 +1,354 @@ +""" +๊ฒ€์ƒ‰ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_, and_, text +from sqlalchemy.orm import joinedload, selectinload +from typing import List, Optional, Dict, Any +from datetime import datetime + +from src.core.database import get_db +from src.models.user import User +from src.models.document import Document, Tag +from src.models.highlight import Highlight +from src.models.note import Note +from src.api.dependencies import get_current_active_user +from pydantic import BaseModel + + +class SearchResult(BaseModel): + """๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ""" + type: str # "document", "note", "highlight" + id: str + title: str + content: str + document_id: str + document_title: str + created_at: datetime + relevance_score: float = 0.0 + highlight_info: Optional[Dict[str, Any]] = None + + class Config: + from_attributes = True + + +class SearchResponse(BaseModel): + """๊ฒ€์ƒ‰ ์‘๋‹ต""" + query: str + total_results: int + results: List[SearchResult] + facets: Dict[str, List[Dict[str, Any]]] = {} + + +router = APIRouter() + + +@router.get("/", response_model=SearchResponse) +async def search_all( + q: str = Query(..., description="๊ฒ€์ƒ‰์–ด"), + type_filter: Optional[str] = Query(None, description="๊ฒ€์ƒ‰ ํƒ€์ž… ํ•„ํ„ฐ: document, note, highlight"), + document_id: Optional[str] = Query(None, description="ํŠน์ • ๋ฌธ์„œ ๋‚ด ๊ฒ€์ƒ‰"), + tag: Optional[str] = Query(None, description="ํƒœ๊ทธ ํ•„ํ„ฐ"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ†ตํ•ฉ ๊ฒ€์ƒ‰ (๋ฌธ์„œ + ๋ฉ”๋ชจ + ํ•˜์ด๋ผ์ดํŠธ)""" + results = [] + + # 1. ๋ฌธ์„œ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "document": + document_results = await search_documents(q, document_id, tag, current_user, db) + results.extend(document_results) + + # 2. ๋ฉ”๋ชจ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "note": + note_results = await search_notes(q, document_id, tag, current_user, db) + results.extend(note_results) + + # 3. ํ•˜์ด๋ผ์ดํŠธ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "highlight": + highlight_results = await search_highlights(q, document_id, current_user, db) + results.extend(highlight_results) + + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜๋กœ ์ •๋ ฌ + results.sort(key=lambda x: x.relevance_score, reverse=True) + + # ํŽ˜์ด์ง€๋„ค์ด์…˜ + total_results = len(results) + paginated_results = results[skip:skip + limit] + + # ํŒจ์‹ฏ ์ •๋ณด ์ƒ์„ฑ + facets = await generate_search_facets(results, current_user, db) + + return SearchResponse( + query=q, + total_results=total_results, + results=paginated_results, + facets=facets + ) + + +async def search_documents( + query: str, + document_id: Optional[str], + tag: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """๋ฌธ์„œ ๊ฒ€์ƒ‰""" + query_obj = select(Document).options( + selectinload(Document.uploader), + selectinload(Document.tags) + ) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง + if not current_user.is_admin: + query_obj = query_obj.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + + # ํŠน์ • ๋ฌธ์„œ ํ•„ํ„ฐ + if document_id: + query_obj = query_obj.where(Document.id == document_id) + + # ํƒœ๊ทธ ํ•„ํ„ฐ + if tag: + query_obj = query_obj.join(Document.tags).where(Tag.name == tag) + + # ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ + search_condition = or_( + Document.title.ilike(f"%{query}%"), + Document.description.ilike(f"%{query}%") + ) + query_obj = query_obj.where(search_condition) + + result = await db.execute(query_obj) + documents = result.scalars().all() + + search_results = [] + for doc in documents: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ (์ œ๋ชฉ ๋งค์น˜๊ฐ€ ๋” ๋†’์€ ์ ์ˆ˜) + score = 0.0 + if query.lower() in doc.title.lower(): + score += 2.0 + if doc.description and query.lower() in doc.description.lower(): + score += 1.0 + + search_results.append(SearchResult( + type="document", + id=str(doc.id), + title=doc.title, + content=doc.description or "", + document_id=str(doc.id), + document_title=doc.title, + created_at=doc.created_at, + relevance_score=score + )) + + return search_results + + +async def search_notes( + query: str, + document_id: Optional[str], + tag: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """๋ฉ”๋ชจ ๊ฒ€์ƒ‰""" + query_obj = ( + select(Note) + .options( + joinedload(Note.highlight).joinedload(Highlight.document) + ) + .join(Highlight) + .where(Highlight.user_id == current_user.id) + ) + + # ํŠน์ • ๋ฌธ์„œ ํ•„ํ„ฐ + if document_id: + query_obj = query_obj.where(Highlight.document_id == document_id) + + # ํƒœ๊ทธ ํ•„ํ„ฐ + if tag: + query_obj = query_obj.where(Note.tags.contains([tag])) + + # ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ (๋ฉ”๋ชจ ๋‚ด์šฉ + ํ•˜์ด๋ผ์ดํŠธ๋œ ํ…์ŠคํŠธ) + search_condition = or_( + Note.content.ilike(f"%{query}%"), + Highlight.selected_text.ilike(f"%{query}%") + ) + query_obj = query_obj.where(search_condition) + + result = await db.execute(query_obj) + notes = result.scalars().all() + + search_results = [] + for note in notes: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 0.0 + if query.lower() in note.content.lower(): + score += 2.0 + if query.lower() in note.highlight.selected_text.lower(): + score += 1.5 + + search_results.append(SearchResult( + type="note", + id=str(note.id), + title=f"๋ฉ”๋ชจ: {note.highlight.selected_text[:50]}...", + content=note.content, + document_id=str(note.highlight.document.id), + document_title=note.highlight.document.title, + created_at=note.created_at, + relevance_score=score, + highlight_info={ + "highlight_id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset + } + )) + + return search_results + + +async def search_highlights( + query: str, + document_id: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """ํ•˜์ด๋ผ์ดํŠธ ๊ฒ€์ƒ‰""" + query_obj = ( + select(Highlight) + .options(joinedload(Highlight.document)) + .where(Highlight.user_id == current_user.id) + ) + + # ํŠน์ • ๋ฌธ์„œ ํ•„ํ„ฐ + if document_id: + query_obj = query_obj.where(Highlight.document_id == document_id) + + # ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ + query_obj = query_obj.where(Highlight.selected_text.ilike(f"%{query}%")) + + result = await db.execute(query_obj) + highlights = result.scalars().all() + + search_results = [] + for highlight in highlights: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 1.0 if query.lower() in highlight.selected_text.lower() else 0.5 + + search_results.append(SearchResult( + type="highlight", + id=str(highlight.id), + title=f"ํ•˜์ด๋ผ์ดํŠธ: {highlight.selected_text[:50]}...", + content=highlight.selected_text, + document_id=str(highlight.document.id), + document_title=highlight.document.title, + created_at=highlight.created_at, + relevance_score=score, + highlight_info={ + "highlight_id": str(highlight.id), + "selected_text": highlight.selected_text, + "start_offset": highlight.start_offset, + "end_offset": highlight.end_offset, + "highlight_color": highlight.highlight_color + } + )) + + return search_results + + +async def generate_search_facets( + results: List[SearchResult], + current_user: User, + db: AsyncSession +) -> Dict[str, List[Dict[str, Any]]]: + """๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํŒจ์‹ฏ ์ƒ์„ฑ""" + facets = {} + + # ํƒ€์ž…๋ณ„ ๊ฐœ์ˆ˜ + type_counts = {} + for result in results: + type_counts[result.type] = type_counts.get(result.type, 0) + 1 + + facets["types"] = [ + {"name": type_name, "count": count} + for type_name, count in type_counts.items() + ] + + # ๋ฌธ์„œ๋ณ„ ๊ฐœ์ˆ˜ + document_counts = {} + for result in results: + doc_title = result.document_title + document_counts[doc_title] = document_counts.get(doc_title, 0) + 1 + + facets["documents"] = [ + {"name": doc_title, "count": count} + for doc_title, count in sorted(document_counts.items(), key=lambda x: x[1], reverse=True)[:10] + ] + + return facets + + +@router.get("/suggestions") +async def get_search_suggestions( + q: str = Query(..., min_length=2, description="๊ฒ€์ƒ‰์–ด (์ตœ์†Œ 2๊ธ€์ž)"), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๊ฒ€์ƒ‰์–ด ์ž๋™์™„์„ฑ ์ œ์•ˆ""" + suggestions = [] + + # ๋ฌธ์„œ ์ œ๋ชฉ์—์„œ ์ œ์•ˆ + doc_result = await db.execute( + select(Document.title) + .where( + and_( + Document.title.ilike(f"%{q}%"), + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) if not current_user.is_admin else text("true") + ) + ) + .limit(5) + ) + doc_titles = doc_result.scalars().all() + suggestions.extend([{"text": title, "type": "document"} for title in doc_titles]) + + # ํƒœ๊ทธ์—์„œ ์ œ์•ˆ + tag_result = await db.execute( + select(Tag.name) + .where(Tag.name.ilike(f"%{q}%")) + .limit(5) + ) + tag_names = tag_result.scalars().all() + suggestions.extend([{"text": name, "type": "tag"} for name in tag_names]) + + # ๋ฉ”๋ชจ ํƒœ๊ทธ์—์„œ ์ œ์•ˆ + note_result = await db.execute( + select(Note.tags) + .join(Highlight) + .where(Highlight.user_id == current_user.id) + ) + notes = note_result.scalars().all() + + note_tags = set() + for note in notes: + if note and isinstance(note, list): + for tag in note: + if q.lower() in tag.lower(): + note_tags.add(tag) + + suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]]) + + return {"suggestions": suggestions[:10]} diff --git a/backend/src/api/routes/users.py b/backend/src/api/routes/users.py new file mode 100644 index 0000000..2afbdf2 --- /dev/null +++ b/backend/src/api/routes/users.py @@ -0,0 +1,176 @@ +""" +์‚ฌ์šฉ์ž ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from typing import List + +from src.core.database import get_db +from src.models.user import User +from src.schemas.auth import UserInfo +from src.api.dependencies import get_current_active_user, get_current_admin_user +from pydantic import BaseModel + + +class UpdateProfileRequest(BaseModel): + """ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + full_name: str = None + theme: str = None + language: str = None + timezone: str = None + + +class UpdateUserRequest(BaseModel): + """์‚ฌ์šฉ์ž ์ •๋ณด ์—…๋ฐ์ดํŠธ ์š”์ฒญ (๊ด€๋ฆฌ์ž์šฉ)""" + full_name: str = None + is_active: bool = None + is_admin: bool = None + + +router = APIRouter() + + +@router.get("/profile", response_model=UserInfo) +async def get_profile( + current_user: User = Depends(get_current_active_user) +): + """ํ˜„์žฌ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ""" + return UserInfo.from_orm(current_user) + + +@router.put("/profile", response_model=UserInfo) +async def update_profile( + profile_data: UpdateProfileRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ""" + update_data = {} + + if profile_data.full_name is not None: + update_data["full_name"] = profile_data.full_name + if profile_data.theme is not None: + update_data["theme"] = profile_data.theme + if profile_data.language is not None: + update_data["language"] = profile_data.language + if profile_data.timezone is not None: + update_data["timezone"] = profile_data.timezone + + if update_data: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(**update_data) + ) + await db.commit() + await db.refresh(current_user) + + return UserInfo.from_orm(current_user) + + +@router.get("/", response_model=List[UserInfo]) +async def list_users( + admin_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + result = await db.execute(select(User).order_by(User.created_at.desc())) + users = result.scalars().all() + return [UserInfo.from_orm(user) for user in users] + + +@router.get("/{user_id}", response_model=UserInfo) +async def get_user( + user_id: str, + admin_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ์‚ฌ์šฉ์ž ์กฐํšŒ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return UserInfo.from_orm(user) + + +@router.put("/{user_id}", response_model=UserInfo) +async def update_user( + user_id: str, + user_data: UpdateUserRequest, + admin_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ์ •๋ณด ์—…๋ฐ์ดํŠธ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + # ์‚ฌ์šฉ์ž ์กด์žฌ ํ™•์ธ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # ์ž๊ธฐ ์ž์‹ ์˜ ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์€ ์ œ๊ฑฐํ•  ์ˆ˜ ์—†์Œ + if user.id == admin_user.id and user_data.is_admin is False: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot remove admin privileges from yourself" + ) + + # ์—…๋ฐ์ดํŠธํ•  ๋ฐ์ดํ„ฐ ์ค€๋น„ + update_data = {} + if user_data.full_name is not None: + update_data["full_name"] = user_data.full_name + if user_data.is_active is not None: + update_data["is_active"] = user_data.is_active + if user_data.is_admin is not None: + update_data["is_admin"] = user_data.is_admin + + if update_data: + await db.execute( + update(User) + .where(User.id == user_id) + .values(**update_data) + ) + await db.commit() + await db.refresh(user) + + return UserInfo.from_orm(user) + + +@router.delete("/{user_id}") +async def delete_user( + user_id: str, + admin_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ์‚ญ์ œ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + # ์‚ฌ์šฉ์ž ์กด์žฌ ํ™•์ธ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # ์ž๊ธฐ ์ž์‹ ์€ ์‚ญ์ œํ•  ์ˆ˜ ์—†์Œ + if user.id == admin_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete yourself" + ) + + # ์‚ฌ์šฉ์ž ์‚ญ์ œ + await db.execute(delete(User).where(User.id == user_id)) + await db.commit() + + return {"message": "User deleted successfully"} diff --git a/backend/src/core/config.py b/backend/src/core/config.py new file mode 100644 index 0000000..275bfed --- /dev/null +++ b/backend/src/core/config.py @@ -0,0 +1,52 @@ +""" +์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ • +""" +from pydantic_settings import BaseSettings +from typing import List +import os + + +class Settings(BaseSettings): + """์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ • ํด๋ž˜์Šค""" + + # ๊ธฐ๋ณธ ์„ค์ • + APP_NAME: str = "Document Server" + DEBUG: bool = True + VERSION: str = "0.1.0" + + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • + DATABASE_URL: str = "postgresql+asyncpg://docuser:docpass@localhost:24101/document_db" + + # Redis ์„ค์ • + REDIS_URL: str = "redis://localhost:24103/0" + + # JWT ์„ค์ • + SECRET_KEY: str = "your-secret-key-change-this-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # CORS ์„ค์ • + ALLOWED_HOSTS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"] + + # ํŒŒ์ผ ์—…๋กœ๋“œ ์„ค์ • + UPLOAD_DIR: str = "uploads" + MAX_FILE_SIZE: int = 100 * 1024 * 1024 # 100MB + ALLOWED_EXTENSIONS: List[str] = [".html", ".htm", ".pdf"] + + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์„ค์ • (์ดˆ๊ธฐ ์„ค์ •์šฉ) + ADMIN_EMAIL: str = "admin@document-server.local" + ADMIN_PASSWORD: str = "admin123" # ํ”„๋กœ๋•์…˜์—์„œ๋Š” ๋ฐ˜๋“œ์‹œ ๋ณ€๊ฒฝ + + class Config: + env_file = ".env" + case_sensitive = True + + +# ์„ค์ • ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ +settings = Settings() + +# ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +os.makedirs(settings.UPLOAD_DIR, exist_ok=True) +os.makedirs(f"{settings.UPLOAD_DIR}/documents", exist_ok=True) +os.makedirs(f"{settings.UPLOAD_DIR}/thumbnails", exist_ok=True) diff --git a/backend/src/core/database.py b/backend/src/core/database.py new file mode 100644 index 0000000..1dfb7fc --- /dev/null +++ b/backend/src/core/database.py @@ -0,0 +1,94 @@ +""" +๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • ๋ฐ ์—ฐ๊ฒฐ +""" +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import MetaData +from typing import AsyncGenerator + +from src.core.config import settings + + +# SQLAlchemy ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์„ค์ • +metadata = MetaData( + naming_convention={ + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + } +) + + +class Base(DeclarativeBase): + """SQLAlchemy Base ํด๋ž˜์Šค""" + metadata = metadata + + +# ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—”์ง„ ์ƒ์„ฑ +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + future=True, + pool_pre_ping=True, + pool_recycle=300, +) + +# ๋น„๋™๊ธฐ ์„ธ์…˜ ํŒฉํ† ๋ฆฌ +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ธ์…˜ ์˜์กด์„ฑ""" + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db() -> None: + """๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™”""" + from src.models import user, document, highlight, note, bookmark, tag + + async with engine.begin() as conn: + # ๋ชจ๋“  ํ…Œ์ด๋ธ” ์ƒ์„ฑ + await conn.run_sync(Base.metadata.create_all) + + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ + await create_admin_user() + + +async def create_admin_user() -> None: + """๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ (์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ)""" + from src.models.user import User + from src.core.security import get_password_hash + from sqlalchemy import select + + async with AsyncSessionLocal() as session: + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์กด์žฌ ํ™•์ธ + result = await session.execute( + select(User).where(User.email == settings.ADMIN_EMAIL) + ) + admin_user = result.scalar_one_or_none() + + if not admin_user: + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ + admin_user = User( + email=settings.ADMIN_EMAIL, + hashed_password=get_password_hash(settings.ADMIN_PASSWORD), + is_active=True, + is_admin=True, + full_name="Administrator" + ) + session.add(admin_user) + await session.commit() + print(f"๊ด€๋ฆฌ์ž ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {settings.ADMIN_EMAIL}") diff --git a/backend/src/core/security.py b/backend/src/core/security.py new file mode 100644 index 0000000..8de4260 --- /dev/null +++ b/backend/src/core/security.py @@ -0,0 +1,87 @@ +""" +๋ณด์•ˆ ๊ด€๋ จ ์œ ํ‹ธ๋ฆฌํ‹ฐ +""" +from datetime import datetime, timedelta +from typing import Optional, Union +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import HTTPException, status + +from src.core.config import settings + + +# ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ ์ปจํ…์ŠคํŠธ +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """์•ก์„ธ์Šค ํ† ํฐ ์ƒ์„ฑ""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict) -> str: + """๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ์ƒ์„ฑ""" + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def verify_token(token: str, token_type: str = "access") -> dict: + """ํ† ํฐ ๊ฒ€์ฆ""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + + # ํ† ํฐ ํƒ€์ž… ํ™•์ธ + if payload.get("type") != token_type: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type" + ) + + # ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ํ™•์ธ + exp = payload.get("exp") + if exp is None or datetime.utcnow() > datetime.fromtimestamp(exp): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired" + ) + + return payload + + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + +def get_user_id_from_token(token: str) -> str: + """ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ID ์ถ”์ถœ""" + payload = verify_token(token) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + return user_id diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..b616309 --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,72 @@ +""" +Document Server - FastAPI Main Application +""" +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import uvicorn + +from src.core.config import settings +from src.core.database import init_db +from src.api.routes import auth, users, documents, highlights, notes, bookmarks, search + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹œ์ž‘/์ข…๋ฃŒ ์‹œ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜""" + # ์‹œ์ž‘ ์‹œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” + await init_db() + yield + # ์ข…๋ฃŒ ์‹œ ์ •๋ฆฌ ์ž‘์—… (ํ•„์š”์‹œ) + + +# FastAPI ์•ฑ ์ƒ์„ฑ +app = FastAPI( + title="Document Server", + description="HTML Document Management and Viewer System", + version="0.1.0", + lifespan=lifespan, +) + +# CORS ์„ค์ • +app.add_middleware( + CORSMiddleware, + allow_origins=settings.ALLOWED_HOSTS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ์ •์  ํŒŒ์ผ ์„œ๋น™ (์—…๋กœ๋“œ๋œ ํŒŒ์ผ๋“ค) +app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") + +# API ๋ผ์šฐํ„ฐ ๋“ฑ๋ก +app.include_router(auth.router, prefix="/api/auth", tags=["์ธ์ฆ"]) +app.include_router(users.router, prefix="/api/users", tags=["์‚ฌ์šฉ์ž"]) +app.include_router(documents.router, prefix="/api/documents", tags=["๋ฌธ์„œ"]) +app.include_router(highlights.router, prefix="/api/highlights", tags=["ํ•˜์ด๋ผ์ดํŠธ"]) +app.include_router(notes.router, prefix="/api/notes", tags=["๋ฉ”๋ชจ"]) +app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["์ฑ…๊ฐˆํ”ผ"]) +app.include_router(search.router, prefix="/api/search", tags=["๊ฒ€์ƒ‰"]) + + +@app.get("/") +async def root(): + """๋ฃจํŠธ ์—”๋“œํฌ์ธํŠธ""" + return {"message": "Document Server API", "version": "0.1.0"} + + +@app.get("/health") +async def health_check(): + """ํ—ฌ์Šค์ฒดํฌ ์—”๋“œํฌ์ธํŠธ""" + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run( + "src.main:app", + host="0.0.0.0", + port=8000, + reload=True if settings.DEBUG else False, + ) diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..dba4270 --- /dev/null +++ b/backend/src/models/__init__.py @@ -0,0 +1,17 @@ +""" +๋ชจ๋ธ ํŒจํ‚ค์ง€ ์ดˆ๊ธฐํ™” +""" +from src.models.user import User +from src.models.document import Document, Tag +from src.models.highlight import Highlight +from src.models.note import Note +from src.models.bookmark import Bookmark + +__all__ = [ + "User", + "Document", + "Tag", + "Highlight", + "Note", + "Bookmark", +] diff --git a/backend/src/models/bookmark.py b/backend/src/models/bookmark.py new file mode 100644 index 0000000..0b42fe0 --- /dev/null +++ b/backend/src/models/bookmark.py @@ -0,0 +1,42 @@ +""" +์ฑ…๊ฐˆํ”ผ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from src.core.database import Base + + +class Bookmark(Base): + """์ฑ…๊ฐˆํ”ผ ํ…Œ์ด๋ธ”""" + __tablename__ = "bookmarks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ์—ฐ๊ฒฐ ์ •๋ณด + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False) + + # ์ฑ…๊ฐˆํ”ผ ์ •๋ณด + title = Column(String(200), nullable=False) # ์ฑ…๊ฐˆํ”ผ ์ œ๋ชฉ + description = Column(Text, nullable=True) # ์„ค๋ช… + + # ์œ„์น˜ ์ •๋ณด + page_number = Column(Integer, nullable=True) # ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (์ถ”์ •) + scroll_position = Column(Integer, default=0) # ์Šคํฌ๋กค ์œ„์น˜ (ํ”ฝ์…€) + element_id = Column(String(100), nullable=True) # ํŠน์ • ์š”์†Œ ID + element_selector = Column(Text, nullable=True) # CSS ์„ ํƒ์ž + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + user = relationship("User", backref="bookmarks") + document = relationship("Document", back_populates="bookmarks") + + def __repr__(self): + return f"" diff --git a/backend/src/models/document.py b/backend/src/models/document.py new file mode 100644 index 0000000..c9f423c --- /dev/null +++ b/backend/src/models/document.py @@ -0,0 +1,81 @@ +""" +๋ฌธ์„œ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, Boolean, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID, ARRAY +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from src.core.database import Base + + +# ๋ฌธ์„œ-ํƒœ๊ทธ ๋‹ค๋Œ€๋‹ค ๊ด€๊ณ„ ํ…Œ์ด๋ธ” +document_tags = Table( + 'document_tags', + Base.metadata, + Column('document_id', UUID(as_uuid=True), ForeignKey('documents.id'), primary_key=True), + Column('tag_id', UUID(as_uuid=True), ForeignKey('tags.id'), primary_key=True) +) + + +class Document(Base): + """๋ฌธ์„œ ํ…Œ์ด๋ธ”""" + __tablename__ = "documents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(500), nullable=False, index=True) + description = Column(Text, nullable=True) + + # ํŒŒ์ผ ์ •๋ณด + html_path = Column(String(1000), nullable=False) # HTML ํŒŒ์ผ ๊ฒฝ๋กœ + pdf_path = Column(String(1000), nullable=True) # PDF ์›๋ณธ ๊ฒฝ๋กœ (์„ ํƒ) + thumbnail_path = Column(String(1000), nullable=True) # ์ธ๋„ค์ผ ๊ฒฝ๋กœ + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + file_size = Column(Integer, nullable=True) # ๋ฐ”์ดํŠธ ๋‹จ์œ„ + page_count = Column(Integer, nullable=True) # ํŽ˜์ด์ง€ ์ˆ˜ (์ถ”์ •) + language = Column(String(10), default="ko") # ๋ฌธ์„œ ์–ธ์–ด + + # ์—…๋กœ๋“œ ์ •๋ณด + uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + original_filename = Column(String(500), nullable=True) + + # ์ƒํƒœ + is_public = Column(Boolean, default=False) # ๊ณต๊ฐœ ์—ฌ๋ถ€ + is_processed = Column(Boolean, default=True) # ์ฒ˜๋ฆฌ ์™„๋ฃŒ ์—ฌ๋ถ€ + + # ์‹œ๊ฐ„ ์ •๋ณด + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + document_date = Column(DateTime(timezone=True), nullable=True) # ๋ฌธ์„œ ์ž‘์„ฑ์ผ (์‚ฌ์šฉ์ž ์ž…๋ ฅ) + + # ๊ด€๊ณ„ + uploader = relationship("User", backref="uploaded_documents") + tags = relationship("Tag", secondary=document_tags, back_populates="documents") + highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan") + bookmarks = relationship("Bookmark", back_populates="document", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class Tag(Base): + """ํƒœ๊ทธ ํ…Œ์ด๋ธ”""" + __tablename__ = "tags" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(100), unique=True, nullable=False, index=True) + color = Column(String(7), default="#3B82F6") # HEX ์ƒ‰์ƒ ์ฝ”๋“œ + description = Column(Text, nullable=True) + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # ๊ด€๊ณ„ + creator = relationship("User", backref="created_tags") + documents = relationship("Document", secondary=document_tags, back_populates="tags") + + def __repr__(self): + return f"" diff --git a/backend/src/models/highlight.py b/backend/src/models/highlight.py new file mode 100644 index 0000000..3e73da5 --- /dev/null +++ b/backend/src/models/highlight.py @@ -0,0 +1,47 @@ +""" +ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from src.core.database import Base + + +class Highlight(Base): + """ํ•˜์ด๋ผ์ดํŠธ ํ…Œ์ด๋ธ”""" + __tablename__ = "highlights" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ์—ฐ๊ฒฐ ์ •๋ณด + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False) + + # ํ…์ŠคํŠธ ์œ„์น˜ ์ •๋ณด + start_offset = Column(Integer, nullable=False) # ์‹œ์ž‘ ์œ„์น˜ + end_offset = Column(Integer, nullable=False) # ๋ ์œ„์น˜ + selected_text = Column(Text, nullable=False) # ์„ ํƒ๋œ ํ…์ŠคํŠธ (๊ฒ€์ƒ‰์šฉ) + + # DOM ์œ„์น˜ ์ •๋ณด (์ •ํ™•ํ•œ ๋ณต์›์„ ์œ„ํ•ด) + element_selector = Column(Text, nullable=True) # CSS ์„ ํƒ์ž + start_container_xpath = Column(Text, nullable=True) # ์‹œ์ž‘ ์ปจํ…Œ์ด๋„ˆ XPath + end_container_xpath = Column(Text, nullable=True) # ๋ ์ปจํ…Œ์ด๋„ˆ XPath + + # ์Šคํƒ€์ผ ์ •๋ณด + highlight_color = Column(String(7), default="#FFFF00") # HEX ์ƒ‰์ƒ ์ฝ”๋“œ + highlight_type = Column(String(20), default="highlight") # highlight, underline, etc. + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + user = relationship("User", backref="highlights") + document = relationship("Document", back_populates="highlights") + note = relationship("Note", back_populates="highlight", uselist=False, cascade="all, delete-orphan") + + def __repr__(self): + return f"" diff --git a/backend/src/models/note.py b/backend/src/models/note.py new file mode 100644 index 0000000..fba4067 --- /dev/null +++ b/backend/src/models/note.py @@ -0,0 +1,47 @@ +""" +๋ฉ”๋ชจ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Boolean, ForeignKey +from sqlalchemy.dialects.postgresql import UUID, ARRAY +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from src.core.database import Base + + +class Note(Base): + """๋ฉ”๋ชจ ํ…Œ์ด๋ธ” (ํ•˜์ด๋ผ์ดํŠธ์™€ 1:1 ๊ด€๊ณ„)""" + __tablename__ = "notes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ์—ฐ๊ฒฐ ์ •๋ณด + highlight_id = Column(UUID(as_uuid=True), ForeignKey("highlights.id"), nullable=False, unique=True) + + # ๋ฉ”๋ชจ ๋‚ด์šฉ + content = Column(Text, nullable=False) + is_private = Column(Boolean, default=True) # ๊ฐœ์ธ ๋ฉ”๋ชจ ์—ฌ๋ถ€ + + # ํƒœ๊ทธ (๋ฉ”๋ชจ ๋ถ„๋ฅ˜์šฉ) + tags = Column(ARRAY(String), nullable=True) # ["์ค‘์š”", "์งˆ๋ฌธ", "์•„์ด๋””์–ด"] + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + highlight = relationship("Highlight", back_populates="note") + + @property + def user_id(self): + """ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ID ๊ฐ€์ ธ์˜ค๊ธฐ""" + return self.highlight.user_id if self.highlight else None + + @property + def document_id(self): + """ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ํ†ตํ•ด ๋ฌธ์„œ ID ๊ฐ€์ ธ์˜ค๊ธฐ""" + return self.highlight.document_id if self.highlight else None + + def __repr__(self): + return f"" diff --git a/backend/src/models/user.py b/backend/src/models/user.py new file mode 100644 index 0000000..de2bbe0 --- /dev/null +++ b/backend/src/models/user.py @@ -0,0 +1,34 @@ +""" +์‚ฌ์šฉ์ž ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, Boolean, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +import uuid + +from src.core.database import Base + + +class User(Base): + """์‚ฌ์šฉ์ž ํ…Œ์ด๋ธ”""" + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + full_name = Column(String(255), nullable=True) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + last_login = Column(DateTime(timezone=True), nullable=True) + + # ์‚ฌ์šฉ์ž ์„ค์ • + theme = Column(String(50), default="light") # light, dark + language = Column(String(10), default="ko") # ko, en + timezone = Column(String(50), default="Asia/Seoul") + + def __repr__(self): + return f"" diff --git a/backend/src/schemas/auth.py b/backend/src/schemas/auth.py new file mode 100644 index 0000000..5488feb --- /dev/null +++ b/backend/src/schemas/auth.py @@ -0,0 +1,56 @@ +""" +์ธ์ฆ ๊ด€๋ จ ์Šคํ‚ค๋งˆ +""" +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + + +class LoginRequest(BaseModel): + """๋กœ๊ทธ์ธ ์š”์ฒญ""" + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + """ํ† ํฐ ์‘๋‹ต""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int # ์ดˆ ๋‹จ์œ„ + + +class RefreshTokenRequest(BaseModel): + """ํ† ํฐ ๊ฐฑ์‹  ์š”์ฒญ""" + refresh_token: str + + +class UserInfo(BaseModel): + """์‚ฌ์šฉ์ž ์ •๋ณด""" + id: str + email: str + full_name: Optional[str] = None + is_active: bool + is_admin: bool + theme: str + language: str + timezone: str + created_at: datetime + last_login: Optional[datetime] = None + + class Config: + from_attributes = True + + +class ChangePasswordRequest(BaseModel): + """๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ""" + current_password: str + new_password: str + + +class CreateUserRequest(BaseModel): + """์‚ฌ์šฉ์ž ์ƒ์„ฑ ์š”์ฒญ (๊ด€๋ฆฌ์ž์šฉ)""" + email: EmailStr + password: str + full_name: Optional[str] = None + is_admin: bool = False diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..dfcc1cf --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,68 @@ +version: '3.8' + +services: + # ๊ฐœ๋ฐœ์šฉ Nginx + nginx: + build: ./nginx + container_name: document-server-nginx-dev + ports: + - "24100:80" + volumes: + - ./frontend:/usr/share/nginx/html + - ./uploads:/usr/share/nginx/html/uploads + - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf + depends_on: + - backend + networks: + - document-network + + # ๊ฐœ๋ฐœ์šฉ Backend (ํ•ซ ๋ฆฌ๋กœ๋“œ) + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: document-server-backend-dev + ports: + - "24102:8000" + volumes: + - ./uploads:/app/uploads + - ./backend:/app + environment: + - DATABASE_URL=postgresql://docuser:docpass@database:5432/document_db + - PAPERLESS_URL=${PAPERLESS_URL:-http://localhost:8000} + - PAPERLESS_TOKEN=${PAPERLESS_TOKEN:-} + - DEBUG=true + - RELOAD=true + depends_on: + - database + networks: + - document-network + + # ๊ฐœ๋ฐœ์šฉ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (๋ฐ์ดํ„ฐ ์˜์†์„ฑ ์—†์Œ) + database: + image: postgres:15-alpine + container_name: document-server-db-dev + ports: + - "24101:5432" + environment: + - POSTGRES_DB=document_db + - POSTGRES_USER=docuser + - POSTGRES_PASSWORD=docpass + volumes: + - ./database/init:/docker-entrypoint-initdb.d + networks: + - document-network + + # ๊ฐœ๋ฐœ์šฉ Redis + redis: + image: redis:7-alpine + container_name: document-server-redis-dev + ports: + - "24103:6379" + networks: + - document-network + command: redis-server + +networks: + document-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..336424c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +version: '3.8' + +services: + # Nginx ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ + nginx: + build: ./nginx + container_name: document-server-nginx + ports: + - "24100:80" + volumes: + - ./frontend:/usr/share/nginx/html + - ./uploads:/usr/share/nginx/html/uploads + depends_on: + - backend + networks: + - document-network + restart: unless-stopped + + # Backend API ์„œ๋ฒ„ + backend: + build: ./backend + container_name: document-server-backend + ports: + - "24102:8000" + volumes: + - ./uploads:/app/uploads + - ./backend/src:/app/src + environment: + - DATABASE_URL=postgresql://docuser:docpass@database:5432/document_db + - PAPERLESS_URL=${PAPERLESS_URL:-http://localhost:8000} + - PAPERLESS_TOKEN=${PAPERLESS_TOKEN:-} + - DEBUG=true + depends_on: + - database + networks: + - document-network + restart: unless-stopped + + # PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค + database: + image: postgres:15-alpine + container_name: document-server-db + ports: + - "24101:5432" + environment: + - POSTGRES_DB=document_db + - POSTGRES_USER=docuser + - POSTGRES_PASSWORD=docpass + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + networks: + - document-network + restart: unless-stopped + + # Redis (์บ์‹ฑ ๋ฐ ์„ธ์…˜) + redis: + image: redis:7-alpine + container_name: document-server-redis + ports: + - "24103:6379" + volumes: + - redis_data:/data + networks: + - document-network + restart: unless-stopped + command: redis-server --appendonly yes + +volumes: + postgres_data: + redis_data: + +networks: + document-network: + driver: bridge diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..36f99a1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,243 @@ + + + + + + Document Server + + + + + + + +
+
+
+

๋กœ๊ทธ์ธ

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+
+
+ +
+

+ + Document Server +

+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+
+
+ + +
+ + + + + +
+
+ + +
+ + + + + + + + + diff --git a/frontend/static/css/main.css b/frontend/static/css/main.css new file mode 100644 index 0000000..5ddb137 --- /dev/null +++ b/frontend/static/css/main.css @@ -0,0 +1,269 @@ +/* ๋ฉ”์ธ ์Šคํƒ€์ผ */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +/* ์•Œ๋ฆผ ์• ๋‹ˆ๋ฉ”์ด์…˜ */ +.notification { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ */ +.loading-spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ์นด๋“œ ํ˜ธ๋ฒ„ ํšจ๊ณผ */ +.card-hover { + transition: all 0.2s ease-in-out; +} + +.card-hover:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +/* ํƒœ๊ทธ ์Šคํƒ€์ผ */ +.tag { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 9999px; + background-color: #dbeafe; + color: #1e40af; +} + +/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ ํฌ์ปค์Šค */ +.search-input:focus { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* ๋“œ๋กญ๋‹ค์šด ์• ๋‹ˆ๋ฉ”์ด์…˜ */ +.dropdown-enter { + opacity: 0; + transform: scale(0.95); +} + +.dropdown-enter-active { + opacity: 1; + transform: scale(1); + transition: opacity 150ms ease-out, transform 150ms ease-out; +} + +/* ๋ชจ๋‹ฌ ๋ฐฐ๊ฒฝ */ +.modal-backdrop { + backdrop-filter: blur(4px); +} + +/* ํŒŒ์ผ ๋“œ๋กญ ์˜์—ญ */ +.file-drop-zone { + border: 2px dashed #d1d5db; + border-radius: 0.5rem; + padding: 2rem; + text-align: center; + transition: all 0.2s ease-in-out; +} + +.file-drop-zone.dragover { + border-color: #3b82f6; + background-color: #eff6ff; +} + +.file-drop-zone:hover { + border-color: #6b7280; +} + +/* ๋ฐ˜์‘ํ˜• ๊ทธ๋ฆฌ๋“œ */ +@media (max-width: 768px) { + .grid-responsive { + grid-template-columns: 1fr; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .grid-responsive { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1025px) { + .grid-responsive { + grid-template-columns: repeat(3, 1fr); + } +} + +/* ์Šคํฌ๋กค๋ฐ” ์Šคํƒ€์ผ๋ง */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* ํ…์ŠคํŠธ ์ค„์ž„ํ‘œ */ +.text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ๋ผ์ธ ํด๋žจํ”„ ์œ ํ‹ธ๋ฆฌํ‹ฐ */ +.line-clamp-1 { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ํฌ์ปค์Šค ๋ง ์ œ๊ฑฐ */ +.focus-visible:focus { + outline: 2px solid transparent; + outline-offset: 2px; + box-shadow: 0 0 0 2px #3b82f6; +} + +/* ๋ฒ„ํŠผ ์ƒํƒœ */ +.btn-primary { + background-color: #3b82f6; + color: white; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-weight: 500; + transition: background-color 0.2s ease-in-out; +} + +.btn-primary:hover { + background-color: #2563eb; +} + +.btn-primary:disabled { + background-color: #9ca3af; + cursor: not-allowed; +} + +.btn-secondary { + background-color: #6b7280; + color: white; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-weight: 500; + transition: background-color 0.2s ease-in-out; +} + +.btn-secondary:hover { + background-color: #4b5563; +} + +/* ์ž…๋ ฅ ํ•„๋“œ ์Šคํƒ€์ผ */ +.input-field { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.875rem; + transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + +.input-field:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.input-field:invalid { + border-color: #ef4444; +} + +/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */ +.error-message { + color: #ef4444; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ */ +.success-message { + color: #10b981; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* ๋กœ๋”ฉ ์˜ค๋ฒ„๋ ˆ์ด */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +/* ๋นˆ ์ƒํƒœ ์ผ๋Ÿฌ์ŠคํŠธ๋ ˆ์ด์…˜ */ +.empty-state { + text-align: center; + padding: 3rem 1rem; +} + +.empty-state i { + font-size: 4rem; + color: #9ca3af; + margin-bottom: 1rem; +} + +.empty-state h3 { + font-size: 1.25rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #6b7280; + margin-bottom: 1.5rem; +} diff --git a/frontend/static/css/viewer.css b/frontend/static/css/viewer.css new file mode 100644 index 0000000..cb0306c --- /dev/null +++ b/frontend/static/css/viewer.css @@ -0,0 +1,455 @@ +/* ๋ทฐ์–ด ์ „์šฉ ์Šคํƒ€์ผ */ + +/* ํ•˜์ด๋ผ์ดํŠธ ์Šคํƒ€์ผ */ +.highlight { + position: relative; + cursor: pointer; + border-radius: 2px; + padding: 1px 2px; + margin: -1px -2px; + transition: all 0.2s ease-in-out; +} + +.highlight:hover { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); + z-index: 10; +} + +.highlight.selected { + box-shadow: 0 0 0 2px #3B82F6; + z-index: 10; +} + +/* ํ•˜์ด๋ผ์ดํŠธ ๋ฒ„ํŠผ */ +.highlight-button { + animation: fadeInUp 0.2s ease-out; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ */ +.search-highlight { + background-color: #FEF3C7 !important; + border: 1px solid #F59E0B; + border-radius: 2px; + padding: 1px 2px; + margin: -1px -2px; +} + +/* ๋ฌธ์„œ ๋‚ด์šฉ ์Šคํƒ€์ผ */ +#document-content { + line-height: 1.7; + font-size: 16px; + color: #374151; +} + +#document-content h1, +#document-content h2, +#document-content h3, +#document-content h4, +#document-content h5, +#document-content h6 { + color: #111827; + font-weight: 600; + margin-top: 2rem; + margin-bottom: 1rem; +} + +#document-content h1 { + font-size: 2.25rem; + border-bottom: 2px solid #e5e7eb; + padding-bottom: 0.5rem; +} + +#document-content h2 { + font-size: 1.875rem; +} + +#document-content h3 { + font-size: 1.5rem; +} + +#document-content p { + margin-bottom: 1rem; +} + +#document-content ul, +#document-content ol { + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +#document-content li { + margin-bottom: 0.25rem; +} + +#document-content blockquote { + border-left: 4px solid #e5e7eb; + padding-left: 1rem; + margin: 1rem 0; + font-style: italic; + color: #6b7280; +} + +#document-content code { + background-color: #f3f4f6; + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; +} + +#document-content pre { + background-color: #f3f4f6; + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 1rem 0; +} + +#document-content pre code { + background: none; + padding: 0; +} + +#document-content table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; +} + +#document-content th, +#document-content td { + border: 1px solid #e5e7eb; + padding: 0.5rem; + text-align: left; +} + +#document-content th { + background-color: #f9fafb; + font-weight: 600; +} + +#document-content img { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin: 1rem 0; +} + +/* ์‚ฌ์ด๋“œ ํŒจ๋„ ์Šคํƒ€์ผ */ +.side-panel { + background: white; + border-left: 1px solid #e5e7eb; + height: calc(100vh - 4rem); + overflow: hidden; +} + +.panel-tab { + transition: all 0.2s ease-in-out; +} + +.panel-tab.active { + background-color: #eff6ff; + color: #2563eb; + border-bottom: 2px solid #2563eb; +} + +/* ๋ฉ”๋ชจ ์นด๋“œ ์Šคํƒ€์ผ */ +.note-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + transition: all 0.2s ease-in-out; + cursor: pointer; +} + +.note-card:hover { + background: #f1f5f9; + border-color: #cbd5e1; + transform: translateY(-1px); +} + +.note-card.selected { + border-color: #3b82f6; + background: #eff6ff; +} + +/* ์ฑ…๊ฐˆํ”ผ ์นด๋“œ ์Šคํƒ€์ผ */ +.bookmark-card { + background: #f0fdf4; + border: 1px solid #dcfce7; + border-radius: 0.5rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + transition: all 0.2s ease-in-out; + cursor: pointer; +} + +.bookmark-card:hover { + background: #ecfdf5; + border-color: #bbf7d0; + transform: translateY(-1px); +} + +/* ์ƒ‰์ƒ ์„ ํƒ๊ธฐ */ +.color-picker { + display: flex; + gap: 0.25rem; + padding: 0.25rem; + background: #f3f4f6; + border-radius: 0.5rem; +} + +.color-option { + width: 2rem; + height: 2rem; + border-radius: 0.25rem; + border: 2px solid white; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.color-option:hover { + transform: scale(1.1); +} + +.color-option.selected { + box-shadow: 0 0 0 2px #3b82f6; +} + +/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ */ +.search-input { + background: white; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + padding: 0.5rem 0.75rem 0.5rem 2.5rem; + width: 100%; + transition: all 0.2s ease-in-out; +} + +.search-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* ๋„๊ตฌ ๋ชจ์Œ */ +.toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: white; + border-bottom: 1px solid #e5e7eb; +} + +.toolbar-button { + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + border: none; + background: #f3f4f6; + color: #374151; + cursor: pointer; + transition: all 0.2s ease-in-out; + font-size: 0.875rem; + font-weight: 500; +} + +.toolbar-button:hover { + background: #e5e7eb; +} + +.toolbar-button.active { + background: #3b82f6; + color: white; +} + +.toolbar-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ๋ชจ๋‹ฌ ์Šคํƒ€์ผ */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.modal-content { + background: white; + border-radius: 0.75rem; + padding: 1.5rem; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + animation: modalSlideIn 0.2s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* ํƒœ๊ทธ ์ž…๋ ฅ */ +.tag-input { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + min-height: 2.5rem; + background: white; +} + +.tag-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: #eff6ff; + color: #1e40af; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.tag-remove { + cursor: pointer; + color: #6b7280; + font-size: 0.75rem; +} + +.tag-remove:hover { + color: #ef4444; +} + +/* ์Šคํฌ๋กค ํ‘œ์‹œ๊ธฐ */ +.scroll-indicator { + position: fixed; + top: 4rem; + right: 1rem; + width: 4px; + height: calc(100vh - 5rem); + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; + z-index: 30; +} + +.scroll-thumb { + width: 100%; + background: #3b82f6; + border-radius: 2px; + transition: background-color 0.2s ease-in-out; +} + +.scroll-thumb:hover { + background: #2563eb; +} + +/* ๋ฐ˜์‘ํ˜• ๋””์ž์ธ */ +@media (max-width: 768px) { + .side-panel { + position: fixed; + top: 4rem; + right: 0; + width: 100%; + max-width: 24rem; + z-index: 40; + box-shadow: -4px 0 6px -1px rgba(0, 0, 0, 0.1); + } + + #document-content { + font-size: 14px; + padding: 1rem; + } + + .toolbar { + flex-wrap: wrap; + gap: 0.25rem; + } + + .toolbar-button { + font-size: 0.75rem; + padding: 0.375rem 0.5rem; + } + + .color-option { + width: 1.5rem; + height: 1.5rem; + } +} + +/* ๋‹คํฌ ๋ชจ๋“œ ์ง€์› */ +@media (prefers-color-scheme: dark) { + .highlight { + filter: brightness(0.8); + } + + .search-highlight { + background-color: #451a03 !important; + border-color: #92400e; + color: #fbbf24; + } + + #document-content { + color: #e5e7eb; + } + + #document-content h1, + #document-content h2, + #document-content h3, + #document-content h4, + #document-content h5, + #document-content h6 { + color: #f9fafb; + } +} + +/* ์ธ์‡„ ์Šคํƒ€์ผ */ +@media print { + .toolbar, + .side-panel, + .highlight-button { + display: none !important; + } + + .highlight { + background-color: #fef3c7 !important; + box-shadow: none !important; + } + + #document-content { + font-size: 12pt; + line-height: 1.5; + } +} diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js new file mode 100644 index 0000000..0158b87 --- /dev/null +++ b/frontend/static/js/api.js @@ -0,0 +1,265 @@ +/** + * API ํ†ต์‹  ์œ ํ‹ธ๋ฆฌํ‹ฐ + */ +class API { + constructor() { + this.baseURL = '/api'; + this.token = localStorage.getItem('access_token'); + } + + // ํ† ํฐ ์„ค์ • + setToken(token) { + this.token = token; + if (token) { + localStorage.setItem('access_token', token); + } else { + localStorage.removeItem('access_token'); + } + } + + // ๊ธฐ๋ณธ ์š”์ฒญ ํ—ค๋” + getHeaders() { + const headers = { + 'Content-Type': 'application/json', + }; + + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + return headers; + } + + // GET ์š”์ฒญ + async get(endpoint, params = {}) { + const url = new URL(`${this.baseURL}${endpoint}`, window.location.origin); + Object.keys(params).forEach(key => { + if (params[key] !== null && params[key] !== undefined) { + url.searchParams.append(key, params[key]); + } + }); + + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + return this.handleResponse(response); + } + + // POST ์š”์ฒญ + async post(endpoint, data = {}) { + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(data), + }); + + return this.handleResponse(response); + } + + // PUT ์š”์ฒญ + async put(endpoint, data = {}) { + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'PUT', + headers: this.getHeaders(), + body: JSON.stringify(data), + }); + + return this.handleResponse(response); + } + + // DELETE ์š”์ฒญ + async delete(endpoint) { + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'DELETE', + headers: this.getHeaders(), + }); + + return this.handleResponse(response); + } + + // ํŒŒ์ผ ์—…๋กœ๋“œ + async uploadFile(endpoint, formData) { + const headers = {}; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + const response = await fetch(`${this.baseURL}${endpoint}`, { + method: 'POST', + headers: headers, + body: formData, + }); + + return this.handleResponse(response); + } + + // ์‘๋‹ต ์ฒ˜๋ฆฌ + async handleResponse(response) { + if (response.status === 401) { + // ํ† ํฐ ๋งŒ๋ฃŒ ๋˜๋Š” ์ธ์ฆ ์‹คํŒจ + this.setToken(null); + window.location.reload(); + throw new Error('์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `HTTP ${response.status}`); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } + + return await response.text(); + } + + // ์ธ์ฆ ๊ด€๋ จ API + async login(email, password) { + return await this.post('/auth/login', { email, password }); + } + + async logout() { + try { + await this.post('/auth/logout'); + } finally { + this.setToken(null); + } + } + + async getCurrentUser() { + return await this.get('/auth/me'); + } + + async refreshToken(refreshToken) { + return await this.post('/auth/refresh', { refresh_token: refreshToken }); + } + + // ๋ฌธ์„œ ๊ด€๋ จ API + async getDocuments(params = {}) { + return await this.get('/documents/', params); + } + + async getDocument(documentId) { + return await this.get(`/documents/${documentId}`); + } + + async uploadDocument(formData) { + return await this.uploadFile('/documents/', formData); + } + + async deleteDocument(documentId) { + return await this.delete(`/documents/${documentId}`); + } + + async getTags() { + return await this.get('/documents/tags/'); + } + + async createTag(tagData) { + return await this.post('/documents/tags/', tagData); + } + + // ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ API + async createHighlight(highlightData) { + return await this.post('/highlights/', highlightData); + } + + async getDocumentHighlights(documentId) { + return await this.get(`/highlights/document/${documentId}`); + } + + async updateHighlight(highlightId, data) { + return await this.put(`/highlights/${highlightId}`, data); + } + + async deleteHighlight(highlightId) { + return await this.delete(`/highlights/${highlightId}`); + } + + // ๋ฉ”๋ชจ ๊ด€๋ จ API + async createNote(noteData) { + return await this.post('/notes/', noteData); + } + + async getNotes(params = {}) { + return await this.get('/notes/', params); + } + + async getDocumentNotes(documentId) { + return await this.get(`/notes/document/${documentId}`); + } + + async updateNote(noteId, data) { + return await this.put(`/notes/${noteId}`, data); + } + + async deleteNote(noteId) { + return await this.delete(`/notes/${noteId}`); + } + + async getPopularNoteTags() { + return await this.get('/notes/tags/popular'); + } + + // ์ฑ…๊ฐˆํ”ผ ๊ด€๋ จ API + async createBookmark(bookmarkData) { + return await this.post('/bookmarks/', bookmarkData); + } + + async getBookmarks(params = {}) { + return await this.get('/bookmarks/', params); + } + + async getDocumentBookmarks(documentId) { + return await this.get(`/bookmarks/document/${documentId}`); + } + + async updateBookmark(bookmarkId, data) { + return await this.put(`/bookmarks/${bookmarkId}`, data); + } + + async deleteBookmark(bookmarkId) { + return await this.delete(`/bookmarks/${bookmarkId}`); + } + + // ๊ฒ€์ƒ‰ ๊ด€๋ จ API + async search(params = {}) { + return await this.get('/search/', params); + } + + async getSearchSuggestions(query) { + return await this.get('/search/suggestions', { q: query }); + } + + // ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ API + async getUsers() { + return await this.get('/users/'); + } + + async createUser(userData) { + return await this.post('/auth/create-user', userData); + } + + async updateUser(userId, userData) { + return await this.put(`/users/${userId}`, userData); + } + + async deleteUser(userId) { + return await this.delete(`/users/${userId}`); + } + + async updateProfile(profileData) { + return await this.put('/users/profile', profileData); + } + + async changePassword(passwordData) { + return await this.put('/auth/change-password', passwordData); + } +} + +// ์ „์—ญ API ์ธ์Šคํ„ด์Šค +window.api = new API(); diff --git a/frontend/static/js/auth.js b/frontend/static/js/auth.js new file mode 100644 index 0000000..b96ae57 --- /dev/null +++ b/frontend/static/js/auth.js @@ -0,0 +1,90 @@ +/** + * ์ธ์ฆ ๊ด€๋ จ Alpine.js ์ปดํฌ๋„ŒํŠธ + */ + +// ์ธ์ฆ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ +window.authModal = () => ({ + showLogin: false, + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + async login() { + this.loginLoading = true; + this.loginError = ''; + + try { + const response = await api.login(this.loginForm.email, this.loginForm.password); + + // ํ† ํฐ ์ €์žฅ + api.setToken(response.access_token); + localStorage.setItem('refresh_token', response.refresh_token); + + // ์‚ฌ์šฉ์ž ์ •๋ณด ๋กœ๋“œ + const user = await api.getCurrentUser(); + + // ์ „์—ญ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + window.dispatchEvent(new CustomEvent('auth-changed', { + detail: { isAuthenticated: true, user } + })); + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + this.showLogin = false; + this.loginForm = { email: '', password: '' }; + + } catch (error) { + this.loginError = error.message; + } finally { + this.loginLoading = false; + } + }, + + async logout() { + try { + await api.logout(); + } catch (error) { + console.error('Logout error:', error); + } finally { + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ •๋ฆฌ + localStorage.removeItem('refresh_token'); + + // ์ „์—ญ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + window.dispatchEvent(new CustomEvent('auth-changed', { + detail: { isAuthenticated: false, user: null } + })); + } + } +}); + +// ์ž๋™ ํ† ํฐ ๊ฐฑ์‹  +async function refreshTokenIfNeeded() { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken || !api.token) return; + + try { + // ํ† ํฐ ๋งŒ๋ฃŒ ํ™•์ธ (JWT ๋””์ฝ”๋”ฉ) + const tokenPayload = JSON.parse(atob(api.token.split('.')[1])); + const now = Date.now() / 1000; + + // ํ† ํฐ์ด 5๋ถ„ ๋‚ด์— ๋งŒ๋ฃŒ๋˜๋ฉด ๊ฐฑ์‹  + if (tokenPayload.exp - now < 300) { + const response = await api.refreshToken(refreshToken); + api.setToken(response.access_token); + localStorage.setItem('refresh_token', response.refresh_token); + } + } catch (error) { + console.error('Token refresh failed:', error); + // ๊ฐฑ์‹  ์‹คํŒจ์‹œ ๋กœ๊ทธ์•„์›ƒ + api.setToken(null); + localStorage.removeItem('refresh_token'); + window.dispatchEvent(new CustomEvent('auth-changed', { + detail: { isAuthenticated: false, user: null } + })); + } +} + +// 5๋ถ„๋งˆ๋‹ค ํ† ํฐ ๊ฐฑ์‹  ์ฒดํฌ +setInterval(refreshTokenIfNeeded, 5 * 60 * 1000); diff --git a/frontend/static/js/main.js b/frontend/static/js/main.js new file mode 100644 index 0000000..d102535 --- /dev/null +++ b/frontend/static/js/main.js @@ -0,0 +1,286 @@ +/** + * ๋ฉ”์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ Alpine.js ์ปดํฌ๋„ŒํŠธ + */ + +// ๋ฉ”์ธ ๋ฌธ์„œ ์•ฑ ์ปดํฌ๋„ŒํŠธ +window.documentApp = () => ({ + // ์ƒํƒœ + isAuthenticated: false, + user: null, + loading: false, + + // ๋ฌธ์„œ ๊ด€๋ จ + documents: [], + tags: [], + selectedTag: '', + viewMode: 'grid', // 'grid' ๋˜๋Š” 'list' + + // ๊ฒ€์ƒ‰ + searchQuery: '', + searchResults: [], + + // ๋ชจ๋‹ฌ ์ƒํƒœ + showUploadModal: false, + showProfile: false, + showMyNotes: false, + showBookmarks: false, + showAdmin: false, + + // ์ดˆ๊ธฐํ™” + async init() { + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuth(); + + // ์ธ์ฆ ์ƒํƒœ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + window.addEventListener('auth-changed', (event) => { + this.isAuthenticated = event.detail.isAuthenticated; + this.user = event.detail.user; + + if (this.isAuthenticated) { + this.loadInitialData(); + } else { + this.resetData(); + } + }); + + // ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + if (this.isAuthenticated) { + await this.loadInitialData(); + } + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuth() { + if (!api.token) { + this.isAuthenticated = false; + return; + } + + try { + this.user = await api.getCurrentUser(); + this.isAuthenticated = true; + } catch (error) { + console.error('Auth check failed:', error); + this.isAuthenticated = false; + api.setToken(null); + } + }, + + // ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + async loadInitialData() { + this.loading = true; + try { + await Promise.all([ + this.loadDocuments(), + this.loadTags() + ]); + } catch (error) { + console.error('Failed to load initial data:', error); + } finally { + this.loading = false; + } + }, + + // ๋ฐ์ดํ„ฐ ๋ฆฌ์…‹ + resetData() { + this.documents = []; + this.tags = []; + this.selectedTag = ''; + this.searchQuery = ''; + this.searchResults = []; + }, + + // ๋ฌธ์„œ ๋ชฉ๋ก ๋กœ๋“œ + async loadDocuments() { + try { + const params = {}; + if (this.selectedTag) { + params.tag = this.selectedTag; + } + + this.documents = await api.getDocuments(params); + } catch (error) { + console.error('Failed to load documents:', error); + this.showNotification('๋ฌธ์„œ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค', 'error'); + } + }, + + // ํƒœ๊ทธ ๋ชฉ๋ก ๋กœ๋“œ + async loadTags() { + try { + this.tags = await api.getTags(); + } catch (error) { + console.error('Failed to load tags:', error); + } + }, + + // ๋ฌธ์„œ ๊ฒ€์ƒ‰ + async searchDocuments() { + if (!this.searchQuery.trim()) { + this.searchResults = []; + return; + } + + try { + const results = await api.search({ + q: this.searchQuery, + limit: 20 + }); + this.searchResults = results.results; + } catch (error) { + console.error('Search failed:', error); + } + }, + + // ๋ฌธ์„œ ์—ด๊ธฐ + openDocument(document) { + // ๋ฌธ์„œ ๋ทฐ์–ด ํŽ˜์ด์ง€๋กœ ์ด๋™ + window.location.href = `/viewer.html?id=${document.id}`; + }, + + // ๋กœ๊ทธ์•„์›ƒ + async logout() { + try { + await api.logout(); + this.isAuthenticated = false; + this.user = null; + this.resetData(); + } catch (error) { + console.error('Logout failed:', error); + } + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + // ๊ฐ„๋‹จํ•œ ์•Œ๋ฆผ ๊ตฌํ˜„ (๋‚˜์ค‘์— ํ† ์ŠคํŠธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ ๊ต์ฒด ๊ฐ€๋Šฅ) + const notification = document.createElement('div'); + notification.className = `fixed top-4 right-4 p-4 rounded-lg text-white z-50 ${ + type === 'error' ? 'bg-red-500' : + type === 'success' ? 'bg-green-500' : + 'bg-blue-500' + }`; + notification.textContent = message; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.remove(); + }, 3000); + } +}); + +// ํŒŒ์ผ ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ +window.uploadModal = () => ({ + show: false, + uploading: false, + uploadForm: { + title: '', + description: '', + tags: '', + is_public: false, + document_date: '', + html_file: null, + pdf_file: null + }, + uploadError: '', + + // ํŒŒ์ผ ์„ ํƒ + onFileSelect(event, fileType) { + const file = event.target.files[0]; + if (file) { + this.uploadForm[fileType] = file; + + // HTML ํŒŒ์ผ์˜ ๊ฒฝ์šฐ ์ œ๋ชฉ ์ž๋™ ์„ค์ • + if (fileType === 'html_file' && !this.uploadForm.title) { + this.uploadForm.title = file.name.replace(/\.[^/.]+$/, ""); + } + } + }, + + // ์—…๋กœ๋“œ ์‹คํ–‰ + async upload() { + if (!this.uploadForm.html_file) { + this.uploadError = 'HTML ํŒŒ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”'; + return; + } + + if (!this.uploadForm.title.trim()) { + this.uploadError = '์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; + return; + } + + this.uploading = true; + this.uploadError = ''; + + try { + const formData = new FormData(); + formData.append('title', this.uploadForm.title); + formData.append('description', this.uploadForm.description); + formData.append('tags', this.uploadForm.tags); + formData.append('is_public', this.uploadForm.is_public); + formData.append('html_file', this.uploadForm.html_file); + + if (this.uploadForm.pdf_file) { + formData.append('pdf_file', this.uploadForm.pdf_file); + } + + if (this.uploadForm.document_date) { + formData.append('document_date', this.uploadForm.document_date); + } + + await api.uploadDocument(formData); + + // ์„ฑ๊ณต์‹œ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ ๋ฐ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + this.show = false; + this.resetForm(); + + // ๋ฌธ์„œ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + window.dispatchEvent(new CustomEvent('documents-changed')); + + // ์„ฑ๊ณต ์•Œ๋ฆผ + document.querySelector('[x-data="documentApp"]').__x.$data.showNotification('๋ฌธ์„œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค', 'success'); + + } catch (error) { + this.uploadError = error.message; + } finally { + this.uploading = false; + } + }, + + // ํผ ๋ฆฌ์…‹ + resetForm() { + this.uploadForm = { + title: '', + description: '', + tags: '', + is_public: false, + document_date: '', + html_file: null, + pdf_file: null + }; + this.uploadError = ''; + + // ํŒŒ์ผ ์ž…๋ ฅ ํ•„๋“œ ๋ฆฌ์…‹ + const fileInputs = document.querySelectorAll('input[type="file"]'); + fileInputs.forEach(input => input.value = ''); + } +}); + +// ๋ฌธ์„œ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ +window.addEventListener('documents-changed', () => { + const app = document.querySelector('[x-data="documentApp"]').__x.$data; + if (app && app.isAuthenticated) { + app.loadDocuments(); + app.loadTags(); + } +}); diff --git a/frontend/static/js/viewer.js b/frontend/static/js/viewer.js new file mode 100644 index 0000000..99b2609 --- /dev/null +++ b/frontend/static/js/viewer.js @@ -0,0 +1,641 @@ +/** + * ๋ฌธ์„œ ๋ทฐ์–ด Alpine.js ์ปดํฌ๋„ŒํŠธ + */ +window.documentViewer = () => ({ + // ์ƒํƒœ + loading: true, + error: null, + document: null, + documentId: null, + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฐ ๋ฉ”๋ชจ + highlights: [], + notes: [], + selectedHighlightColor: '#FFFF00', + selectedText: '', + selectedRange: null, + + // ์ฑ…๊ฐˆํ”ผ + bookmarks: [], + + // UI ์ƒํƒœ + showNotesPanel: false, + showBookmarksPanel: false, + activePanel: 'notes', + + // ๊ฒ€์ƒ‰ + searchQuery: '', + noteSearchQuery: '', + filteredNotes: [], + + // ๋ชจ๋‹ฌ + showNoteModal: false, + showBookmarkModal: false, + editingNote: null, + editingBookmark: null, + noteLoading: false, + bookmarkLoading: false, + + // ํผ ๋ฐ์ดํ„ฐ + noteForm: { + content: '', + tags: '' + }, + bookmarkForm: { + title: '', + description: '' + }, + + // ์ดˆ๊ธฐํ™” + async init() { + // URL์—์„œ ๋ฌธ์„œ ID ์ถ”์ถœ + const urlParams = new URLSearchParams(window.location.search); + this.documentId = urlParams.get('id'); + + if (!this.documentId) { + this.error = '๋ฌธ์„œ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'; + this.loading = false; + return; + } + + // ์ธ์ฆ ํ™•์ธ + if (!api.token) { + window.location.href = '/'; + return; + } + + try { + await this.loadDocument(); + await this.loadDocumentData(); + } catch (error) { + console.error('Failed to load document:', error); + this.error = error.message; + } finally { + this.loading = false; + } + + // ์ดˆ๊ธฐ ํ•„ํ„ฐ๋ง + this.filterNotes(); + }, + + // ๋ฌธ์„œ ๋กœ๋“œ + async loadDocument() { + this.document = await api.getDocument(this.documentId); + + // HTML ๋‚ด์šฉ ๋กœ๋“œ + const response = await fetch(`/uploads/documents/${this.documentId}.html`); + if (!response.ok) { + throw new Error('๋ฌธ์„œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + } + + const htmlContent = await response.text(); + document.getElementById('document-content').innerHTML = htmlContent; + + // ํŽ˜์ด์ง€ ์ œ๋ชฉ ์—…๋ฐ์ดํŠธ + document.title = `${this.document.title} - Document Server`; + }, + + // ๋ฌธ์„œ ๊ด€๋ จ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + async loadDocumentData() { + try { + const [highlights, notes, bookmarks] = await Promise.all([ + api.getDocumentHighlights(this.documentId), + api.getDocumentNotes(this.documentId), + api.getDocumentBookmarks(this.documentId) + ]); + + this.highlights = highlights; + this.notes = notes; + this.bookmarks = bookmarks; + + // ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง + this.renderHighlights(); + + } catch (error) { + console.error('Failed to load document data:', error); + } + }, + + // ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง + renderHighlights() { + const content = document.getElementById('document-content'); + + // ๊ธฐ์กด ํ•˜์ด๋ผ์ดํŠธ ์ œ๊ฑฐ + content.querySelectorAll('.highlight').forEach(el => { + const parent = el.parentNode; + parent.replaceChild(document.createTextNode(el.textContent), el); + parent.normalize(); + }); + + // ์ƒˆ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + this.highlights.forEach(highlight => { + this.applyHighlight(highlight); + }); + }, + + // ๊ฐœ๋ณ„ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + applyHighlight(highlight) { + const content = document.getElementById('document-content'); + const walker = document.createTreeWalker( + content, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentOffset = 0; + let node; + + while (node = walker.nextNode()) { + const nodeLength = node.textContent.length; + const nodeStart = currentOffset; + const nodeEnd = currentOffset + nodeLength; + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฒ”์œ„์™€ ๊ฒน์น˜๋Š”์ง€ ํ™•์ธ + if (nodeStart < highlight.end_offset && nodeEnd > highlight.start_offset) { + const startInNode = Math.max(0, highlight.start_offset - nodeStart); + const endInNode = Math.min(nodeLength, highlight.end_offset - nodeStart); + + if (startInNode < endInNode) { + // ํ…์ŠคํŠธ ๋…ธ๋“œ๋ฅผ ๋ถ„ํ• ํ•˜๊ณ  ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + const beforeText = node.textContent.substring(0, startInNode); + const highlightText = node.textContent.substring(startInNode, endInNode); + const afterText = node.textContent.substring(endInNode); + + const parent = node.parentNode; + + // ํ•˜์ด๋ผ์ดํŠธ ์š”์†Œ ์ƒ์„ฑ + const highlightEl = document.createElement('span'); + highlightEl.className = 'highlight'; + highlightEl.style.backgroundColor = highlight.highlight_color; + highlightEl.textContent = highlightText; + highlightEl.dataset.highlightId = highlight.id; + + // ํด๋ฆญ ์ด๋ฒคํŠธ ์ถ”๊ฐ€ + highlightEl.addEventListener('click', (e) => { + e.stopPropagation(); + this.selectHighlight(highlight.id); + }); + + // ๋…ธ๋“œ ๊ต์ฒด + if (beforeText) { + parent.insertBefore(document.createTextNode(beforeText), node); + } + parent.insertBefore(highlightEl, node); + if (afterText) { + parent.insertBefore(document.createTextNode(afterText), node); + } + parent.removeChild(node); + } + } + + currentOffset = nodeEnd; + } + }, + + // ํ…์ŠคํŠธ ์„ ํƒ ์ฒ˜๋ฆฌ + handleTextSelection() { + const selection = window.getSelection(); + + if (selection.rangeCount === 0 || selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0); + const selectedText = selection.toString().trim(); + + if (selectedText.length < 2) { + return; + } + + // ๋ฌธ์„œ ์ปจํ…์ธ  ๋‚ด๋ถ€์˜ ์„ ํƒ์ธ์ง€ ํ™•์ธ + const content = document.getElementById('document-content'); + if (!content.contains(range.commonAncestorContainer)) { + return; + } + + // ์„ ํƒ๋œ ํ…์ŠคํŠธ์™€ ๋ฒ”์œ„ ์ €์žฅ + this.selectedText = selectedText; + this.selectedRange = range.cloneRange(); + + // ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ํ‘œ์‹œ (๊ฐ„๋‹จํ•œ ๋ฒ„ํŠผ) + this.showHighlightButton(selection); + }, + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฒ„ํŠผ ํ‘œ์‹œ + showHighlightButton(selection) { + // ๊ธฐ์กด ๋ฒ„ํŠผ ์ œ๊ฑฐ + const existingButton = document.querySelector('.highlight-button'); + if (existingButton) { + existingButton.remove(); + } + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + const button = document.createElement('button'); + button.className = 'highlight-button fixed z-50 bg-blue-600 text-white px-3 py-1 rounded shadow-lg text-sm'; + button.style.left = `${rect.left + window.scrollX}px`; + button.style.top = `${rect.bottom + window.scrollY + 5}px`; + button.innerHTML = 'ํ•˜์ด๋ผ์ดํŠธ'; + + button.addEventListener('click', () => { + this.createHighlight(); + button.remove(); + }); + + document.body.appendChild(button); + + // 3์ดˆ ํ›„ ์ž๋™ ์ œ๊ฑฐ + setTimeout(() => { + if (button.parentNode) { + button.remove(); + } + }, 3000); + }, + + // ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + async createHighlight() { + if (!this.selectedText || !this.selectedRange) { + return; + } + + try { + // ํ…์ŠคํŠธ ์˜คํ”„์…‹ ๊ณ„์‚ฐ + const content = document.getElementById('document-content'); + const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content); + + const highlightData = { + document_id: this.documentId, + start_offset: startOffset, + end_offset: endOffset, + selected_text: this.selectedText, + highlight_color: this.selectedHighlightColor, + highlight_type: 'highlight' + }; + + const highlight = await api.createHighlight(highlightData); + this.highlights.push(highlight); + + // ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง + this.renderHighlights(); + + // ์„ ํƒ ํ•ด์ œ + window.getSelection().removeAllRanges(); + this.selectedText = ''; + this.selectedRange = null; + + // ๋ฉ”๋ชจ ์ถ”๊ฐ€ ์—ฌ๋ถ€ ํ™•์ธ + if (confirm('์ด ํ•˜์ด๋ผ์ดํŠธ์— ๋ฉ”๋ชจ๋ฅผ ์ถ”๊ฐ€ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + this.openNoteModal(highlight); + } + + } catch (error) { + console.error('Failed to create highlight:', error); + alert('ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + }, + + // ํ…์ŠคํŠธ ์˜คํ”„์…‹ ๊ณ„์‚ฐ + calculateTextOffsets(range, container) { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentOffset = 0; + let startOffset = -1; + let endOffset = -1; + let node; + + while (node = walker.nextNode()) { + const nodeLength = node.textContent.length; + + if (range.startContainer === node) { + startOffset = currentOffset + range.startOffset; + } + + if (range.endContainer === node) { + endOffset = currentOffset + range.endOffset; + break; + } + + currentOffset += nodeLength; + } + + return { startOffset, endOffset }; + }, + + // ํ•˜์ด๋ผ์ดํŠธ ์„ ํƒ + selectHighlight(highlightId) { + // ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ์—์„œ selected ํด๋ž˜์Šค ์ œ๊ฑฐ + document.querySelectorAll('.highlight').forEach(el => { + el.classList.remove('selected'); + }); + + // ์„ ํƒ๋œ ํ•˜์ด๋ผ์ดํŠธ์— selected ํด๋ž˜์Šค ์ถ”๊ฐ€ + const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`); + if (highlightEl) { + highlightEl.classList.add('selected'); + } + + // ํ•ด๋‹น ํ•˜์ด๋ผ์ดํŠธ์˜ ๋ฉ”๋ชจ ์ฐพ๊ธฐ + const note = this.notes.find(n => n.highlight.id === highlightId); + if (note) { + this.editNote(note); + } else { + // ๋ฉ”๋ชจ๊ฐ€ ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ + const highlight = this.highlights.find(h => h.id === highlightId); + if (highlight) { + this.openNoteModal(highlight); + } + } + }, + + // ํ•˜์ด๋ผ์ดํŠธ๋กœ ์Šคํฌ๋กค + scrollToHighlight(highlightId) { + const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`); + if (highlightEl) { + highlightEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + highlightEl.classList.add('selected'); + + // 2์ดˆ ํ›„ ์„ ํƒ ํ•ด์ œ + setTimeout(() => { + highlightEl.classList.remove('selected'); + }, 2000); + } + }, + + // ๋ฉ”๋ชจ ๋ชจ๋‹ฌ ์—ด๊ธฐ + openNoteModal(highlight = null) { + this.editingNote = null; + this.noteForm = { + content: '', + tags: '' + }; + + if (highlight) { + this.selectedHighlight = highlight; + this.selectedText = highlight.selected_text; + } + + this.showNoteModal = true; + }, + + // ๋ฉ”๋ชจ ํŽธ์ง‘ + editNote(note) { + this.editingNote = note; + this.noteForm = { + content: note.content, + tags: note.tags ? note.tags.join(', ') : '' + }; + this.selectedText = note.highlight.selected_text; + this.showNoteModal = true; + }, + + // ๋ฉ”๋ชจ ์ €์žฅ + async saveNote() { + this.noteLoading = true; + + try { + const noteData = { + content: this.noteForm.content, + tags: this.noteForm.tags ? this.noteForm.tags.split(',').map(t => t.trim()).filter(t => t) : [] + }; + + if (this.editingNote) { + // ๋ฉ”๋ชจ ์ˆ˜์ • + const updatedNote = await api.updateNote(this.editingNote.id, noteData); + const index = this.notes.findIndex(n => n.id === this.editingNote.id); + if (index !== -1) { + this.notes[index] = updatedNote; + } + } else { + // ์ƒˆ ๋ฉ”๋ชจ ์ƒ์„ฑ + noteData.highlight_id = this.selectedHighlight.id; + const newNote = await api.createNote(noteData); + this.notes.push(newNote); + } + + this.filterNotes(); + this.closeNoteModal(); + + } catch (error) { + console.error('Failed to save note:', error); + alert('๋ฉ”๋ชจ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } finally { + this.noteLoading = false; + } + }, + + // ๋ฉ”๋ชจ ์‚ญ์ œ + async deleteNote(noteId) { + if (!confirm('์ด ๋ฉ”๋ชจ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + await api.deleteNote(noteId); + this.notes = this.notes.filter(n => n.id !== noteId); + this.filterNotes(); + } catch (error) { + console.error('Failed to delete note:', error); + alert('๋ฉ”๋ชจ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + }, + + // ๋ฉ”๋ชจ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeNoteModal() { + this.showNoteModal = false; + this.editingNote = null; + this.selectedHighlight = null; + this.selectedText = ''; + this.noteForm = { content: '', tags: '' }; + }, + + // ๋ฉ”๋ชจ ํ•„ํ„ฐ๋ง + filterNotes() { + if (!this.noteSearchQuery.trim()) { + this.filteredNotes = [...this.notes]; + } else { + const query = this.noteSearchQuery.toLowerCase(); + this.filteredNotes = this.notes.filter(note => + note.content.toLowerCase().includes(query) || + note.highlight.selected_text.toLowerCase().includes(query) || + (note.tags && note.tags.some(tag => tag.toLowerCase().includes(query))) + ); + } + }, + + // ์ฑ…๊ฐˆํ”ผ ์ถ”๊ฐ€ + async addBookmark() { + const scrollPosition = window.scrollY; + this.bookmarkForm = { + title: `${this.document.title} - ${new Date().toLocaleString()}`, + description: '' + }; + this.currentScrollPosition = scrollPosition; + this.showBookmarkModal = true; + }, + + // ์ฑ…๊ฐˆํ”ผ ํŽธ์ง‘ + editBookmark(bookmark) { + this.editingBookmark = bookmark; + this.bookmarkForm = { + title: bookmark.title, + description: bookmark.description || '' + }; + this.showBookmarkModal = true; + }, + + // ์ฑ…๊ฐˆํ”ผ ์ €์žฅ + async saveBookmark() { + this.bookmarkLoading = true; + + try { + const bookmarkData = { + title: this.bookmarkForm.title, + description: this.bookmarkForm.description, + scroll_position: this.currentScrollPosition || 0 + }; + + if (this.editingBookmark) { + // ์ฑ…๊ฐˆํ”ผ ์ˆ˜์ • + const updatedBookmark = await api.updateBookmark(this.editingBookmark.id, bookmarkData); + const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark.id); + if (index !== -1) { + this.bookmarks[index] = updatedBookmark; + } + } else { + // ์ƒˆ ์ฑ…๊ฐˆํ”ผ ์ƒ์„ฑ + bookmarkData.document_id = this.documentId; + const newBookmark = await api.createBookmark(bookmarkData); + this.bookmarks.push(newBookmark); + } + + this.closeBookmarkModal(); + + } catch (error) { + console.error('Failed to save bookmark:', error); + alert('์ฑ…๊ฐˆํ”ผ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } finally { + this.bookmarkLoading = false; + } + }, + + // ์ฑ…๊ฐˆํ”ผ ์‚ญ์ œ + async deleteBookmark(bookmarkId) { + if (!confirm('์ด ์ฑ…๊ฐˆํ”ผ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + await api.deleteBookmark(bookmarkId); + this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId); + } catch (error) { + console.error('Failed to delete bookmark:', error); + alert('์ฑ…๊ฐˆํ”ผ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + }, + + // ์ฑ…๊ฐˆํ”ผ๋กœ ์Šคํฌ๋กค + scrollToBookmark(bookmark) { + window.scrollTo({ + top: bookmark.scroll_position, + behavior: 'smooth' + }); + }, + + // ์ฑ…๊ฐˆํ”ผ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeBookmarkModal() { + this.showBookmarkModal = false; + this.editingBookmark = null; + this.bookmarkForm = { title: '', description: '' }; + this.currentScrollPosition = null; + }, + + // ๋ฌธ์„œ ๋‚ด ๊ฒ€์ƒ‰ + searchInDocument() { + // ๊ธฐ์กด ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ์ œ๊ฑฐ + document.querySelectorAll('.search-highlight').forEach(el => { + const parent = el.parentNode; + parent.replaceChild(document.createTextNode(el.textContent), el); + parent.normalize(); + }); + + if (!this.searchQuery.trim()) { + return; + } + + // ์ƒˆ ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + const content = document.getElementById('document-content'); + this.highlightSearchResults(content, this.searchQuery); + }, + + // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํ•˜์ด๋ผ์ดํŠธ + highlightSearchResults(element, searchText) { + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + textNodes.push(node); + } + + textNodes.forEach(textNode => { + const text = textNode.textContent; + const regex = new RegExp(`(${searchText})`, 'gi'); + + if (regex.test(text)) { + const parent = textNode.parentNode; + const highlightedHTML = text.replace(regex, '$1'); + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = highlightedHTML; + + while (tempDiv.firstChild) { + parent.insertBefore(tempDiv.firstChild, textNode); + } + parent.removeChild(textNode); + } + }); + }, + + // ๋ฌธ์„œ ํด๋ฆญ ์ฒ˜๋ฆฌ + handleDocumentClick(event) { + // ํ•˜์ด๋ผ์ดํŠธ ๋ฒ„ํŠผ ์ œ๊ฑฐ + const button = document.querySelector('.highlight-button'); + if (button && !button.contains(event.target)) { + button.remove(); + } + + // ํ•˜์ด๋ผ์ดํŠธ ์„ ํƒ ํ•ด์ œ + document.querySelectorAll('.highlight.selected').forEach(el => { + el.classList.remove('selected'); + }); + }, + + // ๋’ค๋กœ๊ฐ€๊ธฐ + goBack() { + window.history.back(); + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } +}); diff --git a/frontend/viewer.html b/frontend/viewer.html new file mode 100644 index 0000000..53de403 --- /dev/null +++ b/frontend/viewer.html @@ -0,0 +1,363 @@ + + + + + + ๋ฌธ์„œ ๋ทฐ์–ด - Document Server + + + + + + + + +
+ +
+
+
+ +
+ +
+

+

+
+
+ + +
+ +
+ + + + +
+ + + + + + + + +
+ + +
+
+
+
+
+ + +
+ +
+
+ +
+ +

๋ฌธ์„œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+ +

+ +
+ + +
+ +
+
+
+ + + +
+ + +
+
+
+

+ +
+ + +
+

์„ ํƒ๋œ ํ…์ŠคํŠธ:

+

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + + + + + diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..d837f64 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,15 @@ +FROM nginx:1.24-alpine + +# ์„ค์ • ํŒŒ์ผ ๋ณต์‚ฌ +COPY nginx.conf /etc/nginx/nginx.conf +COPY default.conf /etc/nginx/conf.d/default.conf + +# ์ •์  ํŒŒ์ผ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +RUN mkdir -p /usr/share/nginx/html/uploads + +# ๊ถŒํ•œ ์„ค์ • +RUN chown -R nginx:nginx /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..3ddd3c4 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,75 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # ์ •์  ํŒŒ์ผ ์บ์‹ฑ + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # API ์š”์ฒญ์„ ๋ฐฑ์—”๋“œ๋กœ ํ”„๋ก์‹œ + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # ํƒ€์ž„์•„์›ƒ ์„ค์ • + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + + # ๋ฒ„ํผ๋ง ์„ค์ • + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + + # ์—…๋กœ๋“œ๋œ ๋ฌธ์„œ ํŒŒ์ผ ์„œ๋น™ + location /uploads/ { + alias /usr/share/nginx/html/uploads/; + + # ๋ณด์•ˆ์„ ์œ„ํ•ด ์‹คํ–‰ ํŒŒ์ผ ์ฐจ๋‹จ + location ~* \.(php|pl|py|jsp|asp|sh|cgi)$ { + deny all; + } + + # HTML ํŒŒ์ผ์€ iframe์—์„œ ์•ˆ์ „ํ•˜๊ฒŒ ๋กœ๋“œ + location ~* \.html$ { + add_header X-Frame-Options "SAMEORIGIN"; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline'"; + } + } + + # ๋ฉ”์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ + location / { + try_files $uri $uri/ /index.html; + + # SPA๋ฅผ ์œ„ํ•œ ํžˆ์Šคํ† ๋ฆฌ API ์ง€์› + location ~* ^.+\.(html|htm)$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + } + + # ํ—ฌ์Šค์ฒดํฌ ์—”๋“œํฌ์ธํŠธ + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # ์—๋Ÿฌ ํŽ˜์ด์ง€ + error_page 404 /404.html; + error_page 500 502 503 504 /50x.html; + + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..af561f5 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,57 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # ๋กœ๊ทธ ํฌ๋งท + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # ์„ฑ๋Šฅ ์ตœ์ ํ™” + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + # Gzip ์••์ถ• + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # ๋ณด์•ˆ ํ—ค๋” + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # ๊ฐ€์ƒ ํ˜ธ์ŠคํŠธ ์„ค์ • ํฌํ•จ + include /etc/nginx/conf.d/*.conf; +}