From c1ac0ca246d87ef62c4b20d2fd2a7ebe40d99390 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Fri, 23 Jan 2026 23:32:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20catpcha=20=D0=BF=D1=80=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8,?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D1=83=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- library_service/main.py | 13 +- library_service/routers/__init__.py | 2 + library_service/routers/auth.py | 7 +- library_service/routers/authors.py | 25 +- library_service/routers/books.py | 34 +- library_service/routers/cap.py | 101 +++++ library_service/routers/genres.py | 24 +- library_service/routers/loans.py | 70 ++- library_service/routers/relationships.py | 124 +++-- library_service/services/__init__.py | 29 ++ library_service/services/captcha.py | 75 ++++ library_service/settings.py | 1 + library_service/static/page/auth.js | 547 ++++++++++++----------- library_service/static/page/books.js | 527 +++++++++++++--------- library_service/templates/auth.html | 22 + library_service/templates/books.html | 106 +++++ main.py | 6 - pyproject.toml | 2 + uv.lock | 111 +++++ 19 files changed, 1258 insertions(+), 568 deletions(-) create mode 100644 library_service/routers/cap.py create mode 100644 library_service/services/__init__.py create mode 100644 library_service/services/captcha.py delete mode 100644 main.py diff --git a/library_service/main.py b/library_service/main.py index a23b76a..582688a 100644 --- a/library_service/main.py +++ b/library_service/main.py @@ -1,4 +1,6 @@ """Основной модуль""" + +import asyncio from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path @@ -7,12 +9,13 @@ from uuid import uuid4 from alembic import command from alembic.config import Config -from fastapi import Request, Response +from fastapi import FastAPI, Depends, Request, Response, status from fastapi.staticfiles import StaticFiles from sqlmodel import Session from library_service.auth import run_seeds from library_service.routers import api_router +from library_service.services.captcha import limiter, cleanup_task, require_captcha from library_service.settings import ( LOGGING_CONFIG, engine, @@ -20,6 +23,7 @@ from library_service.settings import ( get_logger, ) + SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"}) @@ -47,6 +51,7 @@ async def lifespan(_): except Exception as e: logger.error(f"[-] Seeding failed: {e}") + asyncio.create_task(cleanup_task()) logger.info("[+] Starting application...") yield # Обработка запросов logger.info("[+] Application shutdown") @@ -113,7 +118,10 @@ async def log_requests(request: Request, call_next): }, exc_info=True, ) - return Response(status_code=500, content="Internal Server Error") + return Response( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content="Internal Server Error", + ) # Подключение маршрутов @@ -127,6 +135,7 @@ app.mount( if __name__ == "__main__": import uvicorn + uvicorn.run( "library_service.main:app", host="0.0.0.0", diff --git a/library_service/routers/__init__.py b/library_service/routers/__init__.py index 706eb46..5756284 100644 --- a/library_service/routers/__init__.py +++ b/library_service/routers/__init__.py @@ -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") diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py index 25ac895..ce27915 100644 --- a/library_service/routers/auth.py +++ b/library_service/routers/auth.py @@ -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) diff --git a/library_service/routers/authors.py b/library_service/routers/authors.py index 9b61c4b..f3b1670 100644 --- a/library_service/routers/authors.py +++ b/library_service/routers/authors.py @@ -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) diff --git a/library_service/routers/books.py b/library_service/routers/books.py index 6002423..d0c8e83 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -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, diff --git a/library_service/routers/cap.py b/library_service/routers/cap.py new file mode 100644 index 0000000..6bedc03 --- /dev/null +++ b/library_service/routers/cap.py @@ -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 diff --git a/library_service/routers/genres.py b/library_service/routers/genres.py index d933dff..be0a134 100644 --- a/library_service/routers/genres.py +++ b/library_service/routers/genres.py @@ -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) diff --git a/library_service/routers/loans.py b/library_service/routers/loans.py index 9abd970..48e3df5 100644 --- a/library_service/routers/loans.py +++ b/library_service/routers/loans.py @@ -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, diff --git a/library_service/routers/relationships.py b/library_service/routers/relationships.py index 6fe3000..4fda0fa 100644 --- a/library_service/routers/relationships.py +++ b/library_service/routers/relationships.py @@ -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, + ) diff --git a/library_service/services/__init__.py b/library_service/services/__init__.py new file mode 100644 index 0000000..4f5fe7b --- /dev/null +++ b/library_service/services/__init__.py @@ -0,0 +1,29 @@ +from .captcha import ( + limiter, + cleanup_task, + get_ip, + require_captcha, + active_challenges, + redeem_tokens, + challenges_by_ip, + MAX_CHALLENGES_PER_IP, + MAX_TOTAL_CHALLENGES, + CHALLENGE_TTL, + REDEEM_TTL, + prng, +) + +__all__ = [ + "limiter", + "cleanup_task", + "get_ip", + "require_captcha", + "active_challenges", + "redeem_tokens", + "challenges_by_ip", + "MAX_CHALLENGES_PER_IP", + "MAX_TOTAL_CHALLENGES", + "CHALLENGE_TTL", + "REDEEM_TTL", + "prng", +] diff --git a/library_service/services/captcha.py b/library_service/services/captcha.py new file mode 100644 index 0000000..b9b7b72 --- /dev/null +++ b/library_service/services/captcha.py @@ -0,0 +1,75 @@ +import os +import asyncio +import hashlib +import secrets +import time +from collections import defaultdict + +from fastapi import Request, HTTPException, Depends, status +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.util import get_remote_address + +CLEANUP_INTERVAL = int(os.getenv("CAP_CLEANUP_INTERVAL", "10")) +REDEEM_TTL = int(os.getenv("CAP_REDEEM_TTL_SECONDS", "180")) * 1000 +CHALLENGE_TTL = int(os.getenv("CAP_CHALLENGE_TTL_SECONDS", "120")) * 1000 +MAX_CHALLENGES_PER_IP = int(os.getenv("CAP_MAX_CHALLENGES_PER_IP", "12")) +MAX_TOTAL_CHALLENGES = int(os.getenv("CAP_MAX_TOTAL_CHALLENGES", "1000")) + +active_challenges: dict[str, dict] = {} +redeem_tokens: dict[str, int] = {} +challenges_by_ip: defaultdict[str, int] = defaultdict(int) +limiter = Limiter(key_func=get_remote_address) + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def fnv1a_utf16(seed: str) -> int: + h = 2166136261 + data = seed.encode("utf-16le") + i = 0 + while i < len(data): + unit = data[i] + (data[i + 1] << 8) + h ^= unit + h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF + i += 2 + return h + + +def prng(seed: str, length: int) -> str: + state = fnv1a_utf16(seed) + out = "" + while len(out) < length: + state ^= (state << 13) & 0xFFFFFFFF + state ^= state >> 17 + state ^= (state << 5) & 0xFFFFFFFF + out += f"{state & 0xFFFFFFFF:08x}" + return out[:length] + + +async def cleanup_task(): + while True: + now = now_ms() + for token, data in list(active_challenges.items()): + if data["expires"] < now: + challenges_by_ip[data["ip"]] -= 1 + del active_challenges[token] + for token, exp in list(redeem_tokens.items()): + if exp < now: + del redeem_tokens[token] + await asyncio.sleep(CLEANUP_INTERVAL) + + +def get_ip(request: Request) -> str: + return get_remote_address(request) + + +async def require_captcha(request: Request): + token = request.cookies.get("capjs_token") + if not token or token not in redeem_tokens or redeem_tokens[token] < now_ms(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail={"error": "captcha_required"} + ) + del redeem_tokens[token] diff --git a/library_service/settings.py b/library_service/settings.py index 8231e80..9daf680 100644 --- a/library_service/settings.py +++ b/library_service/settings.py @@ -61,6 +61,7 @@ OPENAPI_TAGS = [ {"name": "loans", "description": "Действия с выдачами."}, {"name": "relations", "description": "Действия со связями."}, {"name": "users", "description": "Действия с пользователями."}, + {"name": "captcha", "description": "Создание и проверка cap.js каптчи."}, {"name": "misc", "description": "Прочие."}, ] diff --git a/library_service/static/page/auth.js b/library_service/static/page/auth.js index d815b36..92ca099 100644 --- a/library_service/static/page/auth.js +++ b/library_service/static/page/auth.js @@ -1,6 +1,70 @@ $(() => { - const PARTIAL_TOKEN_KEY = "partial_token"; - const PARTIAL_USERNAME_KEY = "partial_username"; + const SELECTORS = { + loginForm: "#login-form", + registerForm: "#register-form", + resetForm: "#reset-password-form", + loginTab: "#login-tab", + registerTab: "#register-tab", + forgotBtn: "#forgot-password-btn", + backToLoginBtn: "#back-to-login-btn", + backToCredentialsBtn: "#back-to-credentials-btn", + submitLogin: "#login-submit", + submitRegister: "#register-submit", + submitReset: "#reset-submit", + usernameLogin: "#login-username", + passwordLogin: "#login-password", + totpInput: "#login-totp", + rememberMe: "#remember-me", + credentialsSection: "#credentials-section", + totpSection: "#totp-section", + registerUsername: "#register-username", + registerEmail: "#register-email", + registerFullname: "#register-fullname", + registerPassword: "#register-password", + registerConfirm: "#register-password-confirm", + passwordStrengthBar: "#password-strength-bar", + passwordStrengthText: "#password-strength-text", + passwordMatchError: "#password-match-error", + resetUsername: "#reset-username", + resetCode: "#reset-recovery-code", + resetNewPassword: "#reset-new-password", + resetConfirmPassword: "#reset-confirm-password", + resetMatchError: "#reset-password-match-error", + recoveryModal: "#recovery-codes-modal", + recoveryList: "#recovery-codes-list", + codesSavedCheckbox: "#codes-saved-checkbox", + closeRecoveryBtn: "#close-recovery-modal-btn", + copyCodesBtn: "#copy-codes-btn", + downloadCodesBtn: "#download-codes-btn", + gotoLoginAfterReset: "#goto-login-after-reset", + capWidget: "#cap", + lockProgressCircle: "#lock-progress-circle", + }; + + const STORAGE_KEYS = { + partialToken: "partial_token", + partialUsername: "partial_username", + }; + + const TEXTS = { + login: "Войти", + confirm: "Подтвердить", + checking: "Проверка...", + registering: "Регистрация...", + resetting: "Сброс...", + enterTotp: "Введите код из приложения аутентификатора", + sessionExpired: "Время сессии истекло. Пожалуйста, войдите заново.", + invalidCode: "Неверный код", + passwordsNotMatch: "Пароли не совпадают", + captchaRequired: "Пожалуйста, пройдите проверку Captcha", + registrationSuccess: "Регистрация успешна! Войдите в систему.", + codesCopied: "Коды скопированы в буфер обмена", + codesDownloaded: "Файл с кодами скачан", + passwordResetSuccess: "Пароль успешно изменён!", + invalidRecoveryCode: "Неверный формат резервного кода", + passwordTooShort: "Пароль должен содержать минимум 8 символов", + }; + const TOTP_PERIOD = 30; const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38; @@ -14,96 +78,71 @@ $(() => { let registeredRecoveryCodes = []; let totpAnimationFrame = null; - function getTotpProgress() { + const getTotpProgress = () => { const now = Date.now() / 1000; const elapsed = now % TOTP_PERIOD; return elapsed / TOTP_PERIOD; - } + }; - function updateTotpTimer() { - const circle = document.getElementById("lock-progress-circle"); + const updateTotpTimer = () => { + const circle = $(SELECTORS.lockProgressCircle).get(0); if (!circle) return; - const progress = getTotpProgress(); const offset = CIRCLE_CIRCUMFERENCE * (1 - progress); circle.style.strokeDashoffset = offset; - totpAnimationFrame = requestAnimationFrame(updateTotpTimer); - } + }; - function startTotpTimer() { + const startTotpTimer = () => { stopTotpTimer(); updateTotpTimer(); - } + }; - function stopTotpTimer() { + const stopTotpTimer = () => { if (totpAnimationFrame) { cancelAnimationFrame(totpAnimationFrame); totpAnimationFrame = null; } - } + }; - function resetCircle() { - const circle = document.getElementById("lock-progress-circle"); - if (circle) { - circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE; - } - } + const resetCircle = () => { + const circle = $(SELECTORS.lockProgressCircle).get(0); + if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE; + }; - function initLoginState() { - const savedToken = sessionStorage.getItem(PARTIAL_TOKEN_KEY); - const savedUsername = sessionStorage.getItem(PARTIAL_USERNAME_KEY); + const savePartialToken = (token, username) => { + sessionStorage.setItem(STORAGE_KEYS.partialToken, token); + sessionStorage.setItem(STORAGE_KEYS.partialUsername, username); + }; - if (savedToken && savedUsername) { - loginState.partialToken = savedToken; - loginState.username = savedUsername; - loginState.step = "2fa"; + const clearPartialToken = () => { + sessionStorage.removeItem(STORAGE_KEYS.partialToken); + sessionStorage.removeItem(STORAGE_KEYS.partialUsername); + }; - $("#login-username").val(savedUsername); - $("#credentials-section").addClass("hidden"); - $("#totp-section").removeClass("hidden"); - $("#login-submit").text("Подтвердить"); - - startTotpTimer(); - - setTimeout(() => { - const totpInput = document.getElementById("login-totp"); - if (totpInput) totpInput.focus(); - }, 100); - } - } - - function savePartialToken(token, username) { - sessionStorage.setItem(PARTIAL_TOKEN_KEY, token); - sessionStorage.setItem(PARTIAL_USERNAME_KEY, username); - } - - function clearPartialToken() { - sessionStorage.removeItem(PARTIAL_TOKEN_KEY); - sessionStorage.removeItem(PARTIAL_USERNAME_KEY); - } - - function showForm(formId) { - $("#login-form, #register-form, #reset-password-form").addClass("hidden"); + const showForm = (formId) => { + $( + `${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`, + ).addClass("hidden"); $(formId).removeClass("hidden"); - $("#login-tab, #register-tab") + $(`${SELECTORS.loginTab}, ${SELECTORS.registerTab}`) .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") .addClass("text-gray-400 hover:text-gray-600"); - if (formId === "#login-form") { - $("#login-tab") + if (formId === SELECTORS.loginForm) { + $(SELECTORS.loginTab) .removeClass("text-gray-400 hover:text-gray-600") .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); resetLoginState(); - } else if (formId === "#register-form") { - $("#register-tab") + } else if (formId === SELECTORS.registerForm) { + $(SELECTORS.registerTab) .removeClass("text-gray-400 hover:text-gray-600") .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); } - } + }; - function resetLoginState() { + const resetLoginState = () => { clearPartialToken(); stopTotpTimer(); loginState = { @@ -112,30 +151,68 @@ $(() => { username: "", rememberMe: false, }; - $("#totp-section").addClass("hidden"); - $("#login-totp").val(""); - $("#credentials-section").removeClass("hidden"); - $("#login-submit").text("Войти"); + $(SELECTORS.totpSection).addClass("hidden"); + $(SELECTORS.totpInput).val(""); + $(SELECTORS.credentialsSection).removeClass("hidden"); + $(SELECTORS.submitLogin).text(TEXTS.login); resetCircle(); - } + }; - $("#login-tab").on("click", () => showForm("#login-form")); - $("#register-tab").on("click", () => showForm("#register-form")); - $("#forgot-password-btn").on("click", () => showForm("#reset-password-form")); - $("#back-to-login-btn").on("click", () => showForm("#login-form")); + const checkPasswordMatch = (passwordId, confirmId, errorId) => { + const password = $(passwordId).val(); + const confirm = $(confirmId).val(); + const $error = $(errorId); + if (confirm && password !== confirm) { + $error.removeClass("hidden"); + return false; + } + $error.addClass("hidden"); + return true; + }; + + const saveTokensAndRedirect = (data, rememberMe) => { + const storage = rememberMe ? localStorage : sessionStorage; + const otherStorage = rememberMe ? sessionStorage : localStorage; + storage.setItem("access_token", data.access_token); + if (data.refresh_token) + storage.setItem("refresh_token", data.refresh_token); + otherStorage.removeItem("access_token"); + otherStorage.removeItem("refresh_token"); + window.location.href = "/"; + }; + + const initLoginState = () => { + const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken); + const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername); + if (savedToken && savedUsername) { + loginState.partialToken = savedToken; + loginState.username = savedUsername; + loginState.step = "2fa"; + $(SELECTORS.usernameLogin).val(savedUsername); + $(SELECTORS.credentialsSection).addClass("hidden"); + $(SELECTORS.totpSection).removeClass("hidden"); + $(SELECTORS.submitLogin).text(TEXTS.confirm); + startTotpTimer(); + setTimeout(() => $(SELECTORS.totpInput).get(0)?.focus(), 100); + } + }; + + $(SELECTORS.loginTab).on("click", () => showForm(SELECTORS.loginForm)); + $(SELECTORS.registerTab).on("click", () => showForm(SELECTORS.registerForm)); + $(SELECTORS.forgotBtn).on("click", () => showForm(SELECTORS.resetForm)); + $(SELECTORS.backToLoginBtn).on("click", () => showForm(SELECTORS.loginForm)); + $(SELECTORS.backToCredentialsBtn).on("click", resetLoginState); $("body").on("click", ".toggle-password", function () { - const $btn = $(this); - const $input = $btn.siblings("input"); + const $input = $(this).siblings("input"); const isPassword = $input.attr("type") === "password"; $input.attr("type", isPassword ? "text" : "password"); - $btn.find("svg").toggleClass("hidden"); + $(this).find("svg").toggleClass("hidden"); }); - $("#register-password").on("input", function () { + $(SELECTORS.registerPassword).on("input", function () { const password = $(this).val(); let strength = 0; - if (password.length >= 8) strength++; if (password.length >= 12) strength++; if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; @@ -150,91 +227,64 @@ $(() => { { width: "80%", color: "bg-lime-500", text: "Хороший" }, { width: "100%", color: "bg-green-500", text: "Отличный" }, ]; - const level = levels[strength]; - $("#password-strength-bar") + $(SELECTORS.passwordStrengthBar) .css("width", level.width) - .attr("class", "h-full transition-all duration-300 " + level.color); - $("#password-strength-text").text(level.text); - - checkPasswordMatch(); + .attr("class", `h-full transition-all duration-300 ${level.color}`); + $(SELECTORS.passwordStrengthText).text(level.text); + checkPasswordMatch( + SELECTORS.registerPassword, + SELECTORS.registerConfirm, + SELECTORS.passwordMatchError, + ); }); - function checkPasswordMatch() { - const password = $("#register-password").val(); - const confirm = $("#register-password-confirm").val(); - if (confirm && password !== confirm) { - $("#password-match-error").removeClass("hidden"); - return false; - } - $("#password-match-error").addClass("hidden"); - return true; - } + $(SELECTORS.registerConfirm).on("input", () => + checkPasswordMatch( + SELECTORS.registerPassword, + SELECTORS.registerConfirm, + SELECTORS.passwordMatchError, + ), + ); - $("#register-password-confirm").on("input", checkPasswordMatch); - - function formatRecoveryCode(input) { - let value = input.value.toUpperCase().replace(/[^0-9A-F]/g, ""); + $(SELECTORS.resetCode).on("input", function () { + let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, ""); let formatted = ""; for (let i = 0; i < value.length && i < 16; i++) { if (i > 0 && i % 4 === 0) formatted += "-"; formatted += value[i]; } - input.value = formatted; - } - - $("#reset-recovery-code").on("input", function () { - formatRecoveryCode(this); + this.value = formatted; }); - $("#login-totp").on("input", function () { + $(SELECTORS.totpInput).on("input", function () { this.value = this.value.replace(/\D/g, "").slice(0, 6); - if (this.value.length === 6) { - $("#login-form").trigger("submit"); - } + if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit"); }); - $("#back-to-credentials-btn").on("click", function () { - resetLoginState(); - }); - - $("#login-form").on("submit", async function (event) { + $(SELECTORS.loginForm).on("submit", async function (event) { event.preventDefault(); - const $submitBtn = $("#login-submit"); - + const $submitBtn = $(SELECTORS.submitLogin); if (loginState.step === "credentials") { - const username = $("#login-username").val(); - const password = $("#login-password").val(); - const rememberMe = $("#remember-me").prop("checked"); - + const username = $(SELECTORS.usernameLogin).val(); + const password = $(SELECTORS.passwordLogin).val(); + const rememberMe = $(SELECTORS.rememberMe).prop("checked"); loginState.username = username; loginState.rememberMe = rememberMe; - $submitBtn.prop("disabled", true).text("Вход..."); - try { - const formData = new URLSearchParams(); - formData.append("username", username); - formData.append("password", password); - + const formData = new URLSearchParams({ username, password }); const data = await Api.postForm("/api/auth/token", formData); - if (data.requires_2fa && data.partial_token) { loginState.partialToken = data.partial_token; loginState.step = "2fa"; - savePartialToken(data.partial_token, username); - - $("#credentials-section").addClass("hidden"); - $("#totp-section").removeClass("hidden"); - + $(SELECTORS.credentialsSection).addClass("hidden"); + $(SELECTORS.totpSection).removeClass("hidden"); startTotpTimer(); - - const totpInput = document.getElementById("login-totp"); - if (totpInput) totpInput.focus(); - - $submitBtn.text("Подтвердить"); - Utils.showToast("Введите код из приложения аутентификатора", "info"); + $(SELECTORS.totpInput).get(0)?.focus(); + $submitBtn.text(TEXTS.confirm); + Utils.showToast(TEXTS.enterTotp, "info"); } else if (data.access_token) { clearPartialToken(); saveTokensAndRedirect(data, rememberMe); @@ -243,20 +293,15 @@ $(() => { Utils.showToast(error.message || "Ошибка входа", "error"); } finally { $submitBtn.prop("disabled", false); - if (loginState.step === "credentials") { - $submitBtn.text("Войти"); - } + if (loginState.step === "credentials") $submitBtn.text(TEXTS.login); } } else if (loginState.step === "2fa") { - const totpCode = $("#login-totp").val(); - + const totpCode = $(SELECTORS.totpInput).val(); if (!totpCode || totpCode.length !== 6) { Utils.showToast("Введите 6-значный код", "error"); return; } - - $submitBtn.prop("disabled", true).text("Проверка..."); - + $submitBtn.prop("disabled", true).text(TEXTS.checking); try { const response = await fetch("/api/auth/2fa/verify", { method: "POST", @@ -266,113 +311,93 @@ $(() => { }, body: JSON.stringify({ code: totpCode }), }); - if (!response.ok) { const errorData = await response.json().catch(() => ({})); - if (response.status === 401) { resetLoginState(); - throw new Error( - "Время сессии истекло. Пожалуйста, войдите заново.", - ); + throw new Error(TEXTS.sessionExpired); } - - throw new Error(errorData.detail || "Неверный код"); + throw new Error(errorData.detail || TEXTS.invalidCode); } - const data = await response.json(); clearPartialToken(); stopTotpTimer(); saveTokensAndRedirect(data, loginState.rememberMe); } catch (error) { - Utils.showToast(error.message || "Неверный код", "error"); - $("#login-totp").val(""); - const totpInput = document.getElementById("login-totp"); - if (totpInput) totpInput.focus(); + Utils.showToast(error.message || TEXTS.invalidCode, "error"); + $(SELECTORS.totpInput).val(""); + $(SELECTORS.totpInput).get(0)?.focus(); } finally { - $submitBtn.prop("disabled", false).text("Подтвердить"); + $submitBtn.prop("disabled", false).text(TEXTS.confirm); } } }); - function saveTokensAndRedirect(data, rememberMe) { - const storage = rememberMe ? localStorage : sessionStorage; - const otherStorage = rememberMe ? sessionStorage : localStorage; - - storage.setItem("access_token", data.access_token); - if (data.refresh_token) { - storage.setItem("refresh_token", data.refresh_token); - } - - otherStorage.removeItem("access_token"); - otherStorage.removeItem("refresh_token"); - - window.location.href = "/"; - } - - $("#register-form").on("submit", async function (event) { + $(SELECTORS.registerForm).on("submit", async function (event) { event.preventDefault(); - const $submitBtn = $("#register-submit"); - const pass = $("#register-password").val(); - const confirm = $("#register-password-confirm").val(); - + const $submitBtn = $(SELECTORS.submitRegister); + const pass = $(SELECTORS.registerPassword).val(); + const confirm = $(SELECTORS.registerConfirm).val(); if (pass !== confirm) { - Utils.showToast("Пароли не совпадают", "error"); + Utils.showToast(TEXTS.passwordsNotMatch, "error"); return; } - const userData = { - username: $("#register-username").val(), - email: $("#register-email").val(), - full_name: $("#register-fullname").val() || null, + username: $(SELECTORS.registerUsername).val(), + email: $(SELECTORS.registerEmail).val(), + full_name: $(SELECTORS.registerFullname).val() || null, password: pass, }; - - $submitBtn.prop("disabled", true).text("Регистрация..."); - + $submitBtn.prop("disabled", true).text(TEXTS.registering); try { const response = await Api.post("/api/auth/register", userData); - if (response.recovery_codes && response.recovery_codes.codes) { registeredRecoveryCodes = response.recovery_codes.codes; showRecoveryCodesModal(registeredRecoveryCodes, userData.username); } else { - Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); + Utils.showToast(TEXTS.registrationSuccess, "success"); setTimeout(() => { - showForm("#login-form"); - $("#login-username").val(userData.username); + showForm(SELECTORS.loginForm); + $(SELECTORS.usernameLogin).val(userData.username); }, 1500); } } catch (error) { + if (error.detail && error.detail.error === "captcha_required") { + Utils.showToast(TEXTS.captchaRequired, "error"); + const $capElement = $(SELECTORS.capWidget); + const $parent = $capElement.parent(); + $capElement.remove(); + $parent.append( + ``, + ); + return; + } let msg = error.message; if (error.detail && Array.isArray(error.detail)) { msg = error.detail.map((e) => e.msg).join(". "); } Utils.showToast(msg || "Ошибка регистрации", "error"); } finally { - $submitBtn.prop("disabled", false).text("Зарегистрироваться"); + $submitBtn + .prop("disabled", false) + .text(TEXTS.registering.replace("...", "")); } }); - function showRecoveryCodesModal(codes, username) { - const $list = $("#recovery-codes-list"); + const showRecoveryCodesModal = (codes, username) => { + const $list = $(SELECTORS.recoveryList); $list.empty(); - codes.forEach((code, index) => { - $list.append(` -
- ${index + 1}. ${Utils.escapeHtml(code)} -
- `); + $list.append( + `
${index + 1}. ${Utils.escapeHtml(code)}
`, + ); }); + $(SELECTORS.codesSavedCheckbox).prop("checked", false); + $(SELECTORS.closeRecoveryBtn).prop("disabled", true); + $(SELECTORS.recoveryModal).data("username", username).removeClass("hidden"); + }; - $("#codes-saved-checkbox").prop("checked", false); - $("#close-recovery-modal-btn").prop("disabled", true); - $("#recovery-codes-modal").data("username", username); - $("#recovery-codes-modal").removeClass("hidden"); - } - - function renderRecoveryCodesStatus(usedCodes) { + const renderRecoveryCodesStatus = (usedCodes) => { return usedCodes .map((used, index) => { const codeDisplay = "████-████-████-████"; @@ -380,31 +405,25 @@ $(() => { ? "text-gray-300 line-through" : "text-green-600"; const statusIcon = used ? "✗" : "✓"; - return ` -
- ${index + 1}. ${codeDisplay} - ${statusIcon} -
- `; + return `
${index + 1}. ${codeDisplay}${statusIcon}
`; }) .join(""); - } + }; - $("#codes-saved-checkbox").on("change", function () { - $("#close-recovery-modal-btn").prop("disabled", !this.checked); + $(SELECTORS.codesSavedCheckbox).on("change", function () { + $(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked); }); - $("#copy-codes-btn").on("click", function () { + $(SELECTORS.copyCodesBtn).on("click", function () { const codesText = registeredRecoveryCodes.join("\n"); - navigator.clipboard.writeText(codesText).then(() => { - Utils.showToast("Коды скопированы в буфер обмена", "success"); - }); + navigator.clipboard + .writeText(codesText) + .then(() => Utils.showToast(TEXTS.codesCopied, "success")); }); - $("#download-codes-btn").on("click", function () { - const username = $("#recovery-codes-modal").data("username") || "user"; + $(SELECTORS.downloadCodesBtn).on("click", function () { + const username = $(SELECTORS.recoveryModal).data("username") || "user"; const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`; - const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -412,69 +431,54 @@ $(() => { a.download = `recovery-codes-${username}.txt`; a.click(); URL.revokeObjectURL(url); - - Utils.showToast("Файл с кодами скачан", "success"); + Utils.showToast(TEXTS.codesDownloaded, "success"); }); - $("#close-recovery-modal-btn").on("click", function () { - const username = $("#recovery-codes-modal").data("username"); - $("#recovery-codes-modal").addClass("hidden"); - - Utils.showToast("Регистрация успешна! Войдите в систему.", "success"); - showForm("#login-form"); - $("#login-username").val(username); + $(SELECTORS.closeRecoveryBtn).on("click", function () { + const username = $(SELECTORS.recoveryModal).data("username"); + $(SELECTORS.recoveryModal).addClass("hidden"); + Utils.showToast(TEXTS.registrationSuccess, "success"); + showForm(SELECTORS.loginForm); + $(SELECTORS.usernameLogin).val(username); }); - function checkResetPasswordMatch() { - const password = $("#reset-new-password").val(); - const confirm = $("#reset-confirm-password").val(); - if (confirm && password !== confirm) { - $("#reset-password-match-error").removeClass("hidden"); - return false; - } - $("#reset-password-match-error").addClass("hidden"); - return true; - } + $(SELECTORS.resetConfirmPassword).on("input", () => + checkPasswordMatch( + SELECTORS.resetNewPassword, + SELECTORS.resetConfirmPassword, + SELECTORS.resetMatchError, + ), + ); - $("#reset-confirm-password").on("input", checkResetPasswordMatch); - - $("#reset-password-form").on("submit", async function (event) { + $(SELECTORS.resetForm).on("submit", async function (event) { event.preventDefault(); - const $submitBtn = $("#reset-submit"); - - const newPassword = $("#reset-new-password").val(); - const confirmPassword = $("#reset-confirm-password").val(); - + const $submitBtn = $(SELECTORS.submitReset); + const newPassword = $(SELECTORS.resetNewPassword).val(); + const confirmPassword = $(SELECTORS.resetConfirmPassword).val(); if (newPassword !== confirmPassword) { - Utils.showToast("Пароли не совпадают", "error"); + Utils.showToast(TEXTS.passwordsNotMatch, "error"); return; } - if (newPassword.length < 8) { - Utils.showToast("Пароль должен содержать минимум 8 символов", "error"); + Utils.showToast(TEXTS.passwordTooShort, "error"); return; } - const data = { - username: $("#reset-username").val(), - recovery_code: $("#reset-recovery-code").val().toUpperCase(), + username: $(SELECTORS.resetUsername).val(), + recovery_code: $(SELECTORS.resetCode).val().toUpperCase(), new_password: newPassword, }; - if ( !/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test( data.recovery_code, ) ) { - Utils.showToast("Неверный формат резервного кода", "error"); + Utils.showToast(TEXTS.invalidRecoveryCode, "error"); return; } - - $submitBtn.prop("disabled", true).text("Сброс..."); - + $submitBtn.prop("disabled", true).text(TEXTS.resetting); try { const response = await Api.post("/api/auth/password/reset", data); - showPasswordResetResult(response, data.username); } catch (error) { Utils.showToast(error.message || "Ошибка сброса пароля", "error"); @@ -482,9 +486,8 @@ $(() => { } }); - function showPasswordResetResult(response, username) { - const $form = $("#reset-password-form"); - + const showPasswordResetResult = (response, username) => { + const $form = $(SELECTORS.resetForm); $form.html(`
@@ -492,22 +495,19 @@ $(() => {
-

Пароль успешно изменён!

+

${TEXTS.passwordResetSuccess}

-

Осталось резервных кодов: ${response.remaining} из ${response.total}

- ${ response.should_regenerate ? `

- + Рекомендуем сгенерировать новые коды в профиле

@@ -515,12 +515,10 @@ $(() => { ` : "" } -

Статус резервных кодов:

${renderRecoveryCodesStatus(response.used_codes)}
- ${ response.generated_at ? ` @@ -531,23 +529,26 @@ $(() => { : "" }
- - `); - $form.off("submit"); - $("#goto-login-after-reset").on("click", function () { location.reload(); setTimeout(() => { - showForm("#login-form"); - $("#login-username").val(username); + showForm(SELECTORS.loginForm); + $(SELECTORS.usernameLogin).val(username); }, 100); }); - } + }; initLoginState(); + + const widget = $(SELECTORS.capWidget).get(0); + if (widget && widget.shadowRoot) { + const style = document.createElement("style"); + style.textContent = `.credits { right: 20px !important; }`; + $(widget.shadowRoot).append(style); + } }); diff --git a/library_service/static/page/books.js b/library_service/static/page/books.js index 2fef73f..08bc32e 100644 --- a/library_service/static/page/books.js +++ b/library_service/static/page/books.js @@ -1,4 +1,25 @@ -$(document).ready(() => { +$(() => { + const SELECTORS = { + booksContainer: "#books-container", + paginationContainer: "#pagination-container", + bookSearchInput: "#book-search-input", + authorSearchInput: "#author-search-input", + authorDropdown: "#author-dropdown", + selectedAuthorsContainer: "#selected-authors-container", + genresList: "#genres-list", + applyFiltersBtn: "#apply-filters-btn", + resetFiltersBtn: "#reset-filters-btn", + adminActions: "#admin-actions", + pagesMin: "#pages-min", + pagesMax: "#pages-max", + }; + + const TEMPLATES = { + bookCard: document.getElementById("book-card-template"), + genreBadge: document.getElementById("genre-badge-template"), + emptyState: document.getElementById("empty-state-template"), + }; + const STATUS_CONFIG = { active: { label: "Доступна", @@ -27,6 +48,40 @@ $(document).ready(() => { }, }; + const PAGE_SIZE = 12; + + const STATE = { + selectedAuthors: new Map(), + selectedGenres: new Map(), + currentPage: 1, + totalBooks: 0, + }; + + const urlParams = new URLSearchParams(window.location.search); + const INITIAL_FILTERS = { + search: urlParams.get("q") || "", + authorIds: new Set(urlParams.getAll("author_id")), + genreIds: new Set(urlParams.getAll("genre_id")), + }; + + if (INITIAL_FILTERS.search) { + $(SELECTORS.bookSearchInput).val(INITIAL_FILTERS.search); + } + + const LOADING_SKELETON_HTML = `
${Array.from( + { length: 3 }, + () => ` +
+
+
+
+
+ `, + ).join("")}
`; + + const USER_CAN_MANAGE = + typeof window.canManage === "function" && window.canManage(); + function getStatusConfig(status) { return ( STATUS_CONFIG[status] || { @@ -37,224 +92,191 @@ $(document).ready(() => { ); } - let selectedAuthors = new Map(); - let selectedGenres = new Map(); - let currentPage = 1; - let pageSize = 12; - let totalBooks = 0; - - const urlParams = new URLSearchParams(window.location.search); - const genreIdsFromUrl = urlParams.getAll("genre_id"); - const authorIdsFromUrl = urlParams.getAll("author_id"); - const searchFromUrl = urlParams.get("q"); - - if (searchFromUrl) $("#book-search-input").val(searchFromUrl); - - Promise.all([Api.get("/api/authors"), Api.get("/api/genres")]) - .then(([authorsData, genresData]) => { - initAuthors(authorsData.authors); - initGenres(genresData.genres); - initializeAuthorDropdownListeners(); - renderChips(); - loadBooks(); - }) - .catch((error) => { - console.error(error); - Utils.showToast("Ошибка загрузки данных", "error"); - }); - function initAuthors(authors) { - const $dropdown = $("#author-dropdown"); - authors.forEach((author) => { - $("
") - .addClass( - "p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors", - ) - .attr("data-id", author.id) - .attr("data-name", author.name) - .text(author.name) - .appendTo($dropdown); + const $dropdown = $(SELECTORS.authorDropdown); + const fragment = document.createDocumentFragment(); - if (authorIdsFromUrl.includes(String(author.id))) { - selectedAuthors.set(author.id, author.name); + authors.forEach((author) => { + const item = document.createElement("div"); + item.className = + "p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors"; + item.dataset.id = author.id; + item.dataset.name = author.name; + item.textContent = author.name; + fragment.appendChild(item); + + if (INITIAL_FILTERS.authorIds.has(String(author.id))) { + STATE.selectedAuthors.set(author.id, author.name); } }); + + $dropdown.empty().append(fragment); } function initGenres(genres) { - const $list = $("#genres-list"); - genres.forEach((genre) => { - const isChecked = genreIdsFromUrl.includes(String(genre.id)); - if (isChecked) selectedGenres.set(genre.id, genre.name); + const $list = $(SELECTORS.genresList); + const canManage = USER_CAN_MANAGE; + let html = ""; - const editButton = window.canManage() + genres.forEach((genre) => { + const isChecked = INITIAL_FILTERS.genreIds.has(String(genre.id)); + if (isChecked) { + STATE.selectedGenres.set(genre.id, genre.name); + } + const safeName = Utils.escapeHtml(genre.name); + const editButton = canManage ? ` ` : ""; - - $list.append(` + html += `
  • ${editButton}
  • - `); + `; }); - $list.on("change", "input", function () { - const id = parseInt($(this).data("id")); - const name = $(this).data("name"); - this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id); - }); + $list.html(html); $list.on("change", "input", function () { - const id = parseInt($(this).data("id")); + const id = parseInt($(this).data("id"), 10); const name = $(this).data("name"); - this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id); + if (this.checked) { + STATE.selectedGenres.set(id, name); + } else { + STATE.selectedGenres.delete(id); + } }); } + function getTotalPages() { + return Math.max(1, Math.ceil(STATE.totalBooks / PAGE_SIZE)); + } + function loadBooks() { - const searchQuery = $("#book-search-input").val().trim(); - const params = new URLSearchParams(); - - params.append("q", searchQuery); - selectedAuthors.forEach((_, id) => params.append("author_ids", id)); - selectedGenres.forEach((_, id) => params.append("genre_ids", id)); + const searchQuery = $(SELECTORS.bookSearchInput).val().trim(); + const $minPages = $(SELECTORS.pagesMin); + const $maxPages = $(SELECTORS.pagesMax); + const minPages = $minPages.length ? $minPages.val() : ""; + const maxPages = $maxPages.length ? $maxPages.val() : ""; + const apiParams = new URLSearchParams(); const browserParams = new URLSearchParams(); - browserParams.append("q", searchQuery); - selectedAuthors.forEach((_, id) => browserParams.append("author_id", id)); - selectedGenres.forEach((_, id) => browserParams.append("genre_id", id)); + + if (searchQuery) { + apiParams.append("q", searchQuery); + browserParams.append("q", searchQuery); + } + + if (minPages && minPages > 0) { + apiParams.append("min_page_count", minPages); + browserParams.append("min_page_count", minPages); + } + + if (maxPages && maxPages < 2000) { + apiParams.append("max_page_count", maxPages); + browserParams.append("max_page_count", maxPages); + } + + STATE.selectedAuthors.forEach((_, id) => { + apiParams.append("author_ids", id); + browserParams.append("author_id", id); + }); + + STATE.selectedGenres.forEach((_, id) => { + apiParams.append("genre_ids", id); + browserParams.append("genre_id", id); + }); + + apiParams.append("page", STATE.currentPage); + apiParams.append("size", PAGE_SIZE); const newUrl = window.location.pathname + (browserParams.toString() ? `?${browserParams.toString()}` : ""); window.history.replaceState({}, "", newUrl); - params.append("page", currentPage); - params.append("size", pageSize); - showLoadingState(); - Api.get(`/api/books/filter?${params.toString()}`) + Api.get(`/api/books/filter?${apiParams.toString()}`) .then((data) => { - totalBooks = data.total; - renderBooks(data.books); + STATE.totalBooks = data.total || 0; + renderBooks(data.books || []); renderPagination(); }) .catch((error) => { console.error(error); Utils.showToast("Не удалось загрузить книги", "error"); - $("#books-container").html( - document.getElementById("empty-state-template").innerHTML, + $(SELECTORS.booksContainer).html( + TEMPLATES.emptyState.content.cloneNode(true), ); }); } function renderBooks(books) { - const $container = $("#books-container"); - const tpl = document.getElementById("book-card-template"); - const emptyTpl = document.getElementById("empty-state-template"); - const badgeTpl = document.getElementById("genre-badge-template"); - + const $container = $(SELECTORS.booksContainer); $container.empty(); - if (books.length === 0) { - $container.append(emptyTpl.content.cloneNode(true)); + if (!books.length) { + $container.append(TEMPLATES.emptyState.content.cloneNode(true)); return; } - books.forEach((book) => { - const clone = tpl.content.cloneNode(true); - const card = clone.querySelector(".book-card"); + const fragment = document.createDocumentFragment(); + books.forEach((book) => { + const clone = TEMPLATES.bookCard.content.cloneNode(true); + const card = clone.querySelector(".book-card"); card.dataset.id = book.id; - clone.querySelector(".book-title").textContent = book.title; - clone.querySelector(".book-authors").textContent = - book.authors.map((a) => a.name).join(", ") || "Автор неизвестен"; + + const titleEl = clone.querySelector(".book-title"); + const authorsEl = clone.querySelector(".book-authors"); + const pageCountWrapper = clone.querySelector(".book-page-count"); + const pageCountValue = + pageCountWrapper.querySelector(".page-count-value"); + const descEl = clone.querySelector(".book-desc"); + const statusEl = clone.querySelector(".book-status"); + const genresContainer = clone.querySelector(".book-genres"); + + titleEl.textContent = book.title; + authorsEl.textContent = + (book.authors && book.authors.map((a) => a.name).join(", ")) || + "Автор неизвестен"; + if (book.page_count && book.page_count > 0) { - const pageEl = clone.querySelector(".book-page-count"); - pageEl.querySelector(".page-count-value").textContent = book.page_count; - pageEl.classList.remove("hidden"); + pageCountValue.textContent = book.page_count; + pageCountWrapper.classList.remove("hidden"); } - clone.querySelector(".book-desc").textContent = book.description || ""; + + descEl.textContent = book.description || ""; const statusConfig = getStatusConfig(book.status); - const statusEl = clone.querySelector(".book-status"); statusEl.textContent = statusConfig.label; statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass); - const genresContainer = clone.querySelector(".book-genres"); - book.genres.forEach((g) => { - const badge = badgeTpl.content.cloneNode(true); - const span = badge.querySelector("span"); - span.textContent = g.name; - genresContainer.appendChild(badge); - }); - - $container.append(clone); - }); - } - - function renderPagination() { - $("#pagination-container").empty(); - const totalPages = Math.ceil(totalBooks / pageSize); - if (totalPages <= 1) return; - - const $pagination = $(` -
    - -
    - -
    - `); - - const $pageNumbers = $pagination.find("#page-numbers"); - const pages = generatePageNumbers(currentPage, totalPages); - - pages.forEach((page) => { - if (page === "...") { - $pageNumbers.append(`...`); - } else { - const isActive = page === currentPage; - $pageNumbers.append(` - - `); + if (Array.isArray(book.genres)) { + book.genres.forEach((g) => { + const badge = TEMPLATES.genreBadge.content.cloneNode(true); + const span = badge.querySelector("span"); + span.textContent = g.name; + genresContainer.appendChild(badge); + }); } + + fragment.appendChild(clone); }); - $("#pagination-container").append($pagination); - - $("#prev-page").on("click", function () { - if (currentPage > 1) { - currentPage--; - loadBooks(); - scrollToTop(); - } - }); - $("#next-page").on("click", function () { - if (currentPage < totalPages) { - currentPage++; - loadBooks(); - scrollToTop(); - } - }); - $(".page-btn").on("click", function () { - const page = parseInt($(this).data("page")); - if (page !== currentPage) { - currentPage = page; - loadBooks(); - scrollToTop(); - } - }); + $container.append(fragment); } function generatePageNumbers(current, total) { @@ -274,49 +296,81 @@ $(document).ready(() => { return pages; } + function renderPagination() { + const totalPages = getTotalPages(); + const $container = $(SELECTORS.paginationContainer); + $container.empty(); + + if (totalPages <= 1) { + return; + } + + const pages = generatePageNumbers(STATE.currentPage, totalPages); + let pagesHtml = ""; + + pages.forEach((page) => { + if (page === "...") { + pagesHtml += `...`; + } else { + const isActive = page === STATE.currentPage; + pagesHtml += ``; + } + }); + + const html = ` +
    + +
    ${pagesHtml}
    + +
    + `; + + $container.html(html); + } + function scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } function showLoadingState() { - $("#books-container").html(` -
    - ${Array(3) - .fill() - .map( - () => ` -
    -
    -
    -
    -
    - `, - ) - .join("")} -
    - `); + $(SELECTORS.booksContainer).html(LOADING_SKELETON_HTML); } - function renderChips() { - const $container = $("#selected-authors-container"); - const $dropdown = $("#author-dropdown"); - + function renderSelectedAuthors() { + const $container = $(SELECTORS.selectedAuthorsContainer); + const $dropdown = $(SELECTORS.authorDropdown); $container.empty(); - selectedAuthors.forEach((name, id) => { - $(` - ${Utils.escapeHtml(name)} - - `).appendTo($container); + const fragment = document.createDocumentFragment(); + + STATE.selectedAuthors.forEach((name, id) => { + const wrapper = document.createElement("span"); + wrapper.className = + "author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full"; + wrapper.innerHTML = ` + ${Utils.escapeHtml(name)} + + `; + fragment.appendChild(wrapper); }); + $container.append(fragment); + $dropdown.find(".author-item").each(function () { - const id = parseInt($(this).data("id")); - if (selectedAuthors.has(id)) { + const id = parseInt($(this).data("id"), 10); + if (STATE.selectedAuthors.has(id)) { $(this) .addClass("bg-gray-200 text-gray-900 font-semibold") .removeClass("hover:bg-gray-100"); @@ -329,11 +383,11 @@ $(document).ready(() => { } function initializeAuthorDropdownListeners() { - const $input = $("#author-search-input"); - const $dropdown = $("#author-dropdown"); - const $container = $("#selected-authors-container"); + const $input = $(SELECTORS.authorSearchInput); + const $dropdown = $(SELECTORS.authorDropdown); + const $container = $(SELECTORS.selectedAuthorsContainer); - $input.on("focus", function () { + $input.on("focus", () => { $dropdown.removeClass("hidden"); }); @@ -349,7 +403,7 @@ $(document).ready(() => { $(document).on("click", function (e) { if ( !$(e.target).closest( - "#author-search-input, #author-dropdown, #selected-authors-container", + `${SELECTORS.authorSearchInput}, ${SELECTORS.authorDropdown}, ${SELECTORS.selectedAuthorsContainer}`, ).length ) { $dropdown.addClass("hidden"); @@ -358,61 +412,108 @@ $(document).ready(() => { $dropdown.on("click", ".author-item", function (e) { e.stopPropagation(); - const id = parseInt($(this).data("id")); + const id = parseInt($(this).data("id"), 10); const name = $(this).data("name"); - if (selectedAuthors.has(id)) { - selectedAuthors.delete(id); + if (STATE.selectedAuthors.has(id)) { + STATE.selectedAuthors.delete(id); } else { - selectedAuthors.set(id, name); + STATE.selectedAuthors.set(id, name); } $input.val(""); $dropdown.find(".author-item").show(); - renderChips(); + renderSelectedAuthors(); $input[0].focus(); }); $container.on("click", ".remove-author", function (e) { e.stopPropagation(); - const id = parseInt($(this).data("id")); - selectedAuthors.delete(id); - renderChips(); + const id = parseInt($(this).data("id"), 10); + STATE.selectedAuthors.delete(id); + renderSelectedAuthors(); }); } - $("#books-container").on("click", ".book-card", function () { - window.location.href = `/book/${$(this).data("id")}`; + $(SELECTORS.booksContainer).on("click", ".book-card", function () { + const id = $(this).data("id"); + if (id) { + window.location.href = `/book/${id}`; + } }); - $("#apply-filters-btn").on("click", function () { - currentPage = 1; + $(SELECTORS.applyFiltersBtn).on("click", function () { + STATE.currentPage = 1; loadBooks(); }); - $("#reset-filters-btn").on("click", function () { - $("#book-search-input").val(""); - selectedAuthors.clear(); - selectedGenres.clear(); - $("#genres-list input").prop("checked", false); - renderChips(); - currentPage = 1; + $(SELECTORS.resetFiltersBtn).on("click", function () { + $(SELECTORS.bookSearchInput).val(""); + STATE.selectedAuthors.clear(); + STATE.selectedGenres.clear(); + $(`${SELECTORS.genresList} input`).prop("checked", false); + + const $min = $(SELECTORS.pagesMin); + const $max = $(SELECTORS.pagesMax); + if ($min.length && $max.length) { + const minDefault = $min.attr("min"); + const maxDefault = $max.attr("max"); + if (minDefault !== undefined) $min.val(minDefault).trigger("input"); + if (maxDefault !== undefined) $max.val(maxDefault).trigger("input"); + } + + renderSelectedAuthors(); + STATE.currentPage = 1; loadBooks(); }); - $("#book-search-input").on("keypress", function (e) { + $(SELECTORS.bookSearchInput).on("keypress", function (e) { if (e.which === 13) { - currentPage = 1; + STATE.currentPage = 1; loadBooks(); } }); - function showAdminControls() { - if (window.canManage()) { - $("#admin-actions").removeClass("hidden"); + $(SELECTORS.paginationContainer).on("click", "#prev-page", function () { + if (STATE.currentPage > 1) { + STATE.currentPage -= 1; + loadBooks(); + scrollToTop(); } + }); + + $(SELECTORS.paginationContainer).on("click", "#next-page", function () { + const totalPages = getTotalPages(); + if (STATE.currentPage < totalPages) { + STATE.currentPage += 1; + loadBooks(); + scrollToTop(); + } + }); + + $(SELECTORS.paginationContainer).on("click", ".page-btn", function () { + const page = parseInt($(this).data("page"), 10); + if (page && page !== STATE.currentPage) { + STATE.currentPage = page; + loadBooks(); + scrollToTop(); + } + }); + + if (USER_CAN_MANAGE) { + $(SELECTORS.adminActions).removeClass("hidden"); } - showAdminControls(); - setTimeout(showAdminControls, 100); + Promise.all([Api.get("/api/authors"), Api.get("/api/genres")]) + .then(([authorsData, genresData]) => { + initAuthors(authorsData.authors || []); + initGenres(genresData.genres || []); + initializeAuthorDropdownListeners(); + renderSelectedAuthors(); + loadBooks(); + }) + .catch((error) => { + console.error(error); + Utils.showToast("Ошибка загрузки данных", "error"); + }); }); diff --git a/library_service/templates/auth.html b/library_service/templates/auth.html index 45c8c07..bb46d0a 100644 --- a/library_service/templates/auth.html +++ b/library_service/templates/auth.html @@ -184,6 +184,27 @@

    +
    + +
    +
    {% endblock %} {% block scripts %} + {% endblock %} diff --git a/library_service/templates/books.html b/library_service/templates/books.html index 7e04eb9..456c040 100644 --- a/library_service/templates/books.html +++ b/library_service/templates/books.html @@ -1,5 +1,38 @@ {% extends "base.html" %} {% block title %}LiB - Книги{% endblock %} {% block content %} +
    + +
    +

    Страниц

    + +
    + От: + До: +
    + +
    +
    + +
    + + + + +
    +
    +

    Авторы

    {% endblock %} {% block scripts %} + {% endblock %} diff --git a/main.py b/main.py deleted file mode 100644 index 9c03940..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from libraryapi!") - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index b7ed01c..25ce994 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "aiofiles>=25.1.0", "qrcode[pil]>=8.2", "pyotp>=2.9.0", + "slowapi>=0.1.9", + "limits>=5.6.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index d1d14f5..bde6f1c 100644 --- a/uv.lock +++ b/uv.lock @@ -278,6 +278,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "dill" version = "0.4.0" @@ -627,6 +639,7 @@ dependencies = [ { name = "fastapi", extra = ["all"] }, { name = "jinja2" }, { name = "json-log-formatter" }, + { name = "limits" }, { name = "passlib", extra = ["argon2"] }, { name = "psycopg2-binary" }, { name = "pydantic", extra = ["email"] }, @@ -634,6 +647,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-jose", extra = ["cryptography"] }, { name = "qrcode", extra = ["pil"] }, + { name = "slowapi" }, { name = "sqlmodel" }, { name = "toml" }, { name = "uvicorn", extra = ["standard"] }, @@ -655,6 +669,7 @@ requires-dist = [ { name = "fastapi", extras = ["all"], specifier = ">=0.115.14" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "json-log-formatter", specifier = ">=1.1.1" }, + { name = "limits", specifier = ">=5.6.0" }, { name = "passlib", extras = ["argon2"], specifier = ">=1.7.4" }, { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, @@ -662,6 +677,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=0.21.1" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, + { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlmodel", specifier = ">=0.0.31" }, { name = "toml", specifier = ">=0.10.2" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, @@ -676,6 +692,20 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.3.0" }, ] +[[package]] +name = "limits" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -1451,6 +1481,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.45" @@ -1796,3 +1838,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" }, + { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" }, + { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" }, + { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +]