diff --git a/frontend/index.html b/frontend/index.html index 1af30a6..d2f6e3e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -97,6 +97,7 @@ Подключение... + @@ -201,6 +202,54 @@

Источники определяются в коде приложения. Здесь можно управлять доменами для каждого источника.

+ + + +
+

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

+
+ + + +
+
+ + + + + + @@ -328,6 +377,7 @@ const state = { chapters: {}, // manga_url → [chapter, ...] filter: 'all', sources: [], // [{id, slug, display_name, domains}] + currentUser: null, // {id, username, role} }; // ── Auth ───────────────────────────────────── @@ -341,14 +391,30 @@ 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'); + } } async function checkAuth() { try { const r = await fetch('/api/auth/check'); const data = await r.json(); - if(!data.auth_enabled) { hideLoginScreen(); return true; } - if(data.authenticated) { hideLoginScreen(); return true; } + if(data.authenticated && data.user) { + state.currentUser = data.user; + hideLoginScreen(); + return true; + } + if(!data.auth_enabled) { + // auth отключён (обратная совместимость) + state.currentUser = {id: 0, username: 'guest', role: 'admin'}; + hideLoginScreen(); + return true; + } showLoginScreen(); return false; } catch(e) { @@ -376,6 +442,8 @@ async function doLogin() { err.classList.remove('hidden'); return; } + const d = await r.json().catch(()=>({})); + if(d.user) state.currentUser = d.user; hideLoginScreen(); await initApp(); } catch(e) { @@ -388,9 +456,12 @@ async function doLogin() { async function doLogout() { await fetch('/api/logout', {method:'POST'}).catch(()=>{}); + state.currentUser = null; showLoginScreen(); document.getElementById('login-input').value = ''; document.getElementById('password-input').value = ''; + const uinfo = document.getElementById('user-info'); + if(uinfo) uinfo.classList.add('hidden'); // Сбрасываем состояние Object.keys(state.mangas).forEach(k => delete state.mangas[k]); Object.keys(state.chapters).forEach(k => delete state.chapters[k]); @@ -462,6 +533,8 @@ function handleEvent(msg) { const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null; state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format, chapters_total: 0, chapters_done: 0, size_human: '—', + added_by: msg.added_by || null, + added_by_username: msg.added_by_username || null, source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null }; } else { state.mangas[msg.url].status = 'queued'; @@ -982,6 +1055,18 @@ async function stopManga(url) { await fetch('/api/mangas/stop?url='+encodeURIComponent(url), {method:'POST'}); } +// ── Helpers доступа ───────────────────────── +function isAdmin() { + return state.currentUser && state.currentUser.role === 'admin'; +} + +function canManage(manga) { + if(!state.currentUser) return false; + if(state.currentUser.role === 'admin') return true; + if(!manga) return false; + return manga.added_by === state.currentUser.id; +} + async function resumeManga(url) { const r = await fetch('/api/mangas/resume?url='+encodeURIComponent(url), {method:'POST'}); if(r.ok && state.mangas[url]) { @@ -990,6 +1075,163 @@ async function resumeManga(url) { } } +// ── Users management (admin only) ───────────── +let _userModalEditId = null; + +function showUsersSection() { + if(isAdmin()) { + document.getElementById('users-section').classList.remove('hidden'); + loadUsers(); + } +} + +async function loadUsers() { + if(!isAdmin()) return; + try { + const r = await fetch('/api/users'); + if(!r.ok) return; + const users = await r.json(); + renderUsers(users); + } catch(e) {} +} + +function renderUsers(users) { + const el = document.getElementById('users-list'); + if(!el) return; + if(!users.length) { + el.innerHTML = '
Нет пользователей
'; + return; + } + const roleColors = {admin: 'color:#fbbf24;background:#292103', user: 'color:#86efac;background:#032911'}; + el.innerHTML = users.map(u => ` +
+
+ ${escHtml(u.username)} + + ${u.role === 'admin' ? '👑 admin' : '👤 user'} + +
+
+ + ${u.id !== state.currentUser?.id ? `` : ''} +
+
+ `).join(''); +} + +function openAddUserModal() { + _userModalEditId = null; + document.getElementById('user-modal-title').textContent = 'Добавить пользователя'; + document.getElementById('user-modal-username').value = ''; + document.getElementById('user-modal-username').disabled = false; + document.getElementById('user-modal-password').value = ''; + document.getElementById('user-modal-pwd-hint').style.display = 'none'; + document.getElementById('user-modal-role').value = 'user'; + document.getElementById('user-modal-error').classList.add('hidden'); + document.getElementById('user-modal').style.display = 'flex'; + document.getElementById('user-modal').classList.remove('hidden'); +} + +function openEditUserModal(id, username, role) { + _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 = ''; + document.getElementById('user-modal-role').value = role; + document.getElementById('user-modal-error').classList.add('hidden'); + document.getElementById('user-modal').style.display = 'flex'; + document.getElementById('user-modal').classList.remove('hidden'); +} + +function closeUserModal() { + document.getElementById('user-modal').style.display = 'none'; + document.getElementById('user-modal').classList.add('hidden'); +} + +async function saveUserModal() { + const errEl = document.getElementById('user-modal-error'); + const btn = document.getElementById('user-modal-save'); + const username = document.getElementById('user-modal-username').value.trim(); + const password = document.getElementById('user-modal-password').value; + const role = document.getElementById('user-modal-role').value; + errEl.classList.add('hidden'); + btn.disabled = true; + try { + let r; + if(_userModalEditId === null) { + // Создание + if(!username || !password) { errEl.textContent = 'Логин и пароль обязательны'; errEl.classList.remove('hidden'); return; } + r = await fetch('/api/users', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, password, role}), + }); + } else { + // Редактирование + const body = {role}; + if(username) body.username = username; + if(password) body.password = password; + r = await fetch(`/api/users/${_userModalEditId}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }); + } + if(!r.ok) { + const d = await r.json().catch(()=>({})); + errEl.textContent = d.detail || 'Ошибка'; + errEl.classList.remove('hidden'); + return; + } + closeUserModal(); + loadUsers(); + } catch(e) { + errEl.textContent = 'Ошибка сети'; + errEl.classList.remove('hidden'); + } finally { + btn.disabled = false; + } +} + +async function confirmDeleteUser(id, username) { + if(!confirm(`Удалить пользователя «${username}»?`)) return; + const r = await fetch(`/api/users/${id}`, {method: 'DELETE'}); + if(r.ok) { + loadUsers(); + } else { + const d = await r.json().catch(()=>({})); + alert(d.detail || 'Ошибка удаления'); + } +} + +async function changeOwnPassword() { + if(!state.currentUser) return; + const pwd = document.getElementById('chpwd-new').value; + const msg = document.getElementById('chpwd-msg'); + msg.classList.add('hidden'); + if(!pwd) { msg.textContent = 'Введите новый пароль'; msg.className = 'text-xs text-red-400'; msg.classList.remove('hidden'); return; } + const r = await fetch(`/api/users/${state.currentUser.id}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({password: pwd}), + }); + if(r.ok) { + msg.textContent = '✓ Пароль сохранён'; + msg.className = 'text-xs text-green-400'; + msg.classList.remove('hidden'); + document.getElementById('chpwd-new').value = ''; + } else { + const d = await r.json().catch(()=>({})); + msg.textContent = d.detail || 'Ошибка'; + msg.className = 'text-xs text-red-400'; + msg.classList.remove('hidden'); + } +} + // ── Sources ─────────────────────────────────── async function loadSources() { try { @@ -1022,17 +1264,17 @@ function renderSources() { ${s.domains.map(d => ` ${escHtml(d)} - + style="color:#ef4444;background:none;border:none;cursor:pointer;padding:0 2px;font-size:0.8rem;line-height:1">✕` : ''} `).join('')} - + ${isAdmin() ? ` - + ` : ''} `).join(''); @@ -1494,6 +1736,7 @@ function renderMangaRow(m) { ${_timerHtml(m)} ${m.queue_position ? `Позиция в очереди: ${m.queue_position}` : ''} ${m.last_checked_at ? `🔍 ${_relTime(m.last_checked_at)}` : ''} + ${isAdmin() && m.added_by_username ? `👤 ${escHtml(m.added_by_username)}` : ''}
${progressHtml}
@@ -1520,6 +1763,8 @@ function _relTime(iso) { function _rowButtons(m) { const u = escHtml(m.url); const isActive = m.status === 'downloading' || m.status === 'queued'; + const manage = canManage(m); + const admin = isAdmin(); return ` ` : ''} - ${isActive + ${isActive && manage ? `` : ''} - ${m.status === 'stopped' || m.status === 'failed' + ${(m.status === 'stopped' || m.status === 'failed') && manage ? `` : ''} - ${m.status === 'queued' + ${m.status === 'queued' && admin ? `` : ''} - + ${admin + ? `` + : ''} `; } function _rowAuto(m) { if(m.pub_status !== 'ongoing') return ''; + if(!canManage(m)) return ''; // только владелец или admin const u = escHtml(m.url); const autoOn = m.auto_update == 1; return ` @@ -1747,6 +1995,11 @@ function renderModalBody(data) { Добавлена: ${new Date(data.added_at+'Z').toLocaleString('ru-RU')} ` : ''} + ${data.added_by_username ? ` +
+ Добавил: + 👤 ${escHtml(data.added_by_username)} +
` : ''} ${data.started_at ? `
Начало загрузки: @@ -1776,35 +2029,42 @@ function renderModalBody(data) { ` : '
Файлов на диске нет
'}
+ ${canManage(data) ? ` - ${data.status !== 'downloading' ? ` + ` : ''} + ${data.status !== 'downloading' && canManage(data) ? ` ` : ''} - ${data.status === 'done' ? ` + ${data.status === 'done' && canManage(data) ? ` ` : ''} - ${data.status !== 'downloading' && data.status !== 'queued' ? ` + ${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? ` ` : ''} - ${data.status !== 'downloading' ? ` + ${data.status !== 'downloading' && isAdmin() ? ` ` : ''} + ${isAdmin() ? ` + ` : ''}
@@ -1837,11 +2097,11 @@ function renderModalBody(data) { ${errors.map(c => renderErrorRow(c)).join('')}
- + ${canManage(data) ? `` : ''}
`} `; @@ -1871,21 +2131,21 @@ function renderErrorRow(c) { ${isPartial ? '⚠️' : '❌'}
- ${escHtml(c.error_label)} + ${escHtml(c.error_label || '')} Том ${c.volume || '?'} · Гл. ${c.number || '?'}
${escHtml(c.title || '')}
${isPartial ? `
-
+
-
${pDone} из ${pTotal} стр. (${pct}%)
-
` : ''} + ${pDone}/${pTotal} стр. (${pct}%) +
` : ` ${escHtml(c.chapter_url || '')} - + `} `; diff --git a/src/api.py b/src/api.py index e91146c..98e787b 100644 --- a/src/api.py +++ b/src/api.py @@ -1,82 +1,61 @@ """ FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги. +Многопользовательская система с ролями admin / user. """ import asyncio -import hashlib -import hmac as _hmac import os import re -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import List, Optional - from croniter import croniter -from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, HTTPException +from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from loguru import logger - from .state import StateDB from .worker import download_manga, check_for_updates from .exporter import patch_meta, MangaMeta from .sources import registry, get_source_for_url, extract_domain - +from .auth import verify_password, hash_password, generate_session_token, COOKIE_NAME, COOKIE_MAX_AGE OUTPUT_DIR = Path("/app/output") FRONTEND_DIR = Path("/app/frontend") - -# ── Авторизация ─────────────────────────────── - -AUTH_LOGIN = os.getenv("AUTH_LOGIN", "") -AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "") -AUTH_ENABLED = bool(AUTH_LOGIN and AUTH_PASSWORD) - -COOKIE_NAME = "manga_session" -COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней - -def _compute_token() -> str: - """Стабильный токен сессии, производный от credentials.""" - return _hmac.new( - AUTH_PASSWORD.encode(), - AUTH_LOGIN.encode(), - hashlib.sha256, - ).hexdigest() - -_VALID_TOKEN: str = _compute_token() if AUTH_ENABLED else "" - -# Пути, доступные без авторизации -_AUTH_EXEMPT = {"/api/login", "/api/auth/check", "/api/logout"} - app = FastAPI(title="Manga Downloader API") - - -@app.middleware("http") -async def auth_middleware(request: Request, call_next): - """Проверяет авторизацию для всех /api/* эндпоинтов.""" - if not AUTH_ENABLED: - return await call_next(request) - path = request.url.path - # Пропускаем статику и исключения - if not path.startswith("/api") or path in _AUTH_EXEMPT: - return await call_next(request) +# ── Auth Dependencies ───────────────────────── +async def get_current_user(request: Request) -> dict: token = request.cookies.get(COOKIE_NAME) - if token != _VALID_TOKEN: - return JSONResponse({"detail": "Unauthorized"}, status_code=401) - return await call_next(request) - + if not token: + raise HTTPException(status_code=401, detail="Не авторизован") + db = StateDB() + try: + session = db.get_session(token) + if not session: + raise HTTPException(status_code=401, detail="Сессия истекла") + user = db.get_user_by_id(session["user_id"]) + if not user: + raise HTTPException(status_code=401, detail="Пользователь не найден") + return dict(user) + finally: + db.close() +async def require_admin(user: dict = Depends(get_current_user)) -> dict: + if user["role"] != "admin": + raise HTTPException(status_code=403, detail="Требуются права администратора") + return user +def _check_manga_access(manga: dict, user: dict) -> None: + if user["role"] == "admin": + return + if manga.get("added_by") != user["id"]: + raise HTTPException(status_code=403, detail="Нет доступа к этой манге") # ── WebSocket менеджер ──────────────────────── - class ConnectionManager: def __init__(self): - self.active: set[WebSocket] = set() - + self.active: set = set() async def connect(self, ws: WebSocket): await ws.accept() self.active.add(ws) - def disconnect(self, ws: WebSocket): self.active.discard(ws) - async def broadcast(self, data: dict): dead = set() for ws in list(self.active): @@ -85,42 +64,26 @@ class ConnectionManager: except Exception: dead.add(ws) self.active -= dead - - ws_manager = ConnectionManager() - # ── Очередь загрузки ───────────────────────── - download_queue: asyncio.Queue = asyncio.Queue() - -# url → asyncio.Task текущей загрузки -active_tasks: dict[str, asyncio.Task] = {} - - +active_tasks: dict = {} async def _broadcast_queue_positions(): - """Отправляет всем клиентам актуальные позиции в очереди.""" - queue_list = list(download_queue._queue) # type: ignore + queue_list = list(download_queue._queue) positions = {job["url"]: i + 1 for i, job in enumerate(queue_list)} await ws_manager.broadcast({"type": "queue_positions", "positions": positions}) - - async def queue_worker(): - """Последовательно обрабатывает очередь загрузок. Перезапускается при краше.""" while True: try: await _queue_worker_loop() except Exception as e: logger.error("queue_worker упал, перезапускаю через 5 сек: {}", e) await asyncio.sleep(5) - - async def _queue_worker_loop(): while True: job = await download_queue.get() url = job["url"] fmt = job.get("fmt", "cbz") - - # Проверяем, не была ли манга остановлена пока стояла в очереди skip = False db = StateDB() try: @@ -130,14 +93,11 @@ async def _queue_worker_loop(): skip = True finally: db.close() - if skip: download_queue.task_done() await _broadcast_queue_positions() continue - logger.info("Воркер: начинаю скачивать {}", url) - # Позиции изменились — уведомляем клиентов await _broadcast_queue_positions() dl_task = asyncio.create_task(download_manga( url=url, @@ -155,13 +115,10 @@ async def _queue_worker_loop(): try: current_status = _db.get_manga(url) if current_status and current_status["status"] == "queued": - # Нас приоритизировали и поставили обратно в очередь — уведомляем await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": fmt}) elif current_status and current_status["status"] != "stopped": - # Статус ещё не "stopped" (например отменили не через /stop, а внутренне) _db.update_manga_status(url, "stopped") await ws_manager.broadcast({"type": "manga_stopped", "url": url}) - # Если статус уже "stopped" — API-эндпоинт уже всё сделал, ничего не дублируем finally: _db.close() except Exception as e: @@ -170,23 +127,30 @@ async def _queue_worker_loop(): active_tasks.pop(url, None) download_queue.task_done() await _broadcast_queue_positions() - - @app.on_event("startup") async def startup_event(): - # Синхронизируем источники с кодом и мигрируем существующие манги _db = StateDB() try: _db.sync_sources(registry) migrated = _db.migrate_manga_sources() if migrated: logger.info("Авто-миграция: проставлен source_id для {} манг", migrated) + cleaned = _db.cleanup_expired_sessions() + if cleaned: + logger.info("Очищено устаревших сессий: {}", cleaned) + 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") + logger.info("Создан начальный администратор: '{}'", admin_login) + if admin_login == "admin" and admin_password == "admin": + logger.warning( + "Используются учётные данные по умолчанию admin/admin. Смените пароль!" + ) finally: _db.close() - asyncio.create_task(queue_worker()) asyncio.create_task(update_scheduler()) - # Восстанавливаем очередь из БД (незавершённые задачи) db = StateDB() try: for manga in db.get_all_mangas(): @@ -196,23 +160,13 @@ async def startup_event(): logger.info("Восстановлено из очереди: {}", manga["url"]) finally: db.close() - - def _parse_schedule() -> Optional[str]: - """ - Читает расписание из переменных окружения. - Приоритет: UPDATE_SCHEDULE (cron-строка) → UPDATE_INTERVAL_HOURS (число часов, legacy). - Возвращает cron-строку или None если планировщик отключён. - """ schedule = os.getenv("UPDATE_SCHEDULE", "").strip() if schedule: - # Валидируем cron-выражение if croniter.is_valid(schedule): return schedule - logger.error("UPDATE_SCHEDULE='{}' — невалидное cron-выражение, планировщик отключён", schedule) + logger.error("UPDATE_SCHEDULE='{}' — невалидное cron-выражение", schedule) return None - - # Обратная совместимость: UPDATE_INTERVAL_HOURS → конвертируем в cron hours_raw = os.getenv("UPDATE_INTERVAL_HOURS", "").strip() if not hours_raw: return None @@ -220,90 +174,57 @@ def _parse_schedule() -> Optional[str]: hours = float(hours_raw) if hours <= 0: return None - # Конвертируем в cron: каждые N часов (если целое и делит 24) или фиксированное время h = int(hours) if h == hours and 24 % h == 0: cron = f"0 */{h} * * *" else: - # Нецелое или не делит 24 — берём ближайшее целое число часов h = max(1, round(hours)) cron = f"0 */{h} * * *" if 24 % h == 0 else f"0 0/{h} * * *" logger.info("UPDATE_INTERVAL_HOURS={} → cron: '{}'", hours_raw, cron) return cron except ValueError: - logger.error("UPDATE_INTERVAL_HOURS='{}' — не число, планировщик отключён", hours_raw) + logger.error("UPDATE_INTERVAL_HOURS='{}' — не число", hours_raw) return None - - async def update_scheduler(): - """ - Планировщик авто-обновлений на основе cron-расписания. - При любой ошибке — 3 попытки с интервалом 5 мин, затем ждёт следующего слота. - Цикл никогда не прерывается. - """ cron_expr = _parse_schedule() if not cron_expr: - logger.info("Планировщик обновлений отключён (UPDATE_SCHEDULE и UPDATE_INTERVAL_HOURS не заданы)") + logger.info("Планировщик обновлений отключён") return - logger.info("Планировщик обновлений запущен: '{}'", cron_expr) - - # Первый запуск — через 5 минут после старта (не сразу, чтобы не мешать инициализации) await asyncio.sleep(300) - while True: - # Вычисляем время до следующего запуска - now_utc = datetime.now(timezone.utc) - now_naive = now_utc.replace(tzinfo=None) # croniter работает с naive datetime + now_naive = datetime.now(timezone.utc).replace(tzinfo=None) cron = croniter(cron_expr, now_naive) - next_run: datetime = cron.get_next(datetime) + next_run = cron.get_next(datetime) wait_sec = max(0.0, (next_run - now_naive).total_seconds()) - logger.info("Следующая проверка обновлений: {} UTC (через {:.0f} мин)", next_run.strftime("%Y-%m-%d %H:%M"), wait_sec / 60) await asyncio.sleep(wait_sec) - - # Запускаем с retry-логикой await _run_auto_updates_with_retry() - - async def _run_auto_updates_with_retry(): - """Запускает _run_auto_updates с тремя попытками при ошибке.""" max_attempts = 3 - retry_delay = 300 # 5 минут между попытками - + retry_delay = 300 for attempt in range(1, max_attempts + 1): try: await _run_auto_updates() - return # успех + return except asyncio.CancelledError: - raise # не перехватываем отмену + raise except Exception as e: if attempt < max_attempts: - logger.warning( - "Авто-обновление: попытка {}/{} завершилась ошибкой: {}. " - "Повтор через {} сек.", attempt, max_attempts, e, retry_delay - ) + logger.warning("Авто-обновление: попытка {}/{}: {}. Повтор через {} сек.", + attempt, max_attempts, e, retry_delay) await asyncio.sleep(retry_delay) else: - logger.error( - "Авто-обновление: все {} попытки исчерпаны. " - "Последняя ошибка: {}. Ждём следующего слота по расписанию.", - max_attempts, e - ) - - + logger.error("Авто-обновление: все попытки исчерпаны. Ошибка: {}", e) async def _run_auto_updates(): - """Проверяет все манги с auto_update=1 на наличие новых глав.""" db = StateDB() try: candidates = db.get_autos() finally: db.close() - if not candidates: return - logger.info("Авто-обновление: проверяем {} манг", len(candidates)) for manga in candidates: url = manga["url"] @@ -321,64 +242,40 @@ async def _run_auto_updates(): db2.close() await download_queue.put({"url": url, "fmt": fmt, "is_update": True}) await ws_manager.broadcast({ - "type": "manga_queued", - "url": url, - "format": fmt, - "reason": "auto_update", + "type": "manga_queued", "url": url, "format": fmt, "reason": "auto_update", }) except Exception as e: logger.error("Ошибка авто-обновления {}: {}", url, e) - - -# ── Вспомогательные функции ─────────────────── - +# ── Helpers ─────────────────────────────────── def _safe_name(s: str) -> str: return re.sub(r'[^\w\s\-]', '', s).strip().replace(" ", "_")[:80] - - def _manga_folder(m: dict) -> Path: - """Возвращает папку манги с учётом кастомного имени.""" if m.get("folder_name"): return OUTPUT_DIR / m["folder_name"] title = m.get("title") or "" - safe_title = _safe_name(title) - return OUTPUT_DIR / safe_title - - + return OUTPUT_DIR / _safe_name(title) def _dir_size(path: Path) -> int: - """Размер директории в байтах.""" if not path.exists(): return 0 return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) - - def _format_size(bytes_val: int) -> str: for unit in ("Б", "КБ", "МБ", "ГБ"): if bytes_val < 1024: return f"{bytes_val:.1f} {unit}" bytes_val /= 1024 return f"{bytes_val:.1f} ТБ" - - def _enrich_manga(m: dict, db: StateDB) -> dict: - """Обогащает строку манги реальными счётчиками из таблицы chapters.""" size_bytes = _dir_size(_manga_folder(m)) ch_done_count = db.conn.execute( - "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'", - (m["url"],) + "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'", (m["url"],) ).fetchone()[0] ch_failed = db.conn.execute( - "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='failed'", - (m["url"],) + "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='failed'", (m["url"],) ).fetchone()[0] ch_partial = db.conn.execute( - """SELECT COUNT(*) FROM chapters - WHERE manga_url=? AND status='done' - AND pages_total > 0 AND pages_done < pages_total""", - (m["url"],) + "SELECT COUNT(*) FROM chapters WHERE manga_url=? AND status='done'" + " AND pages_total > 0 AND pages_done < pages_total", (m["url"],) ).fetchone()[0] - - # Источник source_info = None if m.get("source_id"): src = db.get_source_by_id(m["source_id"]) @@ -386,7 +283,6 @@ def _enrich_manga(m: dict, db: StateDB) -> dict: source_info = {"id": src["id"], "slug": src["slug"], "display_name": src["display_name"]} else: source_info = {"id": m["source_id"], "slug": "unknown", "display_name": "Источник недоступен"} - return { **m, "chapters_done": ch_done_count, @@ -399,46 +295,25 @@ def _enrich_manga(m: dict, db: StateDB) -> dict: "finished_at": m.get("finished_at"), "source": source_info, } - - def _manga_detail(manga: dict, db: StateDB) -> dict: url = manga["url"] chapters = db.get_all_chapters(url) - - # Определяем директорию манги manga_dir = _manga_folder(manga) size_bytes = _dir_size(manga_dir) - - # Файлы files = [] if manga_dir.exists(): for f in sorted(manga_dir.iterdir()): if f.is_file(): - files.append({ - "name": f.name, - "size": f.stat().st_size, - "size_human": _format_size(f.stat().st_size), - }) - - # ── Статистика ─────────────────────────── + files.append({"name": f.name, "size": f.stat().st_size, + "size_human": _format_size(f.stat().st_size)}) ch_done = [c for c in chapters if c["status"] == "done"] ch_failed = [c for c in chapters if c["status"] == "failed"] ch_pending = [c for c in chapters if c["status"] == "pending"] - total_pages_downloaded = sum(c.get("pages_done", 0) for c in chapters) total_pages_expected = sum(c.get("pages_total", 0) for c in chapters if c.get("pages_total", 0) > 0) - - # Частично скачанные (done, но pages_done < pages_total) - ch_partial = [ - c for c in ch_done - if c.get("pages_total", 0) > 0 and c.get("pages_done", 0) < c.get("pages_total", 0) - ] - # Сколько страниц потеряно в частичных - pages_missing = sum( - c.get("pages_total", 0) - c.get("pages_done", 0) - for c in ch_partial - ) - + ch_partial = [c for c in ch_done + if c.get("pages_total", 0) > 0 and c.get("pages_done", 0) < c.get("pages_total", 0)] + pages_missing = sum(c.get("pages_total", 0) - c.get("pages_done", 0) for c in ch_partial) errors = [] for c in ch_failed: errors.append({**c, "error_type": "failed", "error_label": "Глава не загружена"}) @@ -446,89 +321,162 @@ def _manga_detail(manga: dict, db: StateDB) -> dict: missing = c.get("pages_total", 0) - c.get("pages_done", 0) errors.append({**c, "error_type": "partial", "error_label": f"Частичная загрузка: пропущено {missing} стр."}) - # Сортируем: сначала failed, потом partial, внутри — по номеру errors.sort(key=lambda c: (0 if c["error_type"] == "failed" else 1, c.get("number", 0))) - stats = { - "chapters_done": len(ch_done), - "chapters_failed": len(ch_failed), - "chapters_pending": len(ch_pending), - "chapters_partial": len(ch_partial), + "chapters_done": len(ch_done), "chapters_failed": len(ch_failed), + "chapters_pending": len(ch_pending), "chapters_partial": len(ch_partial), "total_pages_downloaded": total_pages_downloaded, - "total_pages_expected": total_pages_expected, - "pages_missing": pages_missing, - "errors_count": len(errors), + "total_pages_expected": total_pages_expected, + "pages_missing": pages_missing, "errors_count": len(errors), } - return { - **manga, - "chapters": chapters, - "files": files, - "size_bytes": size_bytes, - "size_human": _format_size(size_bytes), - "files_count": len(files), - "stats": stats, - "errors": errors, + **manga, "chapters": chapters, "files": files, + "size_bytes": size_bytes, "size_human": _format_size(size_bytes), + "files_count": len(files), "stats": stats, "errors": errors, } - - -# ── REST API ────────────────────────────────── - -class AddMangaRequest(BaseModel): - urls: List[str] - format: str = "cbz" - source_id: Optional[int] = None # явный выбор источника (для неизвестных доменов) - - -# ── Auth API ───────────────────────────────── - +# ── Auth API ────────────────────────────────── class LoginRequest(BaseModel): login: str password: str - - @app.get("/api/auth/check") async def auth_check(request: Request): - """Проверить, авторизован ли пользователь.""" - if not AUTH_ENABLED: - return {"authenticated": True, "auth_enabled": False} - ok = request.cookies.get(COOKIE_NAME) == _VALID_TOKEN - return {"authenticated": ok, "auth_enabled": True} - - + token = request.cookies.get(COOKIE_NAME) + if not token: + return {"authenticated": False, "auth_enabled": True} + db = StateDB() + try: + session = db.get_session(token) + if not session: + return {"authenticated": False, "auth_enabled": True} + user = db.get_user_by_id(session["user_id"]) + if not user: + return {"authenticated": False, "auth_enabled": True} + return { + "authenticated": True, "auth_enabled": True, + "user": {"id": user["id"], "username": user["username"], "role": user["role"]}, + } + finally: + db.close() @app.post("/api/login") async def login(body: LoginRequest, response: Response): - if not AUTH_ENABLED: - return {"ok": True} - if body.login != AUTH_LOGIN or body.password != AUTH_PASSWORD: - raise HTTPException(status_code=401, detail="Неверный логин или пароль") - response.set_cookie( - key=COOKIE_NAME, - value=_VALID_TOKEN, - max_age=COOKIE_MAX_AGE, - httponly=True, - samesite="lax", - secure=False, # включите True если HTTPS - ) - return {"ok": True} - - + db = StateDB() + try: + user = db.get_user_by_username(body.login) + if not user or not verify_password(body.password, user["password"]): + raise HTTPException(status_code=401, detail="Неверный логин или пароль") + token = generate_session_token() + expires_at = (datetime.utcnow() + timedelta(days=30)).isoformat() + 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"]}} + finally: + db.close() @app.post("/api/logout") -async def logout(response: Response): +async def logout(request: Request, response: Response): + token = request.cookies.get(COOKIE_NAME) + if token: + db = StateDB() + try: + db.delete_session(token) + finally: + db.close() response.delete_cookie(COOKIE_NAME) return {"ok": True} - - +# ── User Management API ─────────────────────── +class CreateUserRequest(BaseModel): + username: str + password: str + role: str = "user" +class UpdateUserRequest(BaseModel): + username: Optional[str] = None + password: Optional[str] = None + role: Optional[str] = None +@app.get("/api/users") +async def list_users(_: dict = Depends(require_admin)): + db = StateDB() + try: + return db.get_all_users() + finally: + db.close() +@app.post("/api/users") +async def create_user_endpoint(body: CreateUserRequest, _: dict = Depends(require_admin)): + if body.role not in ("admin", "user"): + raise HTTPException(status_code=400, detail="Роль должна быть 'admin' или 'user'") + username = body.username.strip() + if not username: + raise HTTPException(status_code=400, detail="Имя пользователя не может быть пустым") + db = StateDB() + try: + if db.get_user_by_username(username): + raise HTTPException(status_code=409, detail="Пользователь с таким именем уже существует") + user = db.create_user(username, hash_password(body.password), body.role) + logger.info("Создан пользователь: '{}' ({})", username, body.role) + return {"ok": True, "user": user} + finally: + db.close() +@app.patch("/api/users/{user_id}") +async def update_user_endpoint(user_id: int, body: UpdateUserRequest, + current_user: dict = Depends(get_current_user)): + if current_user["role"] != "admin": + if current_user["id"] != user_id: + raise HTTPException(status_code=403, detail="Нет доступа") + if body.username is not None or body.role is not None: + raise HTTPException(status_code=403, detail="Нельзя изменить логин или роль") + db = StateDB() + try: + user = db.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + 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": + if db.count_admins() <= 1: + raise HTTPException(status_code=400, detail="Нельзя понизить последнего администратора") + updates: dict = {} + if body.username is not None: + upd_name = body.username.strip() + if not upd_name: + raise HTTPException(status_code=400, detail="Имя не может быть пустым") + updates["username"] = upd_name + if body.password is not None: + if not body.password: + raise HTTPException(status_code=400, detail="Пароль не может быть пустым") + updates["password"] = hash_password(body.password) + if body.role is not None: + updates["role"] = body.role + if updates: + db.update_user(user_id, **updates) + return {"ok": True} + finally: + db.close() +@app.delete("/api/users/{user_id}") +async def delete_user_endpoint(user_id: int, current_user: dict = Depends(require_admin)): + if current_user["id"] == user_id: + raise HTTPException(status_code=400, detail="Нельзя удалить себя") + db = StateDB() + try: + user = db.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + if user["role"] == "admin" and db.count_admins() <= 1: + raise HTTPException(status_code=400, detail="Нельзя удалить последнего администратора") + db.delete_user(user_id) + return {"ok": True} + finally: + db.close() # ── REST API ────────────────────────────────── - +class AddMangaRequest(BaseModel): + urls: List[str] + format: str = "cbz" + source_id: Optional[int] = None @app.get("/api/mangas") -async def list_mangas(): +async def list_mangas(_: dict = Depends(get_current_user)): db = StateDB() try: mangas = db.get_all_mangas() result = [_enrich_manga(m, db) for m in mangas] - # Добавляем позицию в очереди - queue_list = list(download_queue._queue) # type: ignore + queue_list = list(download_queue._queue) for i, job in enumerate(queue_list): for r in result: if r["url"] == job["url"]: @@ -536,10 +484,8 @@ async def list_mangas(): return result finally: db.close() - - @app.get("/api/mangas/detail") -async def manga_detail(url: str): +async def manga_detail(url: str, _: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(url) @@ -548,10 +494,8 @@ async def manga_detail(url: str): return _manga_detail(manga, db) finally: db.close() - - @app.post("/api/queue") -async def add_to_queue(body: AddMangaRequest): +async def add_to_queue(body: AddMangaRequest, current_user: dict = Depends(get_current_user)): db = StateDB() added = [] skipped = [] @@ -560,24 +504,20 @@ async def add_to_queue(body: AddMangaRequest): url = url.strip() if not url: continue - - # Определяем source_id: явный из запроса или авто по домену source_id = body.source_id if source_id is None: domain = extract_domain(url) source_row = db.get_source_by_domain(domain) if source_row: source_id = source_row["id"] - - # Если источник указан явно — привязываем домен к нему if body.source_id is not None: domain = extract_domain(url) existing = db.get_source_by_domain(domain) if existing and existing["id"] != body.source_id: db.remove_domain(existing["id"], domain) db.add_domain(body.source_id, domain) - - is_new = db.add_manga(url, body.format, source_id=source_id) + is_new = db.add_manga(url, body.format, source_id=source_id, + added_by=current_user["id"]) if is_new: await download_queue.put({"url": url, "fmt": body.format}) added.append(url) @@ -586,6 +526,8 @@ async def add_to_queue(body: AddMangaRequest): "url": url, "format": body.format, "source_id": source_id, + "added_by": current_user["id"], + "added_by_username": current_user["username"], }) await _broadcast_queue_positions() asyncio.create_task(_fetch_preview(url)) @@ -594,10 +536,7 @@ async def add_to_queue(body: AddMangaRequest): finally: db.close() return {"added": added, "skipped": skipped} - - async def _fetch_preview(url: str): - """Быстро получает название и количество глав сразу после добавления.""" try: from .browser import BrowserManager db = StateDB() @@ -609,10 +548,8 @@ async def _fetch_preview(url: str): source = registry.get_by_db_id(manga_row["source_id"], db) finally: db.close() - if source is None: return - async with BrowserManager(headless=True) as bm: _, page = await bm.new_page() manga = await source.get_manga_info(page, url) @@ -620,63 +557,44 @@ async def _fetch_preview(url: str): return db2 = StateDB() try: - db2.update_manga_info( - url, - title=manga.title_ru or manga.title, - chapters_total=len(manga.chapters), - title_ru=manga.title_ru, - title_full=manga.title_full, - pub_status=manga.pub_status, - ) + db2.update_manga_info(url, title=manga.title_ru or manga.title, + chapters_total=len(manga.chapters), title_ru=manga.title_ru, + title_full=manga.title_full, pub_status=manga.pub_status) finally: db2.close() await ws_manager.broadcast({ - "type": "manga_preview", - "url": url, - "title": manga.title_ru or manga.title, - "title_ru": manga.title_ru, - "title_full": manga.title_full, - "pub_status": manga.pub_status, + "type": "manga_preview", "url": url, + "title": manga.title_ru or manga.title, "title_ru": manga.title_ru, + "title_full": manga.title_full, "pub_status": manga.pub_status, "chapters_total": len(manga.chapters), }) - logger.info("Предпросмотр готов: {} ({} глав)", manga.title_ru or manga.title, len(manga.chapters)) except Exception as e: logger.warning("Ошибка предпросмотра {}: {}", url, e) - - @app.post("/api/mangas/auto_update") -async def toggle_auto_update(url: str, enabled: bool): - """Включить/выключить авто-обновление для манги.""" +async def toggle_auto_update(url: str, enabled: bool, current_user: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(url) if not manga: raise HTTPException(status_code=404, detail="Манга не найдена") + _check_manga_access(manga, current_user) db.set_auto_update(url, enabled) - await ws_manager.broadcast({ - "type": "auto_update_changed", - "url": url, - "auto_update": enabled, - }) + await ws_manager.broadcast({"type": "auto_update_changed", "url": url, "auto_update": enabled}) return {"ok": True, "auto_update": enabled} finally: db.close() - - @app.post("/api/mangas/check_now") -async def check_now(url: str): - """Немедленно проверить новые главы для конкретной манги.""" +async def check_now(url: str, current_user: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(url) if not manga: raise HTTPException(status_code=404, detail="Манга не найдена") + _check_manga_access(manga, current_user) finally: db.close() asyncio.create_task(_check_and_queue(url)) return {"ok": True} - - async def _check_and_queue(url: str): db = StateDB() try: @@ -692,11 +610,8 @@ async def _check_and_queue(url: str): finally: db2.close() await download_queue.put({"url": url, "fmt": fmt, "is_update": True}) - - @app.get("/api/news") -async def get_news(limit: int = 100): - """Только скачанные и автодокаченные главы — для вкладки Новости.""" +async def get_news(limit: int = 100, _: dict = Depends(get_current_user)): db = StateDB() try: cur = db.conn.execute(""" @@ -708,20 +623,15 @@ async def get_news(limit: int = 100): return [dict(r) for r in cur.fetchall()] finally: db.close() - - @app.get("/api/history") -async def get_history(limit: int = 100, manga_url: str = ""): +async def get_history(limit: int = 100, manga_url: str = "", _: dict = Depends(get_current_user)): db = StateDB() try: return db.get_history(limit=limit, manga_url=manga_url) finally: db.close() - - @app.post("/api/mangas/prioritize") -async def prioritize_manga(url: str): - """Поместить мангу в начало очереди, прервав текущую загрузку и вернув её следом.""" +async def prioritize_manga(url: str, _: dict = Depends(require_admin)): db = StateDB() try: manga = db.get_manga(url) @@ -729,74 +639,51 @@ async def prioritize_manga(url: str): raise HTTPException(status_code=404, detail="Манга не найдена") if manga["status"] == "downloading" and url in active_tasks: return {"ok": True, "message": "Уже загружается"} - fmt = manga["format"] or "cbz" - - # 1. Убираем target из очереди если там уже есть - items = list(download_queue._queue) # type: ignore + items = list(download_queue._queue) items = [i for i in items if i["url"] != url] - download_queue._queue.clear() # type: ignore + download_queue._queue.clear() for item in items: - download_queue._queue.append(item) # type: ignore - - # 2. Текущая активная загрузка + download_queue._queue.append(item) current_url = next(iter(active_tasks), None) if current_url and current_url != url: cur_manga = db.get_manga(current_url) cur_fmt = cur_manga["format"] if cur_manga else "cbz" - # Помечаем как queued — воркер увидит это и не поставит stopped db.update_manga_status(current_url, "queued") - # Вставляем обратно на второе место (сразу после target) - download_queue._queue.appendleft({"url": current_url, "fmt": cur_fmt}) # type: ignore - # Отменяем задачу — воркер сразу перейдёт к следующему элементу (target) + download_queue._queue.appendleft({"url": current_url, "fmt": cur_fmt}) task = active_tasks.get(current_url) if task and not task.done(): task.cancel() - - # 3. Вставляем target в самое начало - download_queue._queue.appendleft({"url": url, "fmt": fmt}) # type: ignore + download_queue._queue.appendleft({"url": url, "fmt": fmt}) db.update_manga_status(url, "queued") - logger.info("Приоритет: {} → начало очереди (вытеснен: {})", url, current_url) - await ws_manager.broadcast({ - "type": "manga_prioritized", - "url": url, - "preempted_url": current_url, - }) + await ws_manager.broadcast({"type": "manga_prioritized", "url": url, "preempted_url": current_url}) await _broadcast_queue_positions() return {"ok": True} finally: db.close() - - @app.post("/api/mangas/retry_errors") -async def retry_errors(url: str): - """Сбросить статус failed/partial глав на pending для повторной загрузки.""" +async def retry_errors(url: str, current_user: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(url) if not manga: raise HTTPException(status_code=404, detail="Манга не найдена") - # Сбрасываем failed + _check_manga_access(manga, current_user) + now = db.conn.execute("SELECT datetime('now')").fetchone()[0] db.conn.execute( - "UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? WHERE manga_url=? AND status='failed'", - (db.conn.execute("SELECT datetime('now')").fetchone()[0], url) - ) - # Сбрасываем partial (done, но страниц скачано меньше) + "UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?" + " WHERE manga_url=? AND status='failed'", (now, url)) db.conn.execute( - """UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? - WHERE manga_url=? AND status='done' AND pages_total > 0 AND pages_done < pages_total""", - (db.conn.execute("SELECT datetime('now')").fetchone()[0], url) - ) + "UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=?" + " WHERE manga_url=? AND status='done' AND pages_total > 0 AND pages_done < pages_total", + (now, url)) db.conn.commit() return {"ok": True} finally: db.close() - - @app.post("/api/mangas/refresh_meta") -async def refresh_meta(url: str): - """Обновить метаданные (ComicInfo.xml / EPUB OPF / PDF XMP) во всех уже скачанных файлах.""" +async def refresh_meta(url: str, current_user: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(url) @@ -804,14 +691,12 @@ async def refresh_meta(url: str): raise HTTPException(status_code=404, detail="Манга не найдена") if manga["status"] == "downloading" and url in active_tasks: raise HTTPException(status_code=400, detail="Манга сейчас загружается") + _check_manga_access(manga, current_user) finally: db.close() asyncio.create_task(_do_refresh_meta(url)) return {"ok": True} - - async def _do_refresh_meta(url: str): - """Фоновая задача: обходит все скачанные файлы и обновляет метаданные.""" db = StateDB() try: manga = db.get_manga(url) @@ -820,7 +705,6 @@ async def _do_refresh_meta(url: str): chapters = db.get_all_chapters(url) chapters_total = len(chapters) pub_status = manga.get("pub_status", "unknown") or "unknown" - updated = failed = 0 for ch in chapters: for fmt_col, ext in (("output_cbz", ".cbz"), ("output_pdf", ".pdf"), ("output_epub", ".epub")): @@ -844,65 +728,43 @@ async def _do_refresh_meta(url: str): updated += 1 else: failed += 1 - logger.info("refresh_meta {}: обновлено {}, ошибок {}", url, updated, failed) - await ws_manager.broadcast({ - "type": "meta_refreshed", - "url": url, - "updated": updated, - "failed": failed, - }) + await ws_manager.broadcast({"type": "meta_refreshed", "url": url, + "updated": updated, "failed": failed}) except Exception as e: logger.error("_do_refresh_meta {}: {}", url, e) finally: db.close() - - class UpdateMetaRequest(BaseModel): url: str title_ru: str title_full: str = "" - - @app.post("/api/mangas/update_meta") -async def update_meta(body: UpdateMetaRequest): - """Обновить метаданные манги (название серии) и применить к файлам.""" +async def update_meta(body: UpdateMetaRequest, current_user: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(body.url) if not manga: raise HTTPException(status_code=404, detail="Манга не найдена") - db.update_manga_meta_fields( - body.url, - title_ru=body.title_ru or None, - title_full=body.title_full or None, - ) + _check_manga_access(manga, current_user) + db.update_manga_meta_fields(body.url, title_ru=body.title_ru or None, + title_full=body.title_full or None) finally: db.close() - # Обновляем метаданные в файлах фоново asyncio.create_task(_do_refresh_meta(body.url)) await ws_manager.broadcast({ - "type": "manga_meta_updated", - "url": body.url, - "title": body.title_ru, - "title_ru": body.title_ru, - "title_full": body.title_full, + "type": "manga_meta_updated", "url": body.url, + "title": body.title_ru, "title_ru": body.title_ru, "title_full": body.title_full, }) return {"ok": True} - - class RenameFolderRequest(BaseModel): url: str folder_name: str - - @app.post("/api/mangas/rename_folder") -async def rename_folder(body: RenameFolderRequest): - """Переименовать папку манги и обновить пути в БД.""" +async def rename_folder(body: RenameFolderRequest, current_user: dict = Depends(get_current_user)): new_folder = _safe_name(body.folder_name) if not new_folder: raise HTTPException(status_code=400, detail="Некорректное имя папки") - db = StateDB() try: manga = db.get_manga(body.url) @@ -910,10 +772,9 @@ async def rename_folder(body: RenameFolderRequest): raise HTTPException(status_code=404, detail="Манга не найдена") if manga["status"] == "downloading" and body.url in active_tasks: raise HTTPException(status_code=400, detail="Нельзя переименовать — манга загружается") - + _check_manga_access(manga, current_user) old_dir = _manga_folder(manga) new_dir = OUTPUT_DIR / new_folder - if old_dir != new_dir: if new_dir.exists(): raise HTTPException(status_code=400, detail=f"Папка '{new_folder}' уже существует") @@ -921,7 +782,6 @@ async def rename_folder(body: RenameFolderRequest): import shutil shutil.move(str(old_dir), str(new_dir)) logger.info("Папка переименована: {} → {}", old_dir, new_dir) - # Обновляем пути в таблице chapters chapters = db.get_all_chapters(body.url) for ch in chapters: updates = {} @@ -931,26 +791,17 @@ async def rename_folder(body: RenameFolderRequest): updates[col] = p.replace(str(old_dir), str(new_dir)) if updates: sets = ", ".join(f"{k}=?" for k in updates) - db.conn.execute( - f"UPDATE chapters SET {sets} WHERE chapter_url=?", - [*updates.values(), ch["chapter_url"]] - ) + db.conn.execute(f"UPDATE chapters SET {sets} WHERE chapter_url=?", + [*updates.values(), ch["chapter_url"]]) db.conn.commit() - db.set_folder_name(body.url, new_folder) - await ws_manager.broadcast({ - "type": "manga_folder_renamed", - "url": body.url, - "folder_name": new_folder, - }) + await ws_manager.broadcast({"type": "manga_folder_renamed", + "url": body.url, "folder_name": new_folder}) return {"ok": True, "folder_name": new_folder} finally: db.close() - - @app.post("/api/mangas/force_redownload") -async def force_redownload(url: str): - """Сбросить все главы на pending и поставить мангу заново в очередь.""" +async def force_redownload(url: str, _: dict = Depends(require_admin)): db = StateDB() try: manga = db.get_manga(url) @@ -958,15 +809,11 @@ async def force_redownload(url: str): raise HTTPException(status_code=404, detail="Манга не найдена") if manga["status"] == "downloading" and url in active_tasks: raise HTTPException(status_code=400, detail="Сначала остановите загрузку") - - # Сбрасываем все главы на pending + now = db.conn.execute("SELECT datetime('now')").fetchone()[0] db.conn.execute( "UPDATE chapters SET status='pending', pages_done=0, pages_total=0, updated_at=? WHERE manga_url=?", - (db.conn.execute("SELECT datetime('now')").fetchone()[0], url) - ) + (now, url)) db.conn.commit() - - # Ставим в очередь с resume=False — перекачает всё заново db.update_manga_status(url, "queued") await download_queue.put({"url": url, "fmt": manga["format"], "resume": False}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) @@ -974,40 +821,25 @@ async def force_redownload(url: str): return {"ok": True} finally: db.close() - - @app.post("/api/mangas/stop") -async def stop_manga(url: str): - """Остановить текущую загрузку манги.""" +async def stop_manga(url: str, current_user: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(url) if not manga: raise HTTPException(status_code=404, detail="Манга не найдена") - - # Отменяем активную задачу если есть + _check_manga_access(manga, current_user) task = active_tasks.get(url) if task and not task.done(): task.cancel() - # Сразу обновляем статус и уведомляем клиентов — не ждём пока воркер - # обработает CancelledError (это может занять секунды пока браузер завершит операцию) - db.update_manga_status(url, "stopped") - await ws_manager.broadcast({"type": "manga_stopped", "url": url}) - await _broadcast_queue_positions() - else: - # Манга в очереди (ещё не начата) — просто помечаем как stopped - db.update_manga_status(url, "stopped") - await ws_manager.broadcast({"type": "manga_stopped", "url": url}) - await _broadcast_queue_positions() - + db.update_manga_status(url, "stopped") + await ws_manager.broadcast({"type": "manga_stopped", "url": url}) + await _broadcast_queue_positions() return {"ok": True} finally: db.close() - - @app.post("/api/mangas/resume") -async def resume_manga(url: str): - """Возобновить загрузку остановленной/упавшей манги.""" +async def resume_manga(url: str, current_user: dict = Depends(get_current_user)): db = StateDB() try: manga = db.get_manga(url) @@ -1015,7 +847,7 @@ async def resume_manga(url: str): raise HTTPException(status_code=404, detail="Манга не найдена") if manga["status"] == "downloading" and url in active_tasks: raise HTTPException(status_code=400, detail="Манга уже загружается") - + _check_manga_access(manga, current_user) db.update_manga_status(url, "queued") await download_queue.put({"url": url, "fmt": manga["format"]}) await ws_manager.broadcast({"type": "manga_queued", "url": url, "format": manga["format"]}) @@ -1023,10 +855,8 @@ async def resume_manga(url: str): return {"ok": True} finally: db.close() - - @app.delete("/api/mangas") -async def delete_manga(url: str, delete_files: bool = False): +async def delete_manga(url: str, delete_files: bool = False, _: dict = Depends(require_admin)): db = StateDB() try: manga = db.get_manga(url) @@ -1034,7 +864,6 @@ async def delete_manga(url: str, delete_files: bool = False): raise HTTPException(status_code=404, detail="Манга не найдена") if manga["status"] == "downloading" and url in active_tasks: raise HTTPException(status_code=400, detail="Нельзя удалить активную загрузку") - deleted_size = 0 if delete_files: manga_dir = _manga_folder(manga) @@ -1043,7 +872,6 @@ async def delete_manga(url: str, delete_files: bool = False): import shutil shutil.rmtree(str(manga_dir)) logger.info("Удалена папка: {} ({} байт)", manga_dir, deleted_size) - db.conn.execute("DELETE FROM chapters WHERE manga_url=?", (url,)) db.conn.execute("DELETE FROM history WHERE manga_url=?", (url,)) db.conn.execute("DELETE FROM mangas WHERE url=?", (url,)) @@ -1051,54 +879,33 @@ async def delete_manga(url: str, delete_files: bool = False): return {"ok": True, "deleted_size": deleted_size} finally: db.close() - - - # ── Sources API ─────────────────────────────── - class DomainAdd(BaseModel): domain: str - - class SwitchSourceRequest(BaseModel): url: str source_id: int - - @app.get("/api/sources") -async def list_sources(): - """Список всех источников с доменами.""" +async def list_sources(_: dict = Depends(get_current_user)): db = StateDB() try: return db.get_all_sources() finally: db.close() - - @app.get("/api/resolve-source") -async def resolve_source(url: str): - """Определить источник по URL. Возвращает {id, slug, display_name} или null.""" +async def resolve_source(url: str, _: dict = Depends(get_current_user)): db = StateDB() try: domain = extract_domain(url) row = db.get_source_by_domain(domain) if not row: return {"source": None, "domain": domain} - return { - "source": { - "id": row["id"], - "slug": row["slug"], - "display_name": row["display_name"], - }, - "domain": domain, - } + return {"source": {"id": row["id"], "slug": row["slug"], + "display_name": row["display_name"]}, "domain": domain} finally: db.close() - - @app.post("/api/sources/{source_id}/domains") -async def add_domain(source_id: int, body: DomainAdd): - """Добавить домен к источнику.""" +async def add_domain(source_id: int, body: DomainAdd, _: dict = Depends(require_admin)): db = StateDB() try: source = db.get_source_by_id(source_id) @@ -1107,29 +914,20 @@ async def add_domain(source_id: int, body: DomainAdd): domain = body.domain.lower().strip() if not domain: raise HTTPException(status_code=400, detail="Домен не может быть пустым") - # Проверяем не занят ли домен другим источником existing = db.get_source_by_domain(domain) if existing and existing["id"] != source_id: - raise HTTPException( - status_code=409, - detail=f"Домен уже привязан к источнику «{existing['display_name']}»" - ) + raise HTTPException(status_code=409, + detail=f"Домен уже привязан к источнику «{existing['display_name']}»") ok = db.add_domain(source_id, domain) if not ok: raise HTTPException(status_code=409, detail="Домен уже существует") - await ws_manager.broadcast({ - "type": "source_domain_added", - "source_id": source_id, - "domain": domain, - }) + await ws_manager.broadcast({"type": "source_domain_added", + "source_id": source_id, "domain": domain}) return {"ok": True, "domain": domain} finally: db.close() - - @app.delete("/api/sources/{source_id}/domains/{domain:path}") -async def remove_domain(source_id: int, domain: str): - """Удалить домен у источника.""" +async def remove_domain(source_id: int, domain: str, _: dict = Depends(require_admin)): db = StateDB() try: source = db.get_source_by_id(source_id) @@ -1138,19 +936,13 @@ async def remove_domain(source_id: int, domain: str): ok = db.remove_domain(source_id, domain) if not ok: raise HTTPException(status_code=404, detail="Домен не найден") - await ws_manager.broadcast({ - "type": "source_domain_removed", - "source_id": source_id, - "domain": domain, - }) + await ws_manager.broadcast({"type": "source_domain_removed", + "source_id": source_id, "domain": domain}) return {"ok": True} finally: db.close() - - @app.post("/api/mangas/switch-source") -async def switch_manga_source(body: SwitchSourceRequest): - """Сменить источник у манги + перепривязать домен.""" +async def switch_manga_source(body: SwitchSourceRequest, _: dict = Depends(require_admin)): db = StateDB() try: manga = db.get_manga(body.url) @@ -1158,48 +950,30 @@ async def switch_manga_source(body: SwitchSourceRequest): raise HTTPException(status_code=404, detail="Манга не найдена") if manga["status"] == "downloading" and body.url in active_tasks: raise HTTPException(status_code=400, detail="Нельзя сменить источник во время загрузки") - new_source = db.get_source_by_id(body.source_id) if not new_source: raise HTTPException(status_code=404, detail="Источник не найден") - old_source_id = manga.get("source_id") domain = extract_domain(body.url) - - # Перепривязываем домен if domain: existing_domain = db.get_source_by_domain(domain) if existing_domain and existing_domain["id"] != body.source_id: db.remove_domain(existing_domain["id"], domain) db.add_domain(body.source_id, domain) - - # Меняем источник у манги db.set_manga_source(body.url, body.source_id) - - # Сбрасываем failed/partial главы → pending reset_count = db.reset_failed_chapters(body.url) - await ws_manager.broadcast({ - "type": "source_switched", - "url": body.url, - "old_source_id": old_source_id, - "new_source_id": body.source_id, + "type": "source_switched", "url": body.url, + "old_source_id": old_source_id, "new_source_id": body.source_id, "new_source_name": new_source["display_name"], - "domain_rebound": bool(domain), - "chapters_reset": reset_count, + "domain_rebound": bool(domain), "chapters_reset": reset_count, }) - return { - "ok": True, - "source_id": body.source_id, - "source_name": new_source["display_name"], - "chapters_reset": reset_count, - } + return {"ok": True, "source_id": body.source_id, + "source_name": new_source["display_name"], "chapters_reset": reset_count} finally: db.close() - - @app.get("/api/stats") -async def global_stats(): +async def global_stats(_: dict = Depends(get_current_user)): db = StateDB() try: mangas = db.get_all_mangas() @@ -1217,25 +991,28 @@ async def global_stats(): } finally: db.close() - - # ── WebSocket ───────────────────────────────── - @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): - # Проверяем авторизацию по cookie - if AUTH_ENABLED and ws.cookies.get(COOKIE_NAME) != _VALID_TOKEN: + token = ws.cookies.get(COOKIE_NAME) + if not token: await ws.close(code=4401) return + db_auth = StateDB() + try: + session = db_auth.get_session(token) + if not session: + await ws.close(code=4401) + return + finally: + db_auth.close() await ws_manager.connect(ws) try: - # Отправляем начальный снимок состояния db = StateDB() try: mangas = db.get_all_mangas() enriched = [_enrich_manga(m, db) for m in mangas] - # Добавляем позицию в очереди - queue_list = list(download_queue._queue) # type: ignore + queue_list = list(download_queue._queue) for i, job in enumerate(queue_list): for em in enriched: if em["url"] == job["url"]: @@ -1243,9 +1020,7 @@ async def websocket_endpoint(ws: WebSocket): await ws.send_json({"type": "snapshot", "mangas": enriched}) finally: db.close() - while True: - # Держим соединение живым, ждём пинги data = await ws.receive_text() if data == "ping": await ws.send_json({"type": "pong"}) @@ -1253,10 +1028,6 @@ async def websocket_endpoint(ws: WebSocket): ws_manager.disconnect(ws) except Exception: ws_manager.disconnect(ws) - - # ── Статические файлы (фронтенд) ────────────── - if FRONTEND_DIR.exists(): app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend") - diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..135aa70 --- /dev/null +++ b/src/auth.py @@ -0,0 +1,36 @@ +""" +Утилиты авторизации: хеширование паролей, генерация токенов сессий. +""" +import hashlib +import hmac +import secrets + +COOKIE_NAME = "manga_session" +COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней + + +def hash_password(password: str) -> str: + """Хеширует пароль: pbkdf2:iterations:salt:key_hex""" + salt = secrets.token_hex(16) + key = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), 260_000 + ) + return f"pbkdf2:260000:{salt}:{key.hex()}" + + +def verify_password(password: str, hashed: str) -> bool: + """Проверяет пароль против сохранённого хеша.""" + try: + _, iterations, salt, stored_key = hashed.split(":") + key = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), int(iterations) + ) + return hmac.compare_digest(key.hex(), stored_key) + except Exception: + return False + + +def generate_session_token() -> str: + """Генерирует безопасный случайный токен сессии (48 байт).""" + return secrets.token_urlsafe(48) + diff --git a/src/state.py b/src/state.py index 4a2bc68..dd36056 100644 --- a/src/state.py +++ b/src/state.py @@ -95,6 +95,24 @@ class StateDB: domain TEXT UNIQUE NOT NULL ) """) + 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 + ) + """) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT, + expires_at TEXT + ) + """) # Migrate old DB: add missing columns migrations = [ ("chapters", "pages_total", "INTEGER DEFAULT 0"), @@ -108,6 +126,7 @@ class StateDB: ("mangas", "finished_at", "TEXT"), ("mangas", "folder_name", "TEXT"), ("mangas", "source_id", "INTEGER REFERENCES sources(id)"), + ("mangas", "added_by", "INTEGER REFERENCES users(id)"), ] for table, col, typedef in migrations: try: @@ -285,15 +304,16 @@ class StateDB: # ── Mangas ──────────────────────────────────── - def add_manga(self, url: str, fmt: str = "cbz", source_id: Optional[int] = None) -> bool: + def add_manga(self, url: str, fmt: str = "cbz", source_id: Optional[int] = None, + added_by: Optional[int] = None) -> bool: """Добавляет мангу в очередь. Возвращает True если новая.""" cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,)) if cur.fetchone(): return False self.conn.execute(""" - INSERT INTO mangas (url, format, status, source_id, added_at, updated_at) - VALUES (?, ?, 'queued', ?, ?, ?) - """, (url, fmt, source_id, _now(), _now())) + INSERT INTO mangas (url, format, status, source_id, added_by, added_at, updated_at) + VALUES (?, ?, 'queued', ?, ?, ?, ?) + """, (url, fmt, source_id, added_by, _now(), _now())) self.conn.commit() return True @@ -385,12 +405,20 @@ class StateDB: pass def get_manga(self, url: str) -> Optional[dict]: - cur = self.conn.execute("SELECT * FROM mangas WHERE url=?", (url,)) + cur = self.conn.execute(""" + SELECT m.*, u.username AS added_by_username + FROM mangas m LEFT JOIN users u ON u.id = m.added_by + WHERE m.url=? + """, (url,)) row = cur.fetchone() return dict(row) if row else None def get_all_mangas(self) -> list[dict]: - cur = self.conn.execute("SELECT * FROM mangas ORDER BY added_at DESC") + cur = self.conn.execute(""" + SELECT m.*, u.username AS added_by_username + FROM mangas m LEFT JOIN users u ON u.id = m.added_by + ORDER BY m.added_at DESC + """) return [dict(r) for r in cur.fetchall()] def get_manga_format(self, url: str) -> str: @@ -506,6 +534,88 @@ class StateDB: """) return [dict(r) for r in cur.fetchall()] + # ── Users ───────────────────────────────────── + + def create_user(self, username: str, hashed_password: str, role: str = "user") -> dict: + """Создаёт пользователя. Возвращает dict без поля password.""" + self.conn.execute(""" + INSERT INTO users (username, password, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, (username, hashed_password, role, _now(), _now())) + self.conn.commit() + row = self.conn.execute( + "SELECT id, username, role, created_at FROM users WHERE username=?", (username,) + ).fetchone() + return dict(row) + + def get_user_by_id(self, user_id: int) -> Optional[dict]: + row = self.conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + return dict(row) if row else None + + def get_user_by_username(self, username: str) -> Optional[dict]: + row = self.conn.execute( + "SELECT * FROM users WHERE username=?", (username,) + ).fetchone() + return dict(row) if row else None + + 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" + ) + return [dict(r) for r in cur.fetchall()] + + def count_admins(self) -> int: + return self.conn.execute( + "SELECT COUNT(*) FROM users WHERE role='admin'" + ).fetchone()[0] + + def update_user(self, user_id: int, **kwargs) -> None: + """Обновляет поля пользователя. Разрешённые поля: username, password, role.""" + allowed = {"username", "password", "role"} + updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None} + if not updates: + return + updates["updated_at"] = _now() + sets = ", ".join(f"{k}=?" for k in updates) + self.conn.execute( + f"UPDATE users SET {sets} WHERE id=?", [*updates.values(), user_id] + ) + self.conn.commit() + + def delete_user(self, user_id: int) -> None: + """Удаляет пользователя и все его сессии.""" + self.conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,)) + self.conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + self.conn.commit() + + # ── Sessions ────────────────────────────────── + + def create_session(self, token: str, user_id: int, expires_at: str) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)", + (token, user_id, _now(), expires_at) + ) + self.conn.commit() + + def get_session(self, token: str) -> Optional[dict]: + """Возвращает сессию если действующая (не истекла).""" + row = self.conn.execute( + "SELECT * FROM sessions WHERE token=? AND expires_at > ?", + (token, _now()) + ).fetchone() + return dict(row) if row else None + + def delete_session(self, token: str) -> None: + self.conn.execute("DELETE FROM sessions WHERE token=?", (token,)) + self.conn.commit() + + def cleanup_expired_sessions(self) -> int: + """Удаляет истёкшие сессии. Возвращает количество удалённых.""" + cur = self.conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (_now(),)) + self.conn.commit() + return cur.rowcount + def close(self): self.conn.close()