Добавление catpcha при регистрации, фильтрация по количеству страниц

This commit is contained in:
2026-01-23 23:32:09 +03:00
parent 7c3074e8fe
commit c1ac0ca246
19 changed files with 1258 additions and 568 deletions
+2
View File
@@ -8,6 +8,7 @@ from .books import router as books_router
from .genres import router as genres_router
from .loans import router as loans_router
from .relationships import router as relationships_router
from .cap import router as cap_router
from .users import router as users_router
from .misc import router as misc_router
@@ -22,5 +23,6 @@ api_router.include_router(authors_router, prefix="/api")
api_router.include_router(books_router, prefix="/api")
api_router.include_router(genres_router, prefix="/api")
api_router.include_router(loans_router, prefix="/api")
api_router.include_router(cap_router, prefix="/api")
api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api")
+6 -1
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from library_service.services import require_captcha
from library_service.models.db import Role, User
from library_service.models.dto import (
Token,
@@ -63,7 +64,11 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
summary="Регистрация нового пользователя",
description="Создает нового пользователя и возвращает резервные коды",
)
def register(user_data: UserCreate, session: Session = Depends(get_session)):
def register(
user_data: UserCreate,
_=Depends(require_captcha),
session: Session = Depends(get_session),
):
"""Регистрирует нового пользователя в системе"""
existing_user = session.exec(
select(User).where(User.username == user_data.username)
+19 -6
View File
@@ -1,12 +1,19 @@
"""Модуль работы с авторами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import (BookRead, AuthorWithBooks,
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
from library_service.models.dto import (
BookRead,
AuthorWithBooks,
AuthorCreate,
AuthorList,
AuthorRead,
AuthorUpdate,
)
router = APIRouter(prefix="/authors", tags=["authors"])
@@ -59,7 +66,9 @@ def get_author(
"""Возвращает информацию об авторе и его книгах"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
books = session.exec(
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
@@ -88,7 +97,9 @@ def update_author(
"""Обновляет информацию об авторе"""
db_author = session.get(Author, author_id)
if not db_author:
raise HTTPException(status_code=404, detail="Author not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
update_data = author.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -113,7 +124,9 @@ def delete_author(
"""Удаляет автора из системы"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
author_read = AuthorRead(**author.model_dump())
session.delete(author)
+26 -8
View File
@@ -3,7 +3,7 @@
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff
@@ -56,10 +56,16 @@ def close_active_loan(session: Session, book_id: int) -> None:
def filter_books(
session: Session = Depends(get_session),
q: str | None = Query(None, max_length=50, description="Поиск"),
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
min_page_count: int | None = Query(
None, ge=0, description="Минимальное количество страниц"
),
max_page_count: int | None = Query(
None, ge=0, description="Максимальное количество страниц"
),
author_ids: List[int] | None = Query(None, gt=0, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, gt=0, description="Список ID жанров"),
page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
size: int = Query(20, gt=0, le=100, description="Количество элементов на странице"),
):
"""Возвращает отфильтрованный список книг с пагинацией"""
statement = select(Book).distinct()
@@ -69,6 +75,12 @@ def filter_books(
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
)
if min_page_count:
statement = statement.where(Book.page_count >= min_page_count)
if max_page_count:
statement = statement.where(Book.page_count <= max_page_count)
if author_ids:
statement = statement.join(AuthorBookLink).where(
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
@@ -149,7 +161,9 @@ def get_book(
"""Возвращает информацию о книге с авторами и жанрами"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
authors = session.exec(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
@@ -185,12 +199,14 @@ def update_book(
"""Обновляет информацию о книге"""
db_book = session.get(Book, book_id)
if not db_book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book_update.status is not None:
if book_update.status == BookStatus.BORROWED:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Статус 'borrowed' устанавливается только через выдачу книги",
)
@@ -226,7 +242,9 @@ def delete_book(
"""Удаляет книгу из системы"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
book_read = BookRead(
id=(book.id or 0),
title=book.title,
+101
View File
@@ -0,0 +1,101 @@
import asyncio
import hashlib
import secrets
from fastapi import APIRouter, Request, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from library_service.services.captcha import (
limiter,
get_ip,
active_challenges,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
now_ms,
redeem_tokens,
)
router = APIRouter(prefix="/cap", tags=["captcha"])
@router.post("/challenge")
@limiter.limit("15/minute")
async def challenge(request: Request, ip: str = Depends(get_ip)):
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
)
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
)
token = secrets.token_hex(25)
redeem = secrets.token_hex(25)
expires = now_ms() + CHALLENGE_TTL
active_challenges[token] = {
"c": 50,
"s": 32,
"d": 4,
"expires": expires,
"redeem_token": redeem,
"ip": ip,
}
challenges_by_ip[ip] += 1
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
@router.post("/redeem")
@limiter.limit("30/minute")
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
token = payload.get("token")
solutions = payload.get("solutions", [])
if token not in active_challenges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
)
ch = active_challenges.pop(token)
challenges_by_ip[ch["ip"]] -= 1
if now_ms() > ch["expires"]:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
if len(solutions) < ch["c"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
)
def verify(i: int) -> bool:
salt = prng(f"{token}{i+1}", ch["s"])
target = prng(f"{token}{i+1}d", ch["d"])
h = hashlib.sha256((salt + str(solutions[i])).encode()).hexdigest()
return h.startswith(target)
results = await asyncio.gather(
*(asyncio.to_thread(verify, i) for i in range(ch["c"]))
)
if not all(results):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
)
r_token = ch["redeem_token"]
redeem_tokens[r_token] = now_ms() + REDEEM_TTL
resp = JSONResponse(
{"success": True, "token": r_token, "expires": redeem_tokens[r_token]}
)
resp.set_cookie(
key="capjs_token",
value=r_token,
httponly=True,
samesite="lax",
max_age=REDEEM_TTL // 1000,
)
return resp
+19 -5
View File
@@ -1,10 +1,18 @@
"""Модуль работы с жанрами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
from library_service.models.db import Book, Genre, GenreBookLink
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
from library_service.models.dto import (
BookRead,
GenreCreate,
GenreList,
GenreRead,
GenreUpdate,
GenreWithBooks,
)
from library_service.settings import get_session
@@ -57,7 +65,9 @@ def get_genre(
"""Возвращает информацию о жанре и книгах с ним"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
books = session.exec(
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
@@ -86,7 +96,9 @@ def update_genre(
"""Обновляет информацию о жанре"""
db_genre = session.get(Genre, genre_id)
if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
update_data = genre.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -111,7 +123,9 @@ def delete_genre(
"""Удаляет жанр из системы"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
genre_read = GenreRead(**genre.model_dump())
session.delete(genre)
+51 -19
View File
@@ -40,17 +40,21 @@ def create_loan(
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available for loan (status: {book.status})",
)
target_user = session.get(User, loan.user_id)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink(
book_id=loan.book_id,
@@ -248,7 +252,9 @@ def get_loan(
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user)
@@ -275,7 +281,9 @@ def update_loan(
"""Обновляет информацию о выдаче"""
db_loan = session.get(BookUserLink, loan_id)
if not db_loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user)
@@ -287,7 +295,9 @@ def update_loan(
book = session.get(Book, db_loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if loan_update.user_id is not None:
if not is_staff:
@@ -297,7 +307,9 @@ def update_loan(
)
new_user = session.get(User, loan_update.user_id)
if not new_user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan.user_id = loan_update.user_id
if loan_update.due_date is not None:
@@ -305,7 +317,10 @@ def update_loan(
if loan_update.returned_at is not None:
if db_loan.returned_at is not None:
raise HTTPException(status_code=400, detail="Loan is already returned")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Loan is already returned",
)
db_loan.returned_at = loan_update.returned_at
book.status = BookStatus.ACTIVE
@@ -331,18 +346,24 @@ def confirm_loan(
"""Подтверждает бронирование и меняет статус книги на BORROWED"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot confirm loan for book with status: {book.status}",
)
@@ -370,10 +391,14 @@ def return_loan(
"""Возвращает книгу и закрывает выдачу"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
loan.returned_at = datetime.now(timezone.utc)
@@ -403,7 +428,9 @@ def delete_loan(
"""Удаляет выдачу или бронирование (только для RESERVED статуса)"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user)
@@ -417,7 +444,7 @@ def delete_loan(
if book and book.status != BookStatus.RESERVED:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only delete reservations. Use update endpoint to return borrowed books",
)
@@ -471,16 +498,21 @@ def issue_book_directly(
"""Выдает книгу напрямую без бронирования (только для администраторов)"""
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400, detail=f"Book is not available (status: {book.status})"
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available (status: {book.status})",
)
target_user = session.get(User, loan.user_id)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink(
book_id=loan.book_id,
+89 -35
View File
@@ -1,7 +1,8 @@
"""Модуль работы со связями"""
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
@@ -17,7 +18,9 @@ def check_entity_exists(session, model, entity_id, entity_name):
"""Проверяет существование сущности в базе данных"""
entity = session.get(model, entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"{entity_name} not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"{entity_name} not found"
)
return entity
@@ -30,7 +33,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
).first()
if existing_link:
raise HTTPException(status_code=400, detail=detail)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
link = link_model(**{field1: id1, field2: id2})
session.add(link)
@@ -48,7 +51,9 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
).first()
if not link:
raise HTTPException(status_code=404, detail="Relationship not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Relationship not found"
)
session.delete(link)
session.commit()
@@ -56,21 +61,22 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
def get_related(
session,
main_model,
main_id,
main_name,
related_model,
link_model,
link_main_field,
link_related_field,
read_model
):
session,
main_model,
main_id,
main_name,
related_model,
link_model,
link_main_field,
link_related_field,
read_model,
):
"""Возвращает список связанных сущностей"""
check_entity_exists(session, main_model, main_id, main_name)
related = session.exec(
select(related_model).join(link_model)
select(related_model)
.join(link_model)
.where(getattr(link_model, link_main_field) == main_id)
).all()
@@ -93,8 +99,15 @@ def add_author_to_book(
check_entity_exists(session, Author, author_id, "Author")
check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id", "Relationship already exists")
return add_relationship(
session,
AuthorBookLink,
author_id,
"author_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete(
@@ -110,8 +123,9 @@ def remove_author_from_book(
session: Session = Depends(get_session),
):
"""Удаляет связь между автором и книгой"""
return remove_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id")
return remove_relationship(
session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
)
@router.get(
@@ -122,9 +136,17 @@ def remove_author_from_book(
)
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг автора"""
return get_related(session,
Author, author_id, "Author", Book,
AuthorBookLink, "author_id", "book_id", BookRead)
return get_related(
session,
Author,
author_id,
"Author",
Book,
AuthorBookLink,
"author_id",
"book_id",
BookRead,
)
@router.get(
@@ -135,9 +157,17 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
)
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
"""Возвращает список авторов книги"""
return get_related(session,
Book, book_id, "Book", Author,
AuthorBookLink, "book_id", "author_id", AuthorRead)
return get_related(
session,
Book,
book_id,
"Book",
Author,
AuthorBookLink,
"book_id",
"author_id",
AuthorRead,
)
@router.post(
@@ -156,8 +186,15 @@ def add_genre_to_book(
check_entity_exists(session, Genre, genre_id, "Genre")
check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
return add_relationship(
session,
GenreBookLink,
genre_id,
"genre_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete(
@@ -173,8 +210,9 @@ def remove_genre_from_book(
session: Session = Depends(get_session),
):
"""Удаляет связь между жанром и книгой"""
return remove_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id")
return remove_relationship(
session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
)
@router.get(
@@ -185,9 +223,17 @@ def remove_genre_from_book(
)
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг в жанре"""
return get_related(session,
Genre, genre_id, "Genre", Book,
GenreBookLink, "genre_id", "book_id", BookRead)
return get_related(
session,
Genre,
genre_id,
"Genre",
Book,
GenreBookLink,
"genre_id",
"book_id",
BookRead,
)
@router.get(
@@ -198,6 +244,14 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
)
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
"""Возвращает список жанров книги"""
return get_related(session,
Book, book_id, "Book", Genre,
GenreBookLink, "book_id", "genre_id", GenreRead)
return get_related(
session,
Book,
book_id,
"Book",
Genre,
GenreBookLink,
"book_id",
"genre_id",
GenreRead,
)