This commit is contained in:
2026-05-01 03:27:33 +03:00
parent 469fd1ba94
commit 43597be020
6 changed files with 260 additions and 66 deletions

View File

@@ -7,6 +7,7 @@
3. [Стек технологий](#3-стек-технологий) 3. [Стек технологий](#3-стек-технологий)
4. [Схема архитектуры](#4-схема-архитектуры) 4. [Схема архитектуры](#4-схема-архитектуры)
5. [Модули бэкенда](#5-модули-бэкенда) 5. [Модули бэкенда](#5-модули-бэкенда)
- [auth.py](#authpy)
- [browser.py](#browserpy) - [browser.py](#browserpy)
- [scraper.py](#scraperpy) - [scraper.py](#scraperpy)
- [exporter.py](#exporterpy) - [exporter.py](#exporterpy)
@@ -29,7 +30,7 @@
Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы: Приложение скачивает мангу с сайтов типа readmanga.ru, обходя JS-защиту (DDoS-Guard, антибот) с помощью управляемого браузера Chromium. Поддерживает два режима работы:
- **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket. - **Веб-интерфейс** — FastAPI-сервер + SPA на чистом JS (порт 8000). Позволяет управлять загрузками через браузер с realtime-прогрессом через WebSocket. Требует входа — поддерживает многопользовательский режим с ролями `admin` / `user`.
- **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`. - **CLI** — консольные команды `download` и `analyze` для запуска через `docker compose run`.
--- ---
@@ -41,6 +42,7 @@ manga/
├── src/ # Весь бэкенд-код (Python-пакет) ├── src/ # Весь бэкенд-код (Python-пакет)
│ ├── __init__.py │ ├── __init__.py
│ ├── api.py # FastAPI-приложение, REST + WebSocket │ ├── api.py # FastAPI-приложение, REST + WebSocket
│ ├── auth.py # Хеширование паролей, генерация токенов сессий
│ ├── browser.py # Обёртка над Playwright/Chromium │ ├── browser.py # Обёртка над Playwright/Chromium
│ ├── cli.py # CLI-команды (click) │ ├── cli.py # CLI-команды (click)
│ ├── downloader.py # (legacy, не используется в web-режиме) │ ├── downloader.py # (legacy, не используется в web-режиме)
@@ -100,6 +102,7 @@ manga/
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ FastAPI (api.py) │ │ FastAPI (api.py) │
│ │ │ │
│ Auth middleware (cookie/session)│
│ REST endpoints WebSocket /ws │ │ REST endpoints WebSocket /ws │
│ │ │ │ │ │ │ │
│ asyncio.Queue ws_manager │ │ asyncio.Queue ws_manager │
@@ -129,6 +132,25 @@ manga/
## 5. Модули бэкенда ## 5. Модули бэкенда
### auth.py
**Отвечает за:** хеширование паролей и генерацию токенов сессий.
**Константы:**
```python
COOKIE_NAME = "manga_session"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
```
**Функции:**
- `hash_password(password)``str` — хеширует пароль методом PBKDF2-SHA256, 260 000 итераций, случайная соль. Формат результата: `pbkdf2:260000:<salt_hex>:<key_hex>`.
- `verify_password(password, hashed)``bool` — проверяет пароль по хешу через `hmac.compare_digest` (защита от timing-атак).
- `generate_session_token()``str` — генерирует 48-байтный URL-safe токен (`secrets.token_urlsafe`).
---
### browser.py ### browser.py
**Отвечает за:** запуск и управление Playwright Chromium. **Отвечает за:** запуск и управление Playwright Chromium.
@@ -248,9 +270,11 @@ class MangaInfo:
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` | | `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` | | `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
| `chapters_total` | INTEGER | Кол-во глав (из scraper) | | `chapters_total` | INTEGER | Кол-во глав (из scraper) |
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ | | `chapters_done` | INTEGER | Денормализованный счётчик¹ |
| `last_checked_at` | TEXT | Время последней проверки новых глав | | `last_checked_at` | TEXT | Время последней проверки новых глав |
| `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) | | `folder_name` | TEXT | Кастомное имя папки на диске (NULL → вычисляется из `title_ru`) |
| `source_id` | INTEGER | FK → sources.id — источник манги |
| `added_by` | INTEGER | FK → users.id — кто добавил мангу |
¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`. ¹ API всегда пересчитывает `chapters_done` запросом `SELECT COUNT(*) FROM chapters WHERE status='done'`.
@@ -275,15 +299,59 @@ class MangaInfo:
| `new_chapter_found` | Найдена новая глава при проверке | | `new_chapter_found` | Найдена новая глава при проверке |
| `check_started` / `check_done` | Начало/конец проверки обновлений | | `check_started` / `check_done` | Начало/конец проверки обновлений |
**`sources`** — источники (определяются в коде приложения):
| Колонка | Тип | Описание |
|---------|-----|---------|
| `slug` | TEXT UNIQUE | Идентификатор источника (`readmanga`) |
| `display_name` | TEXT | Отображаемое название |
| `settings` | TEXT | JSON-настройки источника |
**`source_domains`** — домены, привязанные к источникам:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `source_id` | INTEGER | FK → sources.id |
| `domain` | TEXT UNIQUE | Домен без схемы и www (`readmanga.ru`) |
При первом запуске домены ReadManga автоматически засеиваются из списка `_DEFAULT_READMANGA_DOMAINS` в коде.
**`users`** — пользователи:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `id` | INTEGER PK | |
| `username` | TEXT UNIQUE | Логин |
| `password` | TEXT | Хеш пароля (pbkdf2:iterations:salt:key) |
| `role` | TEXT | `admin` / `user` |
| `is_env_admin` | INTEGER | 1 — системный администратор из `AUTH_LOGIN`/`AUTH_PASSWORD`; его пароль нельзя изменить через интерфейс |
| `created_at` / `updated_at` | TEXT | ISO-8601 timestamp |
**`sessions`** — активные сессии:
| Колонка | Тип | Описание |
|---------|-----|---------|
| `token` | TEXT PK | URL-safe токен (48 байт) |
| `user_id` | INTEGER | FK → users.id ON DELETE CASCADE |
| `created_at` / `expires_at` | TEXT | Сессия действительна 30 дней |
#### Ключевые методы #### Ключевые методы
- `add_manga(url, fmt)``bool` — добавляет, возвращает `False` если уже есть. - `add_manga(url, fmt, source_id, added_by)``bool` — добавляет, возвращает `False` если уже есть.
- `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`). - `upsert_chapter(...)` — INSERT OR UPDATE (по `chapter_url`).
- `chapter_status(chapter_url)``str | None`. - `chapter_status(chapter_url)``str | None`.
- `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters, возвращает число. - `sync_chapters_done(url)` — пересчитывает и сохраняет `chapters_done` из таблицы chapters.
- `get_autos()` — манги с `auto_update=1` не в статусе `downloading`. - `get_autos()` — манги с `auto_update=1` не в статусе `downloading`.
- `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку. - `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку.
- `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки. - `set_folder_name(url, folder_name)` — устанавливает кастомное имя папки.
- `create_user(username, hashed_password, role, is_env_admin)``dict` — создаёт пользователя.
- `get_user_by_id(id)` / `get_user_by_username(username)``dict | None`.
- `get_all_users()``list[dict]` — все пользователи без поля `password`.
- `update_user(user_id, **kwargs)` — обновляет разрешённые поля (`username`, `password`, `role`).
- `delete_user(user_id)` — удаляет пользователя и все его сессии.
- `create_session(token, user_id, expires_at)` — создаёт сессию.
- `get_session(token)``dict | None` — возвращает только не истёкшие сессии.
- `cleanup_expired_sessions()``int` — удаляет истёкшие сессии.
--- ---
@@ -341,7 +409,17 @@ async with sem: # ограничение параллельности
### api.py ### api.py
**Отвечает за:** FastAPI-приложение — HTTP-сервер, очередь загрузок, планировщик обновлений. **Отвечает за:** FastAPI-приложение — HTTP-сервер, аутентификация, очередь загрузок, планировщик обновлений.
#### Auth-зависимости (Depends)
```python
get_current_user(request) # читает токен из cookie, валидирует сессию → dict пользователя
require_admin(user) # get_current_user + проверка role == "admin"
_check_manga_access(manga, user) # admin: полный доступ; user: только свои манги
```
Все endpoint'ы (кроме `/api/login` и `/api/auth/check`) требуют валидной сессии.
#### Глобальное состояние #### Глобальное состояние
@@ -354,14 +432,17 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
#### Вспомогательные функции #### Вспомогательные функции
- `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов). - `_safe_name(s)` — транслитерирует строку в безопасное имя папки (strip спецсимволов, пробелы → `_`, max 80 символов).
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`. Используется везде: `_enrich_manga`, `_manga_detail`, `delete_manga`, `rename_folder`. - `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`.
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions` с актуальным словарём `{url: позиция}`. Вызывается после любого изменения очереди: старт/конец задачи в воркере, `prioritize`, `stop`, `resume`, `add_to_queue`, `force_redownload`. - `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions`. Вызывается при любом изменении очереди.
#### Жизненный цикл при старте (`startup_event`) #### Жизненный цикл при старте (`startup_event`)
1. Запускает `queue_worker()` как фоновую Task. 1. Синхронизирует `sources` из кода реестра с БД (`sync_sources`).
2. Запускает `update_scheduler()` как фоновую Task. 2. Авто-мигрирует `source_id` для манг без него (`migrate_manga_sources`).
3. Восстанавливает из БД незавершённые задачи (status `queued`/`downloading` → снова в очередь). 3. Удаляет истёкшие сессии (`cleanup_expired_sessions`).
4. **Bootstrap-admin:** если таблица `users` пуста — создаёт пользователя из `AUTH_LOGIN`/`AUTH_PASSWORD` с ролью `admin` и флагом `is_env_admin=True`.
5. Запускает `queue_worker()` и `update_scheduler()` как фоновые Task.
6. Восстанавливает незавершённые задачи из БД (status `queued`/`downloading` → снова в очередь).
#### `queue_worker()` #### `queue_worker()`
@@ -373,18 +454,15 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается. - Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него. - Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**. - Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
- `_run_auto_updates()` — сама логика: вызывает `check_for_updates()` для каждой манги с `auto_update=1`, ставит новые главы в очередь.
#### `_enrich_manga(m, db)` #### `_enrich_manga(m, db)`
Вспомогательная функция: обогащает строку из `mangas` реальными данными: Вспомогательная функция: обогащает строку из `mangas` реальными данными:
- `chapters_done``COUNT(*)` из таблицы `chapters` (не стальная колонка). - `chapters_done``COUNT(*)` из таблицы `chapters` (не денормализованная колонка).
- `size_bytes` / `size_human` — размер папки на диске. - `size_bytes` / `size_human` — размер папки на диске.
- `is_active` — есть ли Task в `active_tasks`. - `is_active` — есть ли Task в `active_tasks`.
- `errors_count` — сумма failed и partial глав. - `errors_count` — сумма failed и partial глав.
Используется в `/api/mangas` и в WebSocket snapshot — гарантирует консистентность данных.
--- ---
### cli.py ### cli.py
@@ -429,6 +507,36 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
Базовый URL: `http://localhost:8000` Базовый URL: `http://localhost:8000`
Все endpoint'ы, кроме `/api/login` и `/api/auth/check`, требуют валидной сессии (cookie `manga_session`). Endpoint'ы с пометкой `[admin]` доступны только пользователям с ролью `admin`.
### Аутентификация
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/auth/check` | Проверить текущую сессию. Возвращает `{authenticated, user: {id, username, role, is_env_admin}}` |
| `POST` | `/api/login` | Войти `{login, password}`. Устанавливает cookie сессии на 30 дней |
| `POST` | `/api/logout` | Выйти. Удаляет сессию из БД и cookie |
### Управление пользователями `[admin]`
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/users` | Список всех пользователей (без паролей) |
| `POST` | `/api/users` | Создать пользователя `{username, password, role}` |
| `PATCH` | `/api/users/{user_id}` | Изменить `{username?, password?, role?}`. Для `is_env_admin=1` смена пароля заблокирована. Обычный пользователь может менять только свой пароль |
| `DELETE` | `/api/users/{user_id}` | Удалить пользователя. Системного администратора и последнего admin удалить нельзя |
### Источники
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/sources` | Список источников с доменами и настройками |
| `GET` | `/api/resolve-source?url=` | Определить источник по URL манги |
| `POST` | `/api/sources/{id}/domains` `[admin]` | Добавить домен к источнику `{domain}` |
| `DELETE` | `/api/sources/{id}/domains/{domain}` `[admin]` | Удалить домен у источника |
### Манги
| Метод | Путь | Описание | | Метод | Путь | Описание |
|-------|------|---------| |-------|------|---------|
| `GET` | `/api/mangas` | Список всех манг с реальными счётчиками | | `GET` | `/api/mangas` | Список всех манг с реальными счётчиками |
@@ -436,15 +544,21 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` | | `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
| `POST` | `/api/mangas/stop?url=` | Остановить загрузку | | `POST` | `/api/mangas/stop?url=` | Остановить загрузку |
| `POST` | `/api/mangas/resume?url=` | Возобновить | | `POST` | `/api/mangas/resume?url=` | Возобновить |
| `POST` | `/api/mangas/prioritize?url=` | Переместить в начало очереди (вытесняет текущую) | | `POST` | `/api/mangas/prioritize?url=` `[admin]` | Переместить в начало очереди (вытесняет текущую) |
| `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending | | `POST` | `/api/mangas/retry_errors?url=` | Сбросить failed/partial главы → pending |
| `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление | | `POST` | `/api/mangas/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы | | `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
| `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` | | `POST` | `/api/mangas/update_meta` | Изменить `title_ru`/`title_full` и применить к метаданным файлов `{url, title_ru, title_full}` |
| `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске и обновить пути в БД `{url, folder_name}` | | `POST` | `/api/mangas/rename_folder` | Переименовать папку на диске `{url, folder_name}` |
| `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах | | `POST` | `/api/mangas/refresh_meta?url=` | Обновить метаданные в уже скачанных файлах |
| `POST` | `/api/mangas/force_redownload?url=` | Сбросить все главы и поставить в очередь заново | | `POST` | `/api/mangas/force_redownload?url=` `[admin]` | Сбросить все главы и поставить в очередь заново |
| `DELETE` | `/api/mangas?url=` | Удалить мангу из БД | | `POST` | `/api/mangas/switch-source` `[admin]` | Сменить источник манги `{manga_url, source_id}` |
| `DELETE` | `/api/mangas?url=` `[admin]` | Удалить мангу из БД |
### Прочее
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) | | `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
| `GET` | `/api/history?limit=&manga_url=` | История событий | | `GET` | `/api/history?limit=&manga_url=` | История событий |
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` | | `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
@@ -469,7 +583,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления | | `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем | | `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
| `manga_folder_renamed` | `{url, folder_name}` | Папка переименована | | `manga_folder_renamed` | `{url, folder_name}` | Папка переименована |
| `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — отправляется при любом изменении очереди | | `queue_positions` | `{positions: {url: номер}}` | Актуальные позиции в очереди — при любом изменении очереди |
| `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы | | `chapter_start` | `{url, chapter_url, chapter_number, chapters_done, chapters_total}` | Начало главы |
| `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова | | `chapter_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) | | `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
@@ -479,6 +593,8 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава | | `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления | | `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
| `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены | | `meta_refreshed` | `{url, updated, failed}` | Метаданные файлов обновлены |
| `source_domain_added` | `{source_id, domain}` | Добавлен домен к источнику |
| `source_domain_removed` | `{source_id, domain}` | Домен удалён у источника |
### Клиент → Сервер ### Клиент → Сервер
@@ -490,16 +606,24 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
## 9. Фронтенд ## 9. Фронтенд
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1500 строк). **Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~2350 строк).
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика). **Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
### Экран входа
При загрузке приложение вызывает `GET /api/auth/check`. Если сессия невалидна — показывается экран входа (`#login-screen`). После успешного `POST /api/login` сессия сохраняется в cookie, экран входа скрывается.
`state.currentUser` содержит `{id, username, role, is_env_admin}` — используется для скрытия/показа элементов интерфейса в зависимости от роли.
### Архитектура состояния ### Архитектура состояния
```javascript ```javascript
const state = { const state = {
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления) mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
chapters: {}, // url → массив глав (загружается по запросу в модалке) chapters: {}, // url → массив глав (загружается по запросу в модалке)
currentUser: null, // {id, username, role, is_env_admin}
sources: [], // список источников
}; };
``` ```
@@ -508,24 +632,41 @@ const state = {
``` ```
DOMContentLoaded DOMContentLoaded
├─ loadStats() ──────────────────────► GET /api/stats ├─ checkAuth() ──────────────────────► GET /api/auth/check
│ │ (если ok → initApp())
│ └─ showLoginScreen()
connectWS() ──────────────────────► WS /ws initApp()
├─ loadStats() ──────────────────► GET /api/stats
├─ connectWS() ──────────────────► WS /ws
│ │ │ │
│ └─ snapshot event ──────────► state.mangas = enriched list │ └─ snapshot event ───────► state.mangas = enriched list
│ + live events ──────────► state.mangas[url].* обновляется │ + live events ───────► state.mangas[url].* обновляется
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список └─ fetch('/api/mangas') ──────────► state.mangas = полный список
(перезаписывает snapshot если пришёл раньше)
``` ```
**Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot теперь тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`. **Важный нюанс порядка:** `connectWS()` не awaited, поэтому snapshot может прийти после `fetch('/api/mangas')` и перезаписать правильные значения устаревшими из БД. Именно поэтому WS snapshot тоже использует `_enrich_manga()` с пересчётом из таблицы `chapters`.
### Вкладки ### Вкладки
- **Манга** — список всех манг, добавление, управление. - **Манга** — список всех манг, добавление, управление.
- **Новости** — события `downloaded`/`auto_downloaded` (что скачалось). - **Новости** — события `downloaded`/`auto_downloaded` (что скачалось).
- **История** — все события из таблицы `history`. - **История** — все события из таблицы `history`.
- **Настройки** — управление источниками, пользователями (только admin), смена своего пароля.
### Вкладка «Настройки»
При открытии загружает:
- Список источников (`GET /api/sources`) с управлением доменами (только admin).
- Список пользователей (`GET /api/users`, только admin) — в разделе **Пользователи**.
- Раздел **Сменить пароль** — скрыт для системного администратора (`is_env_admin=true`).
**Управление пользователями (только admin):**
- Создание: кнопка «+ Добавить» → модалка с логином, паролем, ролью.
- Редактирование: кнопка ✏️ → модалка. Для системного администратора (`is_env_admin`) поле пароля скрыто.
- Удаление: кнопка ✕ (недоступна для системного администратора и для самого себя).
- Системный администратор помечен иконкой 🔒 с тултипом.
### Модальное окно детали ### Модальное окно детали
@@ -533,7 +674,7 @@ DOMContentLoaded
- **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`. - **✏️ Редактировать название** — открывает модалку изменения `title_ru` / `title_full`. Папка не переименовывается, но метаданные всех файлов обновляются автоматически через `_do_refresh_meta`.
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`. - **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`). - **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
- **↺ Скачать заново** — сбрасывает все главы и ставит в очередь повторно. - **↺ Скачать заново** `[admin]` — сбрасывает все главы и ставит в очередь повторно.
### Карточки манги (кнопки) ### Карточки манги (кнопки)
@@ -544,7 +685,7 @@ DOMContentLoaded
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку | | ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
| ▶ | `status` = `stopped` или `failed` | Возобновить | | ▶ | `status` = `stopped` или `failed` | Возобновить |
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди | | 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
| ✕ | всегда | Удалить | | ✕ `[admin]` | всегда | Удалить |
### Позиции в очереди ### Позиции в очереди
@@ -558,10 +699,11 @@ DOMContentLoaded
| Переменная | Default | Описание | | Переменная | Default | Описание |
|------------|---------|---------| |------------|---------|---------|
| `AUTH_LOGIN` | — | Логин системного администратора. Создаётся при первом старте, если таблица `users` пуста |
| `AUTH_PASSWORD` | — | Пароль системного администратора. Для смены — изменить переменную и пересоздать контейнер |
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно | | `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён | | `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически | | `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса. Если оба заданы — включается авторизация |
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) | | `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
### Пути (hardcoded в коде) ### Пути (hardcoded в коде)
@@ -602,7 +744,7 @@ ports:
shm_size: "2gb" # Chromium требует shared memory shm_size: "2gb" # Chromium требует shared memory
environment: environment:
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron) - UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
- AUTH_LOGIN=... - AUTH_LOGIN=... # системный администратор
- AUTH_PASSWORD=... - AUTH_PASSWORD=...
restart: unless-stopped # Автоперезапуск при падении restart: unless-stopped # Автоперезапуск при падении
@@ -624,7 +766,7 @@ docker compose run --rm --entrypoint "" manga \
После остановки контейнера все данные сохраняются на хосте: После остановки контейнера все данные сохраняются на хосте:
- `./output/` — скачанные файлы. - `./output/` — скачанные файлы.
- `./state/progress.db` — состояние БД (что скачано, что в очереди). - `./state/progress.db` — состояние БД (что скачано, что в очереди, пользователи, сессии).
- `./state/manga.log` — логи. - `./state/manga.log` — логи.
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь. При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.

