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(
+ `
Осталось резервных кодов: ${response.remaining} из ${response.total}
- ${ response.should_regenerate ? `Рекомендуем сгенерировать новые коды в профиле
@@ -515,12 +515,10 @@ $(() => { ` : "" } -Статус резервных кодов:
${renderRecoveryCodesStatus(response.used_codes)}