upd
This commit is contained in:
811
src/api.py
811
src/api.py
File diff suppressed because it is too large
Load Diff
36
src/auth.py
Normal file
36
src/auth.py
Normal 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)
|
||||
|
||||
122
src/state.py
122
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user