View File

@@ -30,7 +30,7 @@ docker compose build
docker compose up -d docker compose up -d
``` ```
Откройте **http://localhost:8000** — вставьте URL манги, выберите формат, нажмите «Добавить». Откройте **http://localhost:8000**войдите под учётными данными из `docker-compose.yml`, вставьте URL манги, выберите формат, нажмите «Добавить».
--- ---
@@ -103,16 +103,40 @@ output/
--- ---
## Авторизация ## Авторизация и пользователи
Задайте в `docker-compose.yml`: Приложение использует многопользовательскую систему с ролями. Доступ к веб-интерфейсу защищён формой входа.
### Системный администратор (bootstrap)
При первом запуске приложение создаёт администратора из переменных окружения:
```yaml ```yaml
- AUTH_LOGIN=ваш_логин - AUTH_LOGIN=ваш_логин
- AUTH_PASSWORD=ваш_пароль - AUTH_PASSWORD=ваш_пароль
``` ```
Если оба параметра заданы — интерфейс будет защищён формой входа. Сессия сохраняется в браузере на 30 дней, повторный вход не требуется. Этот пользователь является **системным администратором** (`is_env_admin`):
- Помечен иконкой 🔒 в списке пользователей
- **Пароль нельзя изменить через интерфейс** — только через `AUTH_PASSWORD` в `docker-compose.yml`
- Нельзя удалить
Сессия сохраняется в браузере на 30 дней, повторный вход не требуется.
### Управление пользователями
Администратор может управлять пользователями через вкладку **⚙️ Настройки** → раздел **Пользователи**:
- **Создать** пользователя с указанием логина, пароля и роли
- **Изменить** роль или пароль существующего пользователя
- **Удалить** пользователя (кроме системного администратора и самого себя)
### Роли
| Роль | Описание |
|------|---------|
| `admin` | Полный доступ: управление пользователями, удаление и принудительная перезагрузка манг, управление источниками, приоритизация очереди |
| `user` | Может добавлять мангу, управлять только своими загрузками |
--- ---
@@ -120,10 +144,11 @@ output/
| Переменная | Default | Описание | | Переменная | Default | Описание |
|------------|---------|---------| |------------|---------|---------|
| `AUTH_LOGIN` | — | Логин системного администратора (создаётся при первом старте) |
| `AUTH_PASSWORD` | — | Пароль системного администратора |
| `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно | | `CHAPTER_CONCURRENCY` | `3` | Глав загружается параллельно |
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено | | `UPDATE_SCHEDULE` | — | Расписание авто-проверки новых глав (cron-синтаксис). Если пусто — отключено |
| `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) | | `UPDATE_INTERVAL_HOURS` | — | Устаревший формат: число часов (конвертируется в cron автоматически) |
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса |
### Примеры расписания (`UPDATE_SCHEDULE`) ### Примеры расписания (`UPDATE_SCHEDULE`)
@@ -183,5 +208,3 @@ output/
- Физическая папка на диске будет переименована. - Физическая папка на диске будет переименована.
- Пути ко всем уже скачанным файлам обновятся в БД. - Пути ко всем уже скачанным файлам обновятся в БД.
- Дозагрузка новых глав продолжится в переименованную папку. - Дозагрузка новых глав продолжится в переименованную папку.

