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()