diff --git a/DSCF0130.RAF_compressed.JPEG b/DSCF0130.RAF_compressed.JPEG new file mode 100644 index 0000000..d5667ff Binary files /dev/null and b/DSCF0130.RAF_compressed.JPEG differ diff --git a/DSCF0313.RAF_compressed.JPEG b/DSCF0313.RAF_compressed.JPEG new file mode 100644 index 0000000..e295247 Binary files /dev/null and b/DSCF0313.RAF_compressed.JPEG differ diff --git a/DSCF0390.RAF_compressed.JPEG b/DSCF0390.RAF_compressed.JPEG new file mode 100644 index 0000000..bd1f4d5 Binary files /dev/null and b/DSCF0390.RAF_compressed.JPEG differ diff --git a/backend/migrations/011_create_note_links.sql b/backend/migrations/011_create_note_links.sql index 2a3e48a..f65c78c 100644 --- a/backend/migrations/011_create_note_links.sql +++ b/backend/migrations/011_create_note_links.sql @@ -72,3 +72,4 @@ COMMENT ON COLUMN note_links.source_document_id IS '출발점 문서 ID (문서 COMMENT ON COLUMN note_links.target_note_id IS '도착점 노트 ID'; COMMENT ON COLUMN note_links.target_document_id IS '도착점 문서 ID'; COMMENT ON COLUMN note_links.link_type IS '링크 타입: note, document, text_fragment'; + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c27cc9f..c884ef3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,6 +23,8 @@ python-dotenv = "^1.0.0" httpx = "^0.25.0" aiofiles = "^23.2.0" jinja2 = "^3.1.0" +beautifulsoup4 = "^4.13.0" +pypdf2 = "^3.0.0" [tool.poetry.group.dev.dependencies] pytest = "^7.4.0" diff --git a/backend/src/api/dependencies.py b/backend/src/api/dependencies.py index 19fe7a7..ae03205 100644 --- a/backend/src/api/dependencies.py +++ b/backend/src/api/dependencies.py @@ -1,7 +1,7 @@ """ API 의존성 """ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Query from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -86,3 +86,54 @@ async def get_optional_current_user( return await get_current_user(credentials, db) except HTTPException: return None + + +async def get_current_user_with_token_param( + _token: Optional[str] = Query(None), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db) +) -> User: + """URL 파라미터 또는 헤더에서 토큰을 가져와서 사용자 인증""" + token = None + + # URL 파라미터에서 토큰 확인 + if _token: + token = _token + # Authorization 헤더에서 토큰 확인 + elif credentials: + token = credentials.credentials + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No authentication token provided" + ) + + try: + # 토큰에서 사용자 ID 추출 + user_id = get_user_id_from_token(token) + + # 데이터베이스에서 사용자 조회 + 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: + print(f"🚫 토큰 인증 실패: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py index 5dffe83..45ec12e 100644 --- a/backend/src/api/routes/auth.py +++ b/backend/src/api/routes/auth.py @@ -46,8 +46,11 @@ async def login( detail="Inactive user" ) - # 토큰 생성 - access_token = create_access_token(data={"sub": str(user.id)}) + # 사용자별 세션 타임아웃을 적용한 토큰 생성 + access_token = create_access_token( + data={"sub": str(user.id)}, + timeout_minutes=user.session_timeout_minutes + ) refresh_token = create_refresh_token(data={"sub": str(user.id)}) # 마지막 로그인 시간 업데이트 diff --git a/backend/src/api/routes/books.py b/backend/src/api/routes/books.py index dce1034..067a266 100644 --- a/backend/src/api/routes/books.py +++ b/backend/src/api/routes/books.py @@ -33,7 +33,7 @@ async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse: document_count=document_count ) -@router.post("/", response_model=BookResponse, status_code=status.HTTP_201_CREATED) +@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED) async def create_book( book_data: CreateBookRequest, current_user: User = Depends(get_current_active_user), @@ -58,7 +58,7 @@ async def create_book( await db.refresh(new_book) return await _get_book_response(db, new_book) -@router.get("/", response_model=List[BookResponse]) +@router.get("", response_model=List[BookResponse]) async def get_books( skip: int = 0, limit: int = 50, diff --git a/backend/src/api/routes/documents.py b/backend/src/api/routes/documents.py index 0104844..57521da 100644 --- a/backend/src/api/routes/documents.py +++ b/backend/src/api/routes/documents.py @@ -1,7 +1,7 @@ """ 문서 관리 API 라우터 """ -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete, and_, or_, update from sqlalchemy.orm import selectinload @@ -17,7 +17,7 @@ from ...core.config import settings from ...models.user import User from ...models.document import Document, Tag from ...models.book import Book -from ..dependencies import get_current_active_user, get_current_admin_user +from ..dependencies import get_current_active_user, get_current_admin_user, get_current_user_with_token_param from pydantic import BaseModel from datetime import datetime @@ -469,7 +469,7 @@ async def get_document( async def get_document_content( document_id: str, _token: Optional[str] = Query(None), - current_user: User = Depends(get_current_active_user), + current_user: User = Depends(get_current_user_with_token_param), db: AsyncSession = Depends(get_db) ): """문서 HTML 콘텐츠 조회""" @@ -512,7 +512,7 @@ async def get_document_content( async def get_document_pdf( document_id: str, _token: Optional[str] = Query(None), - current_user: User = Depends(get_current_active_user), + current_user: User = Depends(get_current_user_with_token_param), db: AsyncSession = Depends(get_db) ): """문서 PDF 파일 조회""" @@ -535,6 +535,7 @@ async def get_document_pdf( # PDF 파일 확인 if not document.pdf_path: + print(f"🚫 PDF 경로가 데이터베이스에 없음: {document.title}") raise HTTPException(status_code=404, detail="PDF file not found for this document") # PDF 파일 경로 처리 @@ -544,9 +545,21 @@ async def get_document_pdf( if document.pdf_path.startswith('/'): file_path = document.pdf_path else: - file_path = os.path.join("/app/data/documents", document.pdf_path) + # PDF 파일은 /app/uploads에 저장됨 + file_path = os.path.join("/app", document.pdf_path) + + print(f"🔍 PDF 파일 경로 확인: {file_path}") + print(f"📁 데이터베이스 PDF 경로: {document.pdf_path}") if not os.path.exists(file_path): + print(f"🚫 PDF 파일이 디스크에 없음: {file_path}") + # 디렉토리 내용 확인 + dir_path = os.path.dirname(file_path) + if os.path.exists(dir_path): + files = os.listdir(dir_path) + print(f"📂 디렉토리 내용: {files[:10]}") + else: + print(f"📂 디렉토리도 없음: {dir_path}") raise HTTPException(status_code=404, detail="PDF file not found on disk") return FileResponse( diff --git a/backend/src/api/routes/note_documents.py b/backend/src/api/routes/note_documents.py index d074c70..89da0fa 100644 --- a/backend/src/api/routes/note_documents.py +++ b/backend/src/api/routes/note_documents.py @@ -178,6 +178,7 @@ def create_note_document( is_published=note_data.is_published, parent_note_id=note_data.parent_note_id, sort_order=note_data.sort_order, + notebook_id=note_data.notebook_id, created_by=current_user.email, word_count=word_count, reading_time=reading_time diff --git a/backend/src/api/routes/search.py b/backend/src/api/routes/search.py index bdc9d15..7948249 100644 --- a/backend/src/api/routes/search.py +++ b/backend/src/api/routes/search.py @@ -390,12 +390,15 @@ async def search_highlight_notes( # 하이라이트가 있는 노트만 query_obj = query_obj.where(Note.highlight_id.isnot(None)) + # Highlight와 조인 (권한 및 문서 필터링을 위해) + query_obj = query_obj.join(Highlight) + # 권한 필터링 - 사용자의 노트만 - query_obj = query_obj.where(Note.created_by == current_user.id) + query_obj = query_obj.where(Highlight.user_id == current_user.id) # 특정 문서 필터 if document_id: - query_obj = query_obj.join(Highlight).where(Highlight.document_id == document_id) + query_obj = query_obj.where(Highlight.document_id == document_id) # 메모 내용에서 검색 query_obj = query_obj.where(Note.content.ilike(f"%{query}%")) @@ -560,7 +563,7 @@ async def search_document_content( if doc.html_path.startswith('/'): html_file_path = doc.html_path else: - html_file_path = os.path.join("/app/data/documents", doc.html_path) + html_file_path = os.path.join("/app", doc.html_path) if os.path.exists(html_file_path): with open(html_file_path, 'r', encoding='utf-8') as f: @@ -590,7 +593,7 @@ async def search_document_content( if doc.pdf_path.startswith('/'): pdf_file_path = doc.pdf_path else: - pdf_file_path = os.path.join("/app/data/documents", doc.pdf_path) + pdf_file_path = os.path.join("/app", doc.pdf_path) if os.path.exists(pdf_file_path): with open(pdf_file_path, 'rb') as f: diff --git a/backend/src/api/routes/setup.py b/backend/src/api/routes/setup.py new file mode 100644 index 0000000..6b90ae0 --- /dev/null +++ b/backend/src/api/routes/setup.py @@ -0,0 +1,104 @@ +""" +시스템 초기 설정 API +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel, EmailStr +from typing import Optional + +from ...core.database import get_db +from ...core.security import get_password_hash +from ...models.user import User + +router = APIRouter() + + +class InitialSetupRequest(BaseModel): + """초기 설정 요청""" + admin_email: EmailStr + admin_password: str + admin_full_name: Optional[str] = None + + +class SetupStatusResponse(BaseModel): + """설정 상태 응답""" + is_setup_required: bool + has_admin_user: bool + total_users: int + + +@router.get("/status", response_model=SetupStatusResponse) +async def get_setup_status(db: AsyncSession = Depends(get_db)): + """시스템 설정 상태 확인""" + # 전체 사용자 수 조회 + total_users_result = await db.execute(select(func.count(User.id))) + total_users = total_users_result.scalar() + + # 관리자 사용자 존재 여부 확인 + admin_result = await db.execute( + select(User).where(User.role == "root") + ) + has_admin_user = admin_result.scalar_one_or_none() is not None + + return SetupStatusResponse( + is_setup_required=total_users == 0 or not has_admin_user, + has_admin_user=has_admin_user, + total_users=total_users + ) + + +@router.post("/initialize") +async def initialize_system( + setup_data: InitialSetupRequest, + db: AsyncSession = Depends(get_db) +): + """시스템 초기 설정 (root 계정 생성)""" + # 이미 설정된 시스템인지 확인 + existing_admin = await db.execute( + select(User).where(User.role == "root") + ) + if existing_admin.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="System is already initialized" + ) + + # 이메일 중복 확인 + existing_user = await db.execute( + select(User).where(User.email == setup_data.admin_email) + ) + if existing_user.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Root 관리자 계정 생성 + hashed_password = get_password_hash(setup_data.admin_password) + + admin_user = User( + email=setup_data.admin_email, + hashed_password=hashed_password, + full_name=setup_data.admin_full_name or "시스템 관리자", + is_active=True, + is_admin=True, + role="root", + can_manage_books=True, + can_manage_notes=True, + can_manage_novels=True + ) + + db.add(admin_user) + await db.commit() + await db.refresh(admin_user) + + return { + "message": "System initialized successfully", + "admin_user": { + "id": str(admin_user.id), + "email": admin_user.email, + "full_name": admin_user.full_name, + "role": admin_user.role + } + } diff --git a/backend/src/api/routes/users.py b/backend/src/api/routes/users.py index b43868b..84f7737 100644 --- a/backend/src/api/routes/users.py +++ b/backend/src/api/routes/users.py @@ -1,92 +1,276 @@ """ -사용자 관리 API 라우터 +사용자 관리 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 sqlalchemy.orm import selectinload +from pydantic import BaseModel, EmailStr +from typing import List, Optional +from datetime import datetime from ...core.database import get_db +from ...core.security import get_password_hash, verify_password from ...models.user import User -from ...schemas.auth import UserInfo from ..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( +class UserResponse(BaseModel): + """사용자 응답""" + id: str + email: str + full_name: Optional[str] + is_active: bool + is_admin: bool + role: str + can_manage_books: bool + can_manage_notes: bool + can_manage_novels: bool + session_timeout_minutes: int + theme: str + language: str + timezone: str + created_at: datetime + updated_at: Optional[datetime] + last_login: Optional[datetime] + + class Config: + from_attributes = True + + +class CreateUserRequest(BaseModel): + """사용자 생성 요청""" + email: EmailStr + password: str + full_name: Optional[str] = None + role: str = "user" + can_manage_books: bool = True + can_manage_notes: bool = True + can_manage_novels: bool = True + session_timeout_minutes: int = 5 + + +class UpdateUserRequest(BaseModel): + """사용자 업데이트 요청""" + full_name: Optional[str] = None + is_active: Optional[bool] = None + role: Optional[str] = None + can_manage_books: Optional[bool] = None + can_manage_notes: Optional[bool] = None + can_manage_novels: Optional[bool] = None + session_timeout_minutes: Optional[int] = None + + +class UpdateProfileRequest(BaseModel): + """프로필 업데이트 요청""" + full_name: Optional[str] = None + theme: Optional[str] = None + language: Optional[str] = None + timezone: Optional[str] = None + + +class ChangePasswordRequest(BaseModel): + """비밀번호 변경 요청""" + current_password: str + new_password: str + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_profile( current_user: User = Depends(get_current_active_user) ): """현재 사용자 프로필 조회""" - return UserInfo.from_orm(current_user) + return UserResponse( + id=str(current_user.id), + email=current_user.email, + full_name=current_user.full_name, + is_active=current_user.is_active, + is_admin=current_user.is_admin, + role=current_user.role, + can_manage_books=current_user.can_manage_books, + can_manage_notes=current_user.can_manage_notes, + can_manage_novels=current_user.can_manage_novels, + session_timeout_minutes=current_user.session_timeout_minutes, + theme=current_user.theme, + language=current_user.language, + timezone=current_user.timezone, + created_at=current_user.created_at, + updated_at=current_user.updated_at, + last_login=current_user.last_login + ) -@router.put("/profile", response_model=UserInfo) -async def update_profile( +@router.put("/me", response_model=UserResponse) +async def update_current_user_profile( profile_data: UpdateProfileRequest, current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db) ): - """프로필 업데이트""" - update_data = {} + """현재 사용자 프로필 업데이트""" + update_fields = profile_data.model_dump(exclude_unset=True) - 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 + for field, value in update_fields.items(): + setattr(current_user, field, value) - if update_data: - await db.execute( - update(User) - .where(User.id == current_user.id) - .values(**update_data) + current_user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(current_user) + + return UserResponse( + id=str(current_user.id), + email=current_user.email, + full_name=current_user.full_name, + is_active=current_user.is_active, + is_admin=current_user.is_admin, + role=current_user.role, + can_manage_books=current_user.can_manage_books, + can_manage_notes=current_user.can_manage_notes, + can_manage_novels=current_user.can_manage_novels, + session_timeout_minutes=current_user.session_timeout_minutes, + theme=current_user.theme, + language=current_user.language, + timezone=current_user.timezone, + created_at=current_user.created_at, + updated_at=current_user.updated_at, + last_login=current_user.last_login + ) + + +@router.post("/me/change-password") +async def change_current_user_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="Current password is incorrect" ) - await db.commit() - await db.refresh(current_user) - return UserInfo.from_orm(current_user) + # 새 비밀번호 설정 + current_user.hashed_password = get_password_hash(password_data.new_password) + current_user.updated_at = datetime.utcnow() + + await db.commit() + + return {"message": "Password changed successfully"} -@router.get("/", response_model=List[UserInfo]) +@router.get("/", response_model=List[UserResponse]) async def list_users( - admin_user: User = Depends(get_current_admin_user), + skip: int = 0, + limit: int = 50, + current_user: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_db) ): """사용자 목록 조회 (관리자 전용)""" - result = await db.execute(select(User).order_by(User.created_at.desc())) + result = await db.execute( + select(User) + .order_by(User.created_at.desc()) + .offset(skip) + .limit(limit) + ) users = result.scalars().all() - return [UserInfo.from_orm(user) for user in users] + + return [ + UserResponse( + id=str(user.id), + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_admin=user.is_admin, + role=user.role, + can_manage_books=user.can_manage_books, + can_manage_notes=user.can_manage_notes, + can_manage_novels=user.can_manage_novels, + session_timeout_minutes=user.session_timeout_minutes, + theme=user.theme, + language=user.language, + timezone=user.timezone, + created_at=user.created_at, + updated_at=user.updated_at, + last_login=user.last_login + ) + 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), +@router.post("/", response_model=UserResponse) +async def create_user( + user_data: CreateUserRequest, + current_user: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_db) ): - """특정 사용자 조회 (관리자 전용)""" + """사용자 생성 (관리자 전용)""" + # 이메일 중복 확인 + existing_user = await db.execute( + select(User).where(User.email == user_data.email) + ) + if existing_user.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # 권한 확인 (root만 admin/root 계정 생성 가능) + if user_data.role in ["admin", "root"] and current_user.role != "root": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only root users can create admin accounts" + ) + + # 사용자 생성 + hashed_password = get_password_hash(user_data.password) + + new_user = User( + email=user_data.email, + hashed_password=hashed_password, + full_name=user_data.full_name, + is_active=True, + is_admin=user_data.role in ["admin", "root"], + role=user_data.role, + can_manage_books=user_data.can_manage_books, + can_manage_notes=user_data.can_manage_notes, + can_manage_novels=user_data.can_manage_novels, + session_timeout_minutes=user_data.session_timeout_minutes + ) + + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return UserResponse( + id=str(new_user.id), + email=new_user.email, + full_name=new_user.full_name, + is_active=new_user.is_active, + is_admin=new_user.is_admin, + role=new_user.role, + can_manage_books=new_user.can_manage_books, + can_manage_notes=new_user.can_manage_notes, + can_manage_novels=new_user.can_manage_novels, + session_timeout_minutes=new_user.session_timeout_minutes, + theme=new_user.theme, + language=new_user.language, + timezone=new_user.timezone, + created_at=new_user.created_at, + updated_at=new_user.updated_at, + last_login=new_user.last_login + ) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: str, + current_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() @@ -96,18 +280,34 @@ async def get_user( detail="User not found" ) - return UserInfo.from_orm(user) + return UserResponse( + id=str(user.id), + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_admin=user.is_admin, + role=user.role, + can_manage_books=user.can_manage_books, + can_manage_notes=user.can_manage_notes, + can_manage_novels=user.can_manage_novels, + session_timeout_minutes=user.session_timeout_minutes, + theme=user.theme, + language=user.language, + timezone=user.timezone, + created_at=user.created_at, + updated_at=user.updated_at, + last_login=user.last_login + ) -@router.put("/{user_id}", response_model=UserInfo) +@router.put("/{user_id}", response_model=UserResponse) async def update_user( user_id: str, user_data: UpdateUserRequest, - admin_user: User = Depends(get_current_admin_user), + current_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() @@ -117,42 +317,55 @@ async def update_user( detail="User not found" ) - # 자기 자신의 관리자 권한은 제거할 수 없음 - if user.id == admin_user.id and user_data.is_admin is False: + # 권한 확인 (root만 admin/root 계정 수정 가능) + if user.role in ["admin", "root"] and current_user.role != "root": raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot remove admin privileges from yourself" + status_code=status.HTTP_403_FORBIDDEN, + detail="Only root users can modify admin accounts" ) - # 업데이트할 데이터 준비 - 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 + # 업데이트할 필드들 적용 + update_fields = user_data.model_dump(exclude_unset=True) - if update_data: - await db.execute( - update(User) - .where(User.id == user_id) - .values(**update_data) - ) - await db.commit() - await db.refresh(user) + for field, value in update_fields.items(): + if field == "role": + # 역할 변경 시 is_admin도 함께 업데이트 + setattr(user, field, value) + user.is_admin = value in ["admin", "root"] + else: + setattr(user, field, value) - return UserInfo.from_orm(user) + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + + return UserResponse( + id=str(user.id), + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_admin=user.is_admin, + role=user.role, + can_manage_books=user.can_manage_books, + can_manage_notes=user.can_manage_notes, + can_manage_novels=user.can_manage_novels, + session_timeout_minutes=user.session_timeout_minutes, + theme=user.theme, + language=user.language, + timezone=user.timezone, + created_at=user.created_at, + updated_at=user.updated_at, + last_login=user.last_login + ) @router.delete("/{user_id}") async def delete_user( user_id: str, - admin_user: User = Depends(get_current_admin_user), + current_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() @@ -162,15 +375,28 @@ async def delete_user( detail="User not found" ) - # 자기 자신은 삭제할 수 없음 - if user.id == admin_user.id: + # 자기 자신 삭제 방지 + if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot delete yourself" + detail="Cannot delete your own account" + ) + + # root 계정 삭제 방지 + if user.role == "root": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete root account" + ) + + # 권한 확인 (root만 admin 계정 삭제 가능) + if user.role == "admin" and current_user.role != "root": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only root users can delete admin accounts" ) - # 사용자 삭제 await db.execute(delete(User).where(User.id == user_id)) await db.commit() - return {"message": "User deleted successfully"} + return {"message": "User deleted successfully"} \ No newline at end of file diff --git a/backend/src/core/security.py b/backend/src/core/security.py index 4677dff..2f26036 100644 --- a/backend/src/core/security.py +++ b/backend/src/core/security.py @@ -24,11 +24,18 @@ def get_password_hash(password: str) -> str: return pwd_context.hash(password) -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str: """액세스 토큰 생성""" to_encode = data.copy() + if expires_delta: expire = datetime.utcnow() + expires_delta + elif timeout_minutes is not None: + if timeout_minutes == 0: + # 무제한 토큰 (1년으로 설정) + expire = datetime.utcnow() + timedelta(days=365) + else: + expire = datetime.utcnow() + timedelta(minutes=timeout_minutes) else: expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) diff --git a/backend/src/main.py b/backend/src/main.py index 04dbdad..829f711 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -9,8 +9,7 @@ import uvicorn from .core.config import settings from .core.database import init_db -from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes -from .api.routes.notes import router as note_documents_router +from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup from .api.routes import note_documents, note_links @@ -44,19 +43,19 @@ app.add_middleware( app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") # API 라우터 등록 +app.include_router(setup.router, prefix="/api/setup", tags=["시스템 설정"]) 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(notes.router, prefix="/api/highlight-notes", tags=["하이라이트 메모"]) app.include_router(books.router, prefix="/api/books", tags=["서적"]) app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"]) app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"]) app.include_router(search.router, prefix="/api/search", tags=["검색"]) app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"]) app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"]) -app.include_router(note_documents_router, prefix="/api/note-documents", tags=["노트 문서"]) -app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서 관리"]) +app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서"]) app.include_router(note_links.router, prefix="/api", tags=["노트 링크"]) app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"]) app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"]) diff --git a/backend/src/models/note_document.py b/backend/src/models/note_document.py index bdb9227..fea8486 100644 --- a/backend/src/models/note_document.py +++ b/backend/src/models/note_document.py @@ -46,6 +46,7 @@ class NoteDocumentBase(BaseModel): tags: List[str] = Field(default=[]) is_published: bool = Field(default=False) parent_note_id: Optional[str] = None + notebook_id: Optional[str] = None sort_order: int = Field(default=0) class NoteDocumentCreate(NoteDocumentBase): @@ -58,6 +59,7 @@ class NoteDocumentUpdate(BaseModel): tags: Optional[List[str]] = None is_published: Optional[bool] = None parent_note_id: Optional[str] = None + notebook_id: Optional[str] = None sort_order: Optional[int] = None class NoteDocumentResponse(NoteDocumentBase): @@ -87,6 +89,7 @@ class NoteDocumentResponse(NoteDocumentBase): 'tags': obj.tags or [], 'is_published': obj.is_published, 'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None, + 'notebook_id': str(obj.notebook_id) if obj.notebook_id else None, 'sort_order': obj.sort_order, 'markdown_content': obj.markdown_content, 'created_at': obj.created_at, diff --git a/backend/src/models/note_link.py b/backend/src/models/note_link.py index ea2c900..7a3a66b 100644 --- a/backend/src/models/note_link.py +++ b/backend/src/models/note_link.py @@ -55,3 +55,4 @@ class NoteLink(Base): def __repr__(self): return f"" + diff --git a/backend/src/models/user.py b/backend/src/models/user.py index b87b08e..e4a16b5 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -1,7 +1,7 @@ """ 사용자 모델 """ -from sqlalchemy import Column, String, Boolean, DateTime, Text +from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -21,6 +21,17 @@ class User(Base): is_active = Column(Boolean, default=True) is_admin = Column(Boolean, default=False) + # 권한 시스템 (서적관리, 노트관리, 소설관리) + can_manage_books = Column(Boolean, default=True) # 서적 관리 권한 + can_manage_notes = Column(Boolean, default=True) # 노트 관리 권한 + can_manage_novels = Column(Boolean, default=True) # 소설 관리 권한 + + # 사용자 역할 (root, admin, user) + role = Column(String(20), default="user") # root, admin, user + + # 세션 타임아웃 설정 (분 단위, 0 = 무제한) + session_timeout_minutes = Column(Integer, default=5) # 기본 5분 + # 메타데이터 created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/src/schemas/auth.py b/backend/src/schemas/auth.py index 88fceb4..5169111 100644 --- a/backend/src/schemas/auth.py +++ b/backend/src/schemas/auth.py @@ -33,10 +33,16 @@ class UserInfo(BaseModel): full_name: Optional[str] = None is_active: bool is_admin: bool + role: str + can_manage_books: bool + can_manage_notes: bool + can_manage_novels: bool + session_timeout_minutes: int theme: str language: str timezone: str created_at: datetime + updated_at: Optional[datetime] = None last_login: Optional[datetime] = None class Config: diff --git a/config/postgresql.synology.conf b/config/postgresql.synology.conf index 031de56..37feba9 100644 --- a/config/postgresql.synology.conf +++ b/config/postgresql.synology.conf @@ -89,3 +89,4 @@ jit_optimize_above_cost = 500000 # 최적화 JIT 비용 임계값 # 확장 모듈 설정 shared_preload_libraries = 'pg_stat_statements' # 쿼리 통계 모듈 + diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml index 8893937..bf3ab20 100644 --- a/docker-compose.synology.yml +++ b/docker-compose.synology.yml @@ -152,3 +152,4 @@ volumes: type: none o: bind device: /volume2/document-storage + diff --git a/frontend/account-settings.html b/frontend/account-settings.html new file mode 100644 index 0000000..c0f5d60 --- /dev/null +++ b/frontend/account-settings.html @@ -0,0 +1,363 @@ + + + + + + 계정 설정 - Document Server + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+
+ +

계정 설정

+
+

계정 정보와 환경 설정을 관리하세요

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

프로필 정보

+
+ +
+
+
+ + +

이메일은 변경할 수 없습니다

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

비밀번호 변경

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

환경 설정

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

계정 정보

+
+ +
+
+ 계정 ID: + +
+
+ 역할: + +
+
+ 계정 생성일: + +
+
+ 마지막 로그인: + +
+
+ 세션 타임아웃: + +
+
+ 계정 상태: + +
+
+
+
+
+
+ + + + + + + + diff --git a/frontend/components/header.html b/frontend/components/header.html index 4881080..7ce4d3b 100644 --- a/frontend/components/header.html +++ b/frontend/components/header.html @@ -1,5 +1,5 @@ -
+
@@ -8,107 +8,230 @@

Document Server

- -