View File

@@ -23,3 +23,11 @@ services:
# Веб-интерфейс: http://localhost:8000 # Веб-интерфейс: http://localhost:8000
# CLI-команды: # CLI-команды:
# docker compose run --rm --entrypoint "" manga python -m src.cli download <URL> --format cbz # docker compose run --rm --entrypoint "" manga python -m src.cli download <URL> --format cbz
networks:
default:
driver: bridge
ipam:
driver: default
config:
- subnet: 10.33.1.0/24

View File

@@ -211,7 +211,7 @@
<div id="users-list" class="flex flex-col gap-2"></div> <div id="users-list" class="flex flex-col gap-2"></div>
</div> </div>
<!-- Смена своего пароля --> <!-- Смена своего пароля -->
<div class="px-5 py-4 border-t border-gray-800"> <div id="chpwd-section" class="px-5 py-4 border-t border-gray-800">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3> <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
<div class="flex flex-col gap-2 max-w-sm"> <div class="flex flex-col gap-2 max-w-sm">
<input id="chpwd-new" type="password" placeholder="Новый пароль" <input id="chpwd-new" type="password" placeholder="Новый пароль"
@@ -234,7 +234,7 @@
<label class="text-xs text-gray-400 mb-1 block">Логин</label> <label class="text-xs text-gray-400 mb-1 block">Логин</label>
<input id="user-modal-username" type="text" class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none" style="background:#0f1117" placeholder="username"> <input id="user-modal-username" type="text" class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none" style="background:#0f1117" placeholder="username">
</div> </div>
<div> <div id="user-modal-pwd-wrap">
<label class="text-xs text-gray-400 mb-1 block">Пароль <span id="user-modal-pwd-hint" class="text-gray-600">(оставьте пустым чтобы не менять)</span></label> <label class="text-xs text-gray-400 mb-1 block">Пароль <span id="user-modal-pwd-hint" class="text-gray-600">(оставьте пустым чтобы не менять)</span></label>
<input id="user-modal-password" type="password" class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none" style="background:#0f1117" placeholder="••••••"> <input id="user-modal-password" type="password" class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none" style="background:#0f1117" placeholder="••••••">
</div> </div>
@@ -391,13 +391,14 @@ function showLoginScreen() {
function hideLoginScreen() { function hideLoginScreen() {
document.getElementById('login-screen').classList.add('hidden'); document.getElementById('login-screen').classList.add('hidden');
document.getElementById('logout-btn').classList.remove('hidden'); document.getElementById('logout-btn').classList.remove('hidden');
// Обновляем отображение текущего пользователя в хедере
const uinfo = document.getElementById('user-info'); const uinfo = document.getElementById('user-info');
if(uinfo && state.currentUser) { if(uinfo && state.currentUser) {
const roleLabel = state.currentUser.role === 'admin' ? '👑' : '👤'; const roleLabel = state.currentUser.role === 'admin' ? '👑' : '👤';
uinfo.textContent = `${roleLabel} ${state.currentUser.username}`; uinfo.textContent = `${roleLabel} ${state.currentUser.username}`;
uinfo.classList.remove('hidden'); uinfo.classList.remove('hidden');
} }
const chpwd = document.getElementById('chpwd-section');
if(chpwd) chpwd.classList.toggle('hidden', !!(state.currentUser && state.currentUser.is_env_admin));
} }
async function checkAuth() { async function checkAuth() {
@@ -766,7 +767,7 @@ function switchTab(tab) {
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas'); document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
if(tab === 'history') loadHistory(); if(tab === 'history') loadHistory();
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); } if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
if(tab === 'settings') loadSources(); if(tab === 'settings') { loadSources(); showUsersSection(); }
} }
function updateNewsBadge() { function updateNewsBadge() {
@@ -1110,11 +1111,12 @@ function renderUsers(users) {
<span class="text-xs px-2 py-0.5 rounded-full font-semibold" style="${roleColors[u.role] || ''}"> <span class="text-xs px-2 py-0.5 rounded-full font-semibold" style="${roleColors[u.role] || ''}">
${u.role === 'admin' ? '👑 admin' : '👤 user'} ${u.role === 'admin' ? '👑 admin' : '👤 user'}
</span> </span>
${u.is_env_admin ? '<span class="text-xs text-gray-500" title="Системный администратор — пароль задаётся через AUTH_PASSWORD">🔒</span>' : ''}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', '${u.role}')" <button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', '${u.role}', ${!!u.is_env_admin})"
class="text-xs px-2 py-1 rounded text-gray-400 hover:text-white" style="background:#334155">✏️</button> class="text-xs px-2 py-1 rounded text-gray-400 hover:text-white" style="background:#334155">✏️</button>
${u.id !== state.currentUser?.id ? `<button onclick="confirmDeleteUser(${u.id}, '${escHtml(u.username)}')" ${!u.is_env_admin && u.id !== state.currentUser?.id ? `<button onclick="confirmDeleteUser(${u.id}, '${escHtml(u.username)}')"
class="text-xs px-2 py-1 rounded text-red-400 hover:text-red-300" style="background:#2d1111">✕</button>` : ''} class="text-xs px-2 py-1 rounded text-red-400 hover:text-red-300" style="background:#2d1111">✕</button>` : ''}
</div> </div>
</div> </div>
@@ -1127,6 +1129,8 @@ function openAddUserModal() {
document.getElementById('user-modal-username').value = ''; document.getElementById('user-modal-username').value = '';
document.getElementById('user-modal-username').disabled = false; document.getElementById('user-modal-username').disabled = false;
document.getElementById('user-modal-password').value = ''; document.getElementById('user-modal-password').value = '';
const pwdWrap = document.getElementById('user-modal-pwd-wrap');
if(pwdWrap) pwdWrap.style.display = '';
document.getElementById('user-modal-pwd-hint').style.display = 'none'; document.getElementById('user-modal-pwd-hint').style.display = 'none';
document.getElementById('user-modal-role').value = 'user'; document.getElementById('user-modal-role').value = 'user';
document.getElementById('user-modal-error').classList.add('hidden'); document.getElementById('user-modal-error').classList.add('hidden');
@@ -1134,13 +1138,19 @@ function openAddUserModal() {
document.getElementById('user-modal').classList.remove('hidden'); document.getElementById('user-modal').classList.remove('hidden');
} }
function openEditUserModal(id, username, role) { function openEditUserModal(id, username, role, isEnvAdmin) {
_userModalEditId = id; _userModalEditId = id;
document.getElementById('user-modal-title').textContent = 'Редактировать пользователя'; document.getElementById('user-modal-title').textContent = 'Редактировать пользователя';
document.getElementById('user-modal-username').value = username; document.getElementById('user-modal-username').value = username;
document.getElementById('user-modal-username').disabled = false; document.getElementById('user-modal-username').disabled = false;
document.getElementById('user-modal-password').value = ''; document.getElementById('user-modal-password').value = '';
const pwdWrap = document.getElementById('user-modal-pwd-wrap');
if(isEnvAdmin) {
if(pwdWrap) pwdWrap.style.display = 'none';
} else {
if(pwdWrap) pwdWrap.style.display = '';
document.getElementById('user-modal-pwd-hint').style.display = ''; document.getElementById('user-modal-pwd-hint').style.display = '';
}
document.getElementById('user-modal-role').value = role; document.getElementById('user-modal-role').value = role;
document.getElementById('user-modal-error').classList.add('hidden'); document.getElementById('user-modal-error').classList.add('hidden');
document.getElementById('user-modal').style.display = 'flex'; document.getElementById('user-modal').style.display = 'flex';

View File

@@ -141,7 +141,7 @@ async def startup_event():
if not _db.get_all_users(): if not _db.get_all_users():
admin_login = os.getenv("AUTH_LOGIN", "admin") admin_login = os.getenv("AUTH_LOGIN", "admin")
admin_password = os.getenv("AUTH_PASSWORD", "admin") admin_password = os.getenv("AUTH_PASSWORD", "admin")
_db.create_user(admin_login, hash_password(admin_password), "admin") _db.create_user(admin_login, hash_password(admin_password), "admin", is_env_admin=True)
logger.info("Создан начальный администратор: '{}'", admin_login) logger.info("Создан начальный администратор: '{}'", admin_login)
if admin_login == "admin" and admin_password == "admin": if admin_login == "admin" and admin_password == "admin":
logger.warning( logger.warning(
@@ -353,7 +353,8 @@ async def auth_check(request: Request):
return {"authenticated": False, "auth_enabled": True} return {"authenticated": False, "auth_enabled": True}
return { return {
"authenticated": True, "auth_enabled": True, "authenticated": True, "auth_enabled": True,
"user": {"id": user["id"], "username": user["username"], "role": user["role"]}, "user": {"id": user["id"], "username": user["username"], "role": user["role"],
"is_env_admin": bool(user.get("is_env_admin"))},
} }
finally: finally:
db.close() db.close()
@@ -369,7 +370,8 @@ async def login(body: LoginRequest, response: Response):
db.create_session(token, user["id"], expires_at) db.create_session(token, user["id"], expires_at)
response.set_cookie(key=COOKIE_NAME, value=token, max_age=COOKIE_MAX_AGE, response.set_cookie(key=COOKIE_NAME, value=token, max_age=COOKIE_MAX_AGE,
httponly=True, samesite="lax", secure=False) httponly=True, samesite="lax", secure=False)
return {"ok": True, "user": {"id": user["id"], "username": user["username"], "role": user["role"]}} return {"ok": True, "user": {"id": user["id"], "username": user["username"], "role": user["role"],
"is_env_admin": bool(user.get("is_env_admin"))}}
finally: finally:
db.close() db.close()
@app.post("/api/logout") @app.post("/api/logout")
@@ -428,6 +430,9 @@ async def update_user_endpoint(user_id: int, body: UpdateUserRequest,
user = db.get_user_by_id(user_id) user = db.get_user_by_id(user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден") raise HTTPException(status_code=404, detail="Пользователь не найден")
if user.get("is_env_admin") and body.password is not None:
raise HTTPException(status_code=403,
detail="Пароль системного администратора нельзя изменить через интерфейс — используйте переменную окружения AUTH_PASSWORD")
if body.role and body.role not in ("admin", "user"): if body.role and body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Роль должна быть 'admin' или 'user'") raise HTTPException(status_code=400, detail="Роль должна быть 'admin' или 'user'")
if body.role == "user" and user["role"] == "admin": if body.role == "user" and user["role"] == "admin":
@@ -459,6 +464,8 @@ async def delete_user_endpoint(user_id: int, current_user: dict = Depends(requir
user = db.get_user_by_id(user_id) user = db.get_user_by_id(user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден") raise HTTPException(status_code=404, detail="Пользователь не найден")
if user.get("is_env_admin"):
raise HTTPException(status_code=403, detail="Системного администратора нельзя удалить")
if user["role"] == "admin" and db.count_admins() <= 1: if user["role"] == "admin" and db.count_admins() <= 1:
raise HTTPException(status_code=400, detail="Нельзя удалить последнего администратора") raise HTTPException(status_code=400, detail="Нельзя удалить последнего администратора")
db.delete_user(user_id) db.delete_user(user_id)

View File

@@ -101,6 +101,7 @@ class StateDB:
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
is_env_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT, created_at TEXT,
updated_at TEXT updated_at TEXT
) )
@@ -127,6 +128,7 @@ class StateDB:
("mangas", "folder_name", "TEXT"), ("mangas", "folder_name", "TEXT"),
("mangas", "source_id", "INTEGER REFERENCES sources(id)"), ("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
("mangas", "added_by", "INTEGER REFERENCES users(id)"), ("mangas", "added_by", "INTEGER REFERENCES users(id)"),
("users", "is_env_admin", "INTEGER NOT NULL DEFAULT 0"),
] ]
for table, col, typedef in migrations: for table, col, typedef in migrations:
try: try:
@@ -536,15 +538,17 @@ class StateDB:
# ── Users ───────────────────────────────────── # ── Users ─────────────────────────────────────
def create_user(self, username: str, hashed_password: str, role: str = "user") -> dict: def create_user(self, username: str, hashed_password: str, role: str = "user",
is_env_admin: bool = False) -> dict:
"""Создаёт пользователя. Возвращает dict без поля password.""" """Создаёт пользователя. Возвращает dict без поля password."""
self.conn.execute(""" self.conn.execute("""
INSERT INTO users (username, password, role, created_at, updated_at) INSERT INTO users (username, password, role, is_env_admin, created_at, updated_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", (username, hashed_password, role, _now(), _now())) """, (username, hashed_password, role, 1 if is_env_admin else 0, _now(), _now()))
self.conn.commit() self.conn.commit()
row = self.conn.execute( row = self.conn.execute(
"SELECT id, username, role, created_at FROM users WHERE username=?", (username,) "SELECT id, username, role, is_env_admin, created_at FROM users WHERE username=?",
(username,)
).fetchone() ).fetchone()
return dict(row) return dict(row)
@@ -561,7 +565,7 @@ class StateDB:
def get_all_users(self) -> list[dict]: def get_all_users(self) -> list[dict]:
"""Возвращает всех пользователей без поля password.""" """Возвращает всех пользователей без поля password."""
cur = self.conn.execute( cur = self.conn.execute(
"SELECT id, username, role, created_at, updated_at FROM users ORDER BY id" "SELECT id, username, role, is_env_admin, created_at, updated_at FROM users ORDER BY id"
) )
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]