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-стек-технологий)
4. [Схема архитектуры](#4-схема-архитектуры)
5. [Модули бэкенда](#5-модули-бэкенда)
- [auth.py](#authpy)
- [browser.py](#browserpy)
- [scraper.py](#scraperpy)
- [exporter.py](#exporterpy)
@@ -29,7 +30,7 @@
Приложение скачивает мангу с сайтов типа 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`.
---
@@ -41,6 +42,7 @@ manga/
├── src/ # Весь бэкенд-код (Python-пакет)
│ ├── __init__.py
│ ├── api.py # FastAPI-приложение, REST + WebSocket
│ ├── auth.py # Хеширование паролей, генерация токенов сессий
│ ├── browser.py # Обёртка над Playwright/Chromium
│ ├── cli.py # CLI-команды (click)
│ ├── downloader.py # (legacy, не используется в web-режиме)
@@ -100,6 +102,7 @@ manga/
┌─────────────────────────────────┐
│ FastAPI (api.py) │
│ │
│ Auth middleware (cookie/session)│
│ REST endpoints WebSocket /ws │
│ │ │ │
│ asyncio.Queue ws_manager │
@@ -129,6 +132,25 @@ manga/
## 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
**Отвечает за:** запуск и управление Playwright Chromium.
@@ -248,9 +270,11 @@ class MangaInfo:
| `status` | TEXT | `queued` / `downloading` / `done` / `failed` / `stopped` |
| `format` | TEXT | `cbz` / `pdf` / `epub` / `all` |
| `chapters_total` | INTEGER | Кол-во глав (из scraper) |
| `chapters_done` | INTEGER | **Стальной счётчик** — не используется в API напрямую¹ |
| `chapters_done` | INTEGER | Денормализованный счётчик¹ |
| `last_checked_at` | TEXT | Время последней проверки новых глав |
| `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'`.
@@ -275,15 +299,59 @@ class MangaInfo:
| `new_chapter_found` | Найдена новая глава при проверке |
| `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`).
- `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`.
- `update_manga_meta_fields(url, title_ru, title_full)` — обновляет пользовательские метаданные (название), не меняет папку.
- `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
**Отвечает за:** 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 символов).
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`. Используется везде: `_enrich_manga`, `_manga_detail`, `delete_manga`, `rename_folder`.
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions` с актуальным словарём `{url: позиция}`. Вызывается после любого изменения очереди: старт/конец задачи в воркере, `prioritize`, `stop`, `resume`, `add_to_queue`, `force_redownload`.
- `_manga_folder(m)` — возвращает `Path` к папке манги: если `m["folder_name"]` задан — использует его, иначе вычисляет из `title` через `_safe_name()`.
- `_broadcast_queue_positions()` — отправляет всем WS-клиентам событие `queue_positions`. Вызывается при любом изменении очереди.
#### Жизненный цикл при старте (`startup_event`)
1. Запускает `queue_worker()` как фоновую Task.
2. Запускает `update_scheduler()` как фоновую Task.
3. Восстанавливает из БД незавершённые задачи (status `queued`/`downloading` → снова в очередь).
1. Синхронизирует `sources` из кода реестра с БД (`sync_sources`).
2. Авто-мигрирует `source_id` для манг без него (`migrate_manga_sources`).
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()`
@@ -373,18 +454,15 @@ ws_manager: ConnectionManager # set активных WebSocket-соедине
- Читает расписание через `_parse_schedule()`: приоритет `UPDATE_SCHEDULE` (cron-строка) → `UPDATE_INTERVAL_HOURS` (legacy, число часов → конвертируется в cron автоматически). Если обе переменные пусты — планировщик не запускается.
- Вычисляет время до следующего слота через `croniter.get_next()` и спит ровно до него.
- Запускает `_run_auto_updates_with_retry()` — обёртка с **тремя попытками**: при ошибке ждёт 5 минут и повторяет; после трёх неудач логирует и ждёт следующего слота. Цикл **никогда не прерывается**.
- `_run_auto_updates()` — сама логика: вызывает `check_for_updates()` для каждой манги с `auto_update=1`, ставит новые главы в очередь.
#### `_enrich_manga(m, db)`
Вспомогательная функция: обогащает строку из `mangas` реальными данными:
- `chapters_done``COUNT(*)` из таблицы `chapters` (не стальная колонка).
- `chapters_done``COUNT(*)` из таблицы `chapters` (не денормализованная колонка).
- `size_bytes` / `size_human` — размер папки на диске.
- `is_active` — есть ли Task в `active_tasks`.
- `errors_count` — сумма failed и partial глав.
Используется в `/api/mangas` и в WebSocket snapshot — гарантирует консистентность данных.
---
### cli.py
@@ -429,6 +507,36 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
Базовый 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` | Список всех манг с реальными счётчиками |
@@ -436,15 +544,21 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `POST` | `/api/queue` | Добавить мангу(и) в очередь `{urls: [...], format: "cbz"}` |
| `POST` | `/api/mangas/stop?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/auto_update?url=&enabled=` | Вкл/выкл авто-обновление |
| `POST` | `/api/mangas/check_now?url=` | Немедленно проверить новые главы |
| `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/force_redownload?url=` | Сбросить все главы и поставить в очередь заново |
| `DELETE` | `/api/mangas?url=` | Удалить мангу из БД |
| `POST` | `/api/mangas/force_redownload?url=` `[admin]` | Сбросить все главы и поставить в очередь заново |
| `POST` | `/api/mangas/switch-source` `[admin]` | Сменить источник манги `{manga_url, source_id}` |
| `DELETE` | `/api/mangas?url=` `[admin]` | Удалить мангу из БД |
### Прочее
| Метод | Путь | Описание |
|-------|------|---------|
| `GET` | `/api/stats` | Глобальная статистика (кол-во по статусам, размер) |
| `GET` | `/api/history?limit=&manga_url=` | История событий |
| `GET` | `/api/news?limit=` | Только события `downloaded`/`auto_downloaded` |
@@ -469,7 +583,7 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `manga_preview` | `{url, title, chapters_total, ...}` | Быстрый предпросмотр после добавления |
| `manga_meta_updated` | `{url, title, title_ru, title_full}` | Метаданные отредактированы пользователем |
| `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_done` | `{url, chapter_url, chapters_done, chapters_total}` | Глава готова |
| `chapter_skipped` | `{url, chapter_url, chapters_done}` | Глава пропущена (уже скачана) |
@@ -479,6 +593,8 @@ CLI использует те же `BrowserManager`, `scraper`, `exporter`, `Sta
| `new_chapter_found` | `{url, chapter_url, chapter_number}` | Найдена новая глава |
| `auto_update_changed` | `{url, auto_update}` | Изменён флаг авто-обновления |
| `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. Фронтенд
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~1500 строк).
**Файл:** `frontend/index.html` — весь фронтенд в одном HTML-файле (~2350 строк).
**Стек:** Tailwind CSS (CDN), Vanilla JS (без фреймворков и сборщика).
### Экран входа
При загрузке приложение вызывает `GET /api/auth/check`. Если сессия невалидна — показывается экран входа (`#login-screen`). После успешного `POST /api/login` сессия сохраняется в cookie, экран входа скрывается.
`state.currentUser` содержит `{id, username, role, is_env_admin}` — используется для скрытия/показа элементов интерфейса в зависимости от роли.
### Архитектура состояния
```javascript
const state = {
mangas: {}, // url → объект манги (из snapshot/API + WS-обновления)
chapters: {}, // url → массив глав (загружается по запросу в модалке)
currentUser: null, // {id, username, role, is_env_admin}
sources: [], // список источников
};
```
@@ -508,24 +632,41 @@ const state = {
```
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
│ + live events ──────────► state.mangas[url].* обновляется
│ └─ snapshot event ───────► state.mangas = enriched list
│ + live events ───────► state.mangas[url].* обновляется
└─ fetch('/api/mangas') ─────────────► state.mangas = полный список
(перезаписывает 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` (что скачалось).
- **История** — все события из таблицы `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`.
- **📁 Переименовать папку** — открывает модалку смены имени папки на диске (доступна при статусе не `downloading`). Физически переименовывает папку, обновляет пути в `chapters.output_*`, сохраняет `folder_name` в `mangas`.
- **🏷 Обновить метатеги** — принудительно обновляет метаданные в уже скачанных файлах (для манги в статусе `done`).
- **↺ Скачать заново** — сбрасывает все главы и ставит в очередь повторно.
- **↺ Скачать заново** `[admin]` — сбрасывает все главы и ставит в очередь повторно.
### Карточки манги (кнопки)
@@ -544,7 +685,7 @@ DOMContentLoaded
| ⏸ | `status` = `downloading` или `queued` | Остановить загрузку |
| ▶ | `status` = `stopped` или `failed` | Возобновить |
| 🚀 | `status` = `queued` (только в очереди, не активная) | Переместить в начало очереди |
| ✕ | всегда | Удалить |
| ✕ `[admin]` | всегда | Удалить |
### Позиции в очереди
@@ -558,10 +699,11 @@ DOMContentLoaded
| Переменная | Default | Описание |
|------------|---------|---------|
| `AUTH_LOGIN` | — | Логин системного администратора. Создаётся при первом старте, если таблица `users` пуста |
| `AUTH_PASSWORD` | — | Пароль системного администратора. Для смены — изменить переменную и пересоздать контейнер |
| `CHAPTER_CONCURRENCY` | `3` | Кол-во глав, загружаемых параллельно |
| `UPDATE_SCHEDULE` | — | Расписание авто-проверки (cron-синтаксис). Пример: `0 */6 * * *`. Если пусто — планировщик отключён |
| `UPDATE_INTERVAL_HOURS` | — | Устаревший аналог `UPDATE_SCHEDULE`: число часов → конвертируется в cron автоматически |
| `AUTH_LOGIN` / `AUTH_PASSWORD` | — | Логин и пароль для веб-интерфейса. Если оба заданы — включается авторизация |
| `PYTHONUNBUFFERED` | `1` | Немедленный вывод логов (Docker) |
### Пути (hardcoded в коде)
@@ -602,7 +744,7 @@ ports:
shm_size: "2gb" # Chromium требует shared memory
environment:
- UPDATE_SCHEDULE=0 */6 * * * # каждые 6 часов (cron)
- AUTH_LOGIN=...
- AUTH_LOGIN=... # системный администратор
- AUTH_PASSWORD=...
restart: unless-stopped # Автоперезапуск при падении
@@ -624,7 +766,7 @@ docker compose run --rm --entrypoint "" manga \
После остановки контейнера все данные сохраняются на хосте:
- `./output/` — скачанные файлы.
- `./state/progress.db` — состояние БД (что скачано, что в очереди).
- `./state/progress.db` — состояние БД (что скачано, что в очереди, пользователи, сессии).
- `./state/manga.log` — логи.
При следующем запуске `startup_event` восстанавливает незавершённые задачи из БД в очередь.

View File

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

View File

@@ -23,3 +23,11 @@ services:
# Веб-интерфейс: http://localhost:8000
# CLI-команды:
# 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>
<!-- Смена своего пароля -->
<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>
<div class="flex flex-col gap-2 max-w-sm">
<input id="chpwd-new" type="password" placeholder="Новый пароль"
@@ -234,7 +234,7 @@
<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">
</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>
<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>
@@ -391,13 +391,14 @@ function showLoginScreen() {
function hideLoginScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('logout-btn').classList.remove('hidden');
// Обновляем отображение текущего пользователя в хедере
const uinfo = document.getElementById('user-info');
if(uinfo && state.currentUser) {
const roleLabel = state.currentUser.role === 'admin' ? '👑' : '👤';
uinfo.textContent = `${roleLabel} ${state.currentUser.username}`;
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() {
@@ -766,7 +767,7 @@ function switchTab(tab) {
document.getElementById('manga-filters').classList.toggle('hidden', tab !== 'mangas');
if(tab === 'history') loadHistory();
if(tab === 'news') { newsUnreadCount = 0; updateNewsBadge(); loadNews(); }
if(tab === 'settings') loadSources();
if(tab === 'settings') { loadSources(); showUsersSection(); }
}
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] || ''}">
${u.role === 'admin' ? '👑 admin' : '👤 user'}
</span>
${u.is_env_admin ? '<span class="text-xs text-gray-500" title="Системный администратор — пароль задаётся через AUTH_PASSWORD">🔒</span>' : ''}
</div>
<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>
${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>` : ''}
</div>
</div>
@@ -1127,6 +1129,8 @@ function openAddUserModal() {
document.getElementById('user-modal-username').value = '';
document.getElementById('user-modal-username').disabled = false;
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-role').value = 'user';
document.getElementById('user-modal-error').classList.add('hidden');
@@ -1134,13 +1138,19 @@ function openAddUserModal() {
document.getElementById('user-modal').classList.remove('hidden');
}
function openEditUserModal(id, username, role) {
function openEditUserModal(id, username, role, isEnvAdmin) {
_userModalEditId = id;
document.getElementById('user-modal-title').textContent = 'Редактировать пользователя';
document.getElementById('user-modal-username').value = username;
document.getElementById('user-modal-username').disabled = false;
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-role').value = role;
document.getElementById('user-modal-error').classList.add('hidden');
document.getElementById('user-modal').style.display = 'flex';

View File

@@ -141,7 +141,7 @@ async def startup_event():
if not _db.get_all_users():
admin_login = os.getenv("AUTH_LOGIN", "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)
if admin_login == "admin" and admin_password == "admin":
logger.warning(
@@ -353,7 +353,8 @@ async def auth_check(request: Request):
return {"authenticated": False, "auth_enabled": True}
return {
"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:
db.close()
@@ -369,7 +370,8 @@ async def login(body: LoginRequest, response: Response):
db.create_session(token, user["id"], expires_at)
response.set_cookie(key=COOKIE_NAME, value=token, max_age=COOKIE_MAX_AGE,
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:
db.close()
@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)
if not user:
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"):
raise HTTPException(status_code=400, detail="Роль должна быть 'admin' или 'user'")
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)
if not user:
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:
raise HTTPException(status_code=400, detail="Нельзя удалить последнего администратора")
db.delete_user(user_id)

View File

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