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

This commit is contained in:
2026-01-23 23:32:09 +03:00
parent 7c3074e8fe
commit c1ac0ca246
19 changed files with 1258 additions and 568 deletions
+11 -2
View File
@@ -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",
+2
View File
@@ -8,6 +8,7 @@ from .books import router as books_router
from .genres import router as genres_router
from .loans import router as loans_router
from .relationships import router as relationships_router
from .cap import router as cap_router
from .users import router as users_router
from .misc import router as misc_router
@@ -22,5 +23,6 @@ api_router.include_router(authors_router, prefix="/api")
api_router.include_router(books_router, prefix="/api")
api_router.include_router(genres_router, prefix="/api")
api_router.include_router(loans_router, prefix="/api")
api_router.include_router(cap_router, prefix="/api")
api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api")
+6 -1
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from library_service.services import require_captcha
from library_service.models.db import Role, User
from library_service.models.dto import (
Token,
@@ -63,7 +64,11 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
summary="Регистрация нового пользователя",
description="Создает нового пользователя и возвращает резервные коды",
)
def register(user_data: UserCreate, session: Session = Depends(get_session)):
def register(
user_data: UserCreate,
_=Depends(require_captcha),
session: Session = Depends(get_session),
):
"""Регистрирует нового пользователя в системе"""
existing_user = session.exec(
select(User).where(User.username == user_data.username)
+19 -6
View File
@@ -1,12 +1,19 @@
"""Модуль работы с авторами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import (BookRead, AuthorWithBooks,
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
from library_service.models.dto import (
BookRead,
AuthorWithBooks,
AuthorCreate,
AuthorList,
AuthorRead,
AuthorUpdate,
)
router = APIRouter(prefix="/authors", tags=["authors"])
@@ -59,7 +66,9 @@ def get_author(
"""Возвращает информацию об авторе и его книгах"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
books = session.exec(
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
@@ -88,7 +97,9 @@ def update_author(
"""Обновляет информацию об авторе"""
db_author = session.get(Author, author_id)
if not db_author:
raise HTTPException(status_code=404, detail="Author not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
update_data = author.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -113,7 +124,9 @@ def delete_author(
"""Удаляет автора из системы"""
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
)
author_read = AuthorRead(**author.model_dump())
session.delete(author)
+26 -8
View File
@@ -3,7 +3,7 @@
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff
@@ -56,10 +56,16 @@ def close_active_loan(session: Session, book_id: int) -> None:
def filter_books(
session: Session = Depends(get_session),
q: str | None = Query(None, max_length=50, description="Поиск"),
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
min_page_count: int | None = Query(
None, ge=0, description="Минимальное количество страниц"
),
max_page_count: int | None = Query(
None, ge=0, description="Максимальное количество страниц"
),
author_ids: List[int] | None = Query(None, gt=0, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, gt=0, description="Список ID жанров"),
page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
size: int = Query(20, gt=0, le=100, description="Количество элементов на странице"),
):
"""Возвращает отфильтрованный список книг с пагинацией"""
statement = select(Book).distinct()
@@ -69,6 +75,12 @@ def filter_books(
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
)
if min_page_count:
statement = statement.where(Book.page_count >= min_page_count)
if max_page_count:
statement = statement.where(Book.page_count <= max_page_count)
if author_ids:
statement = statement.join(AuthorBookLink).where(
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
@@ -149,7 +161,9 @@ def get_book(
"""Возвращает информацию о книге с авторами и жанрами"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
authors = session.exec(
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
@@ -185,12 +199,14 @@ def update_book(
"""Обновляет информацию о книге"""
db_book = session.get(Book, book_id)
if not db_book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book_update.status is not None:
if book_update.status == BookStatus.BORROWED:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Статус 'borrowed' устанавливается только через выдачу книги",
)
@@ -226,7 +242,9 @@ def delete_book(
"""Удаляет книгу из системы"""
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
book_read = BookRead(
id=(book.id or 0),
title=book.title,
+101
View File
@@ -0,0 +1,101 @@
import asyncio
import hashlib
import secrets
from fastapi import APIRouter, Request, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from library_service.services.captcha import (
limiter,
get_ip,
active_challenges,
challenges_by_ip,
MAX_CHALLENGES_PER_IP,
MAX_TOTAL_CHALLENGES,
CHALLENGE_TTL,
REDEEM_TTL,
prng,
now_ms,
redeem_tokens,
)
router = APIRouter(prefix="/cap", tags=["captcha"])
@router.post("/challenge")
@limiter.limit("15/minute")
async def challenge(request: Request, ip: str = Depends(get_ip)):
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
)
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
)
token = secrets.token_hex(25)
redeem = secrets.token_hex(25)
expires = now_ms() + CHALLENGE_TTL
active_challenges[token] = {
"c": 50,
"s": 32,
"d": 4,
"expires": expires,
"redeem_token": redeem,
"ip": ip,
}
challenges_by_ip[ip] += 1
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
@router.post("/redeem")
@limiter.limit("30/minute")
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
token = payload.get("token")
solutions = payload.get("solutions", [])
if token not in active_challenges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
)
ch = active_challenges.pop(token)
challenges_by_ip[ch["ip"]] -= 1
if now_ms() > ch["expires"]:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
if len(solutions) < ch["c"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
)
def verify(i: int) -> bool:
salt = prng(f"{token}{i+1}", ch["s"])
target = prng(f"{token}{i+1}d", ch["d"])
h = hashlib.sha256((salt + str(solutions[i])).encode()).hexdigest()
return h.startswith(target)
results = await asyncio.gather(
*(asyncio.to_thread(verify, i) for i in range(ch["c"]))
)
if not all(results):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
)
r_token = ch["redeem_token"]
redeem_tokens[r_token] = now_ms() + REDEEM_TTL
resp = JSONResponse(
{"success": True, "token": r_token, "expires": redeem_tokens[r_token]}
)
resp.set_cookie(
key="capjs_token",
value=r_token,
httponly=True,
samesite="lax",
max_age=REDEEM_TTL // 1000,
)
return resp
+19 -5
View File
@@ -1,10 +1,18 @@
"""Модуль работы с жанрами"""
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
from library_service.models.db import Book, Genre, GenreBookLink
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
from library_service.models.dto import (
BookRead,
GenreCreate,
GenreList,
GenreRead,
GenreUpdate,
GenreWithBooks,
)
from library_service.settings import get_session
@@ -57,7 +65,9 @@ def get_genre(
"""Возвращает информацию о жанре и книгах с ним"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
books = session.exec(
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
@@ -86,7 +96,9 @@ def update_genre(
"""Обновляет информацию о жанре"""
db_genre = session.get(Genre, genre_id)
if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
update_data = genre.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -111,7 +123,9 @@ def delete_genre(
"""Удаляет жанр из системы"""
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
)
genre_read = GenreRead(**genre.model_dump())
session.delete(genre)
+51 -19
View File
@@ -40,17 +40,21 @@ def create_loan(
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available for loan (status: {book.status})",
)
target_user = session.get(User, loan.user_id)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink(
book_id=loan.book_id,
@@ -248,7 +252,9 @@ def get_loan(
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user)
@@ -275,7 +281,9 @@ def update_loan(
"""Обновляет информацию о выдаче"""
db_loan = session.get(BookUserLink, loan_id)
if not db_loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user)
@@ -287,7 +295,9 @@ def update_loan(
book = session.get(Book, db_loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if loan_update.user_id is not None:
if not is_staff:
@@ -297,7 +307,9 @@ def update_loan(
)
new_user = session.get(User, loan_update.user_id)
if not new_user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan.user_id = loan_update.user_id
if loan_update.due_date is not None:
@@ -305,7 +317,10 @@ def update_loan(
if loan_update.returned_at is not None:
if db_loan.returned_at is not None:
raise HTTPException(status_code=400, detail="Loan is already returned")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Loan is already returned",
)
db_loan.returned_at = loan_update.returned_at
book.status = BookStatus.ACTIVE
@@ -331,18 +346,24 @@ def confirm_loan(
"""Подтверждает бронирование и меняет статус книги на BORROWED"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot confirm loan for book with status: {book.status}",
)
@@ -370,10 +391,14 @@ def return_loan(
"""Возвращает книгу и закрывает выдачу"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
)
loan.returned_at = datetime.now(timezone.utc)
@@ -403,7 +428,9 @@ def delete_loan(
"""Удаляет выдачу или бронирование (только для RESERVED статуса)"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
)
is_staff = is_user_staff(current_user)
@@ -417,7 +444,7 @@ def delete_loan(
if book and book.status != BookStatus.RESERVED:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only delete reservations. Use update endpoint to return borrowed books",
)
@@ -471,16 +498,21 @@ def issue_book_directly(
"""Выдает книгу напрямую без бронирования (только для администраторов)"""
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
)
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400, detail=f"Book is not available (status: {book.status})"
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book is not available (status: {book.status})",
)
target_user = session.get(User, loan.user_id)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
db_loan = BookUserLink(
book_id=loan.book_id,
+89 -35
View File
@@ -1,7 +1,8 @@
"""Модуль работы со связями"""
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from library_service.auth import RequireStaff
@@ -17,7 +18,9 @@ def check_entity_exists(session, model, entity_id, entity_name):
"""Проверяет существование сущности в базе данных"""
entity = session.get(model, entity_id)
if not entity:
raise HTTPException(status_code=404, detail=f"{entity_name} not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"{entity_name} not found"
)
return entity
@@ -30,7 +33,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
).first()
if existing_link:
raise HTTPException(status_code=400, detail=detail)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
link = link_model(**{field1: id1, field2: id2})
session.add(link)
@@ -48,7 +51,9 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
).first()
if not link:
raise HTTPException(status_code=404, detail="Relationship not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Relationship not found"
)
session.delete(link)
session.commit()
@@ -56,21 +61,22 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
def get_related(
session,
main_model,
main_id,
main_name,
related_model,
link_model,
link_main_field,
link_related_field,
read_model
):
session,
main_model,
main_id,
main_name,
related_model,
link_model,
link_main_field,
link_related_field,
read_model,
):
"""Возвращает список связанных сущностей"""
check_entity_exists(session, main_model, main_id, main_name)
related = session.exec(
select(related_model).join(link_model)
select(related_model)
.join(link_model)
.where(getattr(link_model, link_main_field) == main_id)
).all()
@@ -93,8 +99,15 @@ def add_author_to_book(
check_entity_exists(session, Author, author_id, "Author")
check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id", "Relationship already exists")
return add_relationship(
session,
AuthorBookLink,
author_id,
"author_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete(
@@ -110,8 +123,9 @@ def remove_author_from_book(
session: Session = Depends(get_session),
):
"""Удаляет связь между автором и книгой"""
return remove_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id")
return remove_relationship(
session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
)
@router.get(
@@ -122,9 +136,17 @@ def remove_author_from_book(
)
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг автора"""
return get_related(session,
Author, author_id, "Author", Book,
AuthorBookLink, "author_id", "book_id", BookRead)
return get_related(
session,
Author,
author_id,
"Author",
Book,
AuthorBookLink,
"author_id",
"book_id",
BookRead,
)
@router.get(
@@ -135,9 +157,17 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
)
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
"""Возвращает список авторов книги"""
return get_related(session,
Book, book_id, "Book", Author,
AuthorBookLink, "book_id", "author_id", AuthorRead)
return get_related(
session,
Book,
book_id,
"Book",
Author,
AuthorBookLink,
"book_id",
"author_id",
AuthorRead,
)
@router.post(
@@ -156,8 +186,15 @@ def add_genre_to_book(
check_entity_exists(session, Genre, genre_id, "Genre")
check_entity_exists(session, Book, book_id, "Book")
return add_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
return add_relationship(
session,
GenreBookLink,
genre_id,
"genre_id",
book_id,
"book_id",
"Relationship already exists",
)
@router.delete(
@@ -173,8 +210,9 @@ def remove_genre_from_book(
session: Session = Depends(get_session),
):
"""Удаляет связь между жанром и книгой"""
return remove_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id")
return remove_relationship(
session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
)
@router.get(
@@ -185,9 +223,17 @@ def remove_genre_from_book(
)
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
"""Возвращает список книг в жанре"""
return get_related(session,
Genre, genre_id, "Genre", Book,
GenreBookLink, "genre_id", "book_id", BookRead)
return get_related(
session,
Genre,
genre_id,
"Genre",
Book,
GenreBookLink,
"genre_id",
"book_id",
BookRead,
)
@router.get(
@@ -198,6 +244,14 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
)
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
"""Возвращает список жанров книги"""
return get_related(session,
Book, book_id, "Book", Genre,
GenreBookLink, "book_id", "genre_id", GenreRead)
return get_related(
session,
Book,
book_id,
"Book",
Genre,
GenreBookLink,
"book_id",
"genre_id",
GenreRead,
)
+29
View File
@@ -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",
]
+75
View File
@@ -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]
+1
View File
@@ -61,6 +61,7 @@ OPENAPI_TAGS = [
{"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."},
{"name": "users", "description": "Действия с пользователями."},
{"name": "captcha", "description": "Создание и проверка cap.js каптчи."},
{"name": "misc", "description": "Прочие."},
]
+274 -273
View File
@@ -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(
`<cap-widget id="cap" data-cap-api-endpoint="/api/cap/" style="--cap-widget-width: 100%;"></cap-widget>`,
);
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(`
<div class="py-1 px-2 bg-white rounded border select-all font-mono">
${index + 1}. ${Utils.escapeHtml(code)}
</div>
`);
$list.append(
`<div class="py-1 px-2 bg-white rounded border select-all font-mono">${index + 1}. ${Utils.escapeHtml(code)}</div>`,
);
});
$(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 `
<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}">
<span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span>
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
</div>
`;
return `<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}"><span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span><span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span></div>`;
})
.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(`
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
@@ -492,22 +495,19 @@ $(() => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-800">Пароль успешно изменён!</h3>
<h3 class="text-lg font-semibold text-gray-800">${TEXTS.passwordResetSuccess}</h3>
</div>
<div class="mb-4">
<p class="text-sm text-gray-600 mb-2 text-center">
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
</p>
${
response.should_regenerate
? `
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
<p class="text-sm text-yellow-800 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
Рекомендуем сгенерировать новые коды в профиле
</p>
@@ -515,12 +515,10 @@ $(() => {
`
: ""
}
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
${renderRecoveryCodesStatus(response.used_codes)}
</div>
${
response.generated_at
? `
@@ -531,23 +529,26 @@ $(() => {
: ""
}
</div>
<button type="button" id="goto-login-after-reset"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
<button type="button" id="goto-login-after-reset" class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
Перейти к входу
</button>
`);
$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);
}
});
+314 -213
View File
@@ -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 = `<div class="space-y-4">${Array.from(
{ length: 3 },
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
</div>
`,
).join("")}</div>`;
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) => {
$("<div>")
.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
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</a>`
: "";
$list.append(`
html += `
<li class="mb-1">
<div class="flex items-center">
<label class="custom-checkbox flex items-center flex-1">
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
<input type="checkbox" data-id="${genre.id}" data-name="${safeName}" ${
isChecked ? "checked" : ""
} />
<span class="checkmark"></span> ${safeName}
</label>
${editButton}
</div>
</li>
`);
`;
});
$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 = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`);
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 += `<span class="px-3 py-2 text-gray-500">...</span>`;
} else {
const isActive = page === STATE.currentPage;
pagesHtml += `<button class="page-btn px-3 py-2 rounded-lg transition-colors ${
isActive
? "bg-gray-600 text-white"
: "bg-white border border-gray-300 hover:bg-gray-50"
}" data-page="${page}">${page}</button>`;
}
});
const html = `
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === 1 ? "disabled" : ""
}>&larr;</button>
<div id="page-numbers" class="flex gap-1">${pagesHtml}</div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
STATE.currentPage === totalPages ? "disabled" : ""
}>&rarr;</button>
</div>
`;
$container.html(html);
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function showLoadingState() {
$("#books-container").html(`
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
</div>
`,
)
.join("")}
</div>
`);
$(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) => {
$(`<span class="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">
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).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)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
`;
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");
});
});
+22
View File
@@ -184,6 +184,27 @@
</p>
</div>
<div class="mb-4">
<cap-widget id="cap"
data-cap-api-endpoint="/api/cap/"
style="
--cap-widget-width: 100%;
--cap-background: #fdfdfd;
--cap-border-color: #d1d5db;
--cap-border-radius: 8px;
--cap-widget-height: auto;
--cap-color: #212121;
--cap-checkbox-size: 32px;
--cap-checkbox-border: 1.5px dashed #d1d5db;
--cap-checkbox-border-radius: 6px;
--cap-checkbox-background: #fafafa;
--cap-checkbox-margin: 2px;
--cap-spinner-color: #4b5563;
--cap-spinner-background-color: #eee;
--cap-spinner-thickness: 5px;"
></cap-widget>
</div>
<button type="submit" id="register-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
Зарегистрироваться
@@ -320,5 +341,6 @@
</div>
</div>
{% endblock %} {% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<script src="/static/page/auth.js"></script>
{% endblock %}
+106
View File
@@ -1,5 +1,38 @@
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
{% block content %}
<style>
.range-double {
height: 0;
pointer-events: none;
appearance: none;
-webkit-appearance: none;
}
.range-double::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-thumb {
width: 8px;
height: 8px;
border-radius: 100%;
background: #4b5563;
border: 1px solid #ffffff;
box-shadow: 0 0 0 1px #4b5563;
cursor: pointer;
pointer-events: auto;
}
.range-double::-moz-range-track {
background: transparent;
}
</style>
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
<aside class="w-full md:w-1/4">
<div
@@ -88,6 +121,49 @@
</svg>
</div>
</div>
<div
x-data="pagesSlider(0, 2000, 10)"
class="bg-white p-4 rounded-lg shadow-md mb-6"
>
<h2 class="text-xl font-bold mb-4">Страниц</h2>
<div class="flex justify-between text-xs text-gray-500 mb-2">
<span>От: <span id="pages-min-value" x-text="minValue"></span></span>
<span>До: <span id="pages-max-value" x-text="maxValue"></span></span>
</div>
<div class="relative mt-4 mb-6">
<div class="absolute top-1/2 -translate-y-1/2 w-full h-1 bg-gray-200 rounded-full"></div>
<div
id="pages-range-progress"
class="absolute top-1/2 -translate-y-1/2 h-1 bg-gray-600 rounded-full"
:style="{ left: leftPercent + '%', right: rightPercent + '%' }"
></div>
<input
id="pages-min"
type="range"
:min="min"
:max="max"
x-model.number="minValue"
@input="onMinInput()"
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
/>
<input
id="pages-max"
type="range"
:min="min"
:max="max"
x-model.number="maxValue"
@input="onMaxInput()"
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
/>
</div>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-bold mb-4">Авторы</h2>
<div
@@ -192,4 +268,34 @@
</template>
{% endblock %} {% block scripts %}
<script src="/static/page/books.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('pagesSlider', (min, max, gap) => ({
min,
max,
gap,
minValue: min,
maxValue: max,
// проценты для заливки
get leftPercent() {
return (this.minValue - this.min) * 100 / (this.max - this.min);
},
get rightPercent() {
return 100 - (this.maxValue - this.min) * 100 / (this.max - this.min);
},
onMinInput() {
if (this.maxValue - this.minValue < this.gap) {
this.minValue = this.maxValue - this.gap;
}
},
onMaxInput() {
if (this.maxValue - this.minValue < this.gap) {
this.maxValue = this.minValue + this.gap;
}
}
}));
});
</script>
{% endblock %}