upd
This commit is contained in:
214
ARCHITECTURE.md
214
ARCHITECTURE.md
@@ -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
|
||||||
│ └─ snapshot event ──────────► state.mangas = enriched list
|
├─ connectWS() ──────────────────► WS /ws
|
||||||
│ + live events ──────────► state.mangas[url].* обновляется
|
│ │
|
||||||
│
|
│ └─ snapshot event ───────► state.mangas = enriched list
|
||||||
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список
|
│ + live events ───────► state.mangas[url].* обновляется
|
||||||
(перезаписывает snapshot если пришёл раньше)
|
│
|
||||||
|
└─ fetch('/api/mangas') ──────────► state.mangas = полный список
|
||||||
```
|
```
|
||||||
|
|
||||||
**Важный нюанс порядка:** `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` восстанавливает незавершённые задачи из БД в очередь.
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -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/
|
|||||||
- Физическая папка на диске будет переименована.
|
- Физическая папка на диске будет переименована.
|
||||||
- Пути ко всем уже скачанным файлам обновятся в БД.
|
- Пути ко всем уже скачанным файлам обновятся в БД.
|
||||||
- Дозагрузка новых глав продолжится в переименованную папку.
|
- Дозагрузка новых глав продолжится в переименованную папку.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
document.getElementById('user-modal-pwd-hint').style.display = '';
|
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-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';
|
||||||
|
|||||||
13
src/api.py
13
src/api.py
@@ -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)
|
||||||
|
|||||||
28
src/state.py
28
src/state.py
@@ -97,12 +97,13 @@ class StateDB:
|
|||||||
""")
|
""")
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
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',
|
||||||
created_at TEXT,
|
is_env_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
updated_at TEXT
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
@@ -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()]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user