🎉 Initial commit: Document Server MVP

 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)
This commit is contained in:
Hyungi Ahn
2025-08-21 16:09:17 +09:00
commit 3036b8f0fb
40 changed files with 6303 additions and 0 deletions

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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