diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c3abcb6..f1a0d5d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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::`. +- `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 → массив глав (загружается по запросу в модалке) + 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 - │ │ - │ └─ snapshot event ──────────► state.mangas = enriched list - │ + live events ──────────► state.mangas[url].* обновляется - │ - └─ fetch('/api/mangas') ─────────────► state.mangas = полный список - (перезаписывает snapshot если пришёл раньше) + └─ initApp() + ├─ loadStats() ──────────────────► GET /api/stats + ├─ connectWS() ──────────────────► WS /ws + │ │ + │ └─ snapshot event ───────► state.mangas = enriched list + │ + live events ───────► state.mangas[url].* обновляется + │ + └─ 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` восстанавливает незавершённые задачи из БД в очередь. diff --git a/README.md b/README.md index f853092..669f0c8 100644 --- a/README.md +++ b/README.md @@ -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/ - Физическая папка на диске будет переименована. - Пути ко всем уже скачанным файлам обновятся в БД. - Дозагрузка новых глав продолжится в переименованную папку. - - diff --git a/docker-compose.yml b/docker-compose.yml index 7b5a448..da3d5f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,3 +23,11 @@ services: # Веб-интерфейс: http://localhost:8000 # CLI-команды: # docker compose run --rm --entrypoint "" manga python -m src.cli download --format cbz + +networks: + default: + driver: bridge + ipam: + driver: default + config: + - subnet: 10.33.1.0/24 diff --git a/frontend/index.html b/frontend/index.html index d2f6e3e..81a7566 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -211,7 +211,7 @@
-
+

Сменить пароль

Логин
-
+
@@ -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) { ${u.role === 'admin' ? '👑 admin' : '👤 user'} + ${u.is_env_admin ? '🔒' : ''}
- - ${u.id !== state.currentUser?.id ? `` : ''}
@@ -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 = ''; - 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-error').classList.add('hidden'); document.getElementById('user-modal').style.display = 'flex'; diff --git a/src/api.py b/src/api.py index 98e787b..a1f7e83 100644 --- a/src/api.py +++ b/src/api.py @@ -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) diff --git a/src/state.py b/src/state.py index dd36056..414b71e 100644 --- a/src/state.py +++ b/src/state.py @@ -97,12 +97,13 @@ class StateDB: """) self.conn.execute(""" CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'user', - created_at TEXT, - updated_at TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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 ) """) self.conn.execute(""" @@ -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()]