This commit is contained in:
2026-05-01 02:45:09 +03:00
parent 9d5d840898
commit 469fd1ba94
4 changed files with 728 additions and 551 deletions

File diff suppressed because it is too large Load Diff

36
src/auth.py Normal file
View File

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

View File

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