mirror of
https://github.com/dbv111m/musicgrabber.git
synced 2026-10-19 01:31:29 -05:00
A backlog of finished work lands together on dev. The security hardening pass (SSRF-proof URL-backed sources, path-traversal-proof custom folders, per-account trash bins, Watched Artists checking the right library, Soulseek no longer deleting a same-named stranger's file, Force Accept checking ownership first), the Monochrome browser-fallback playback route with its own Settings test button and working previews, and assorted Queue/album/ library fixes are all previously-written and already documented in CHANGELOG.md, just not committed until now. New on top of that: long-form YouTube splitting. Paste a single YouTube video into Bulk Import's Fetch box and, if it runs over 12 minutes, MusicGrabber checks it for YouTube chapters, then a cue sheet in the video's own description, then one hunted out of the comments, before falling back to manual entry, always landing on an editable start/end/title/artist table the user confirms before anything downloads or gets cut. Confirming downloads the audio once, cuts each segment with ffmpeg, runs the same integrity check every other download gets, and tags directly from the table with no automatic MusicBrainz guessing against noisy chapter titles. Fast suite: 931 passed, 2 skipped. Built with a human and a grumpy-AI combo who spent a worrying amount of that time listening to a Red Jerry chillout mix for testing purposes, obviously.
343 lines
11 KiB
Python
343 lines
11 KiB
Python
"""
|
|
MusicGrabber - Authentication helpers
|
|
|
|
Session management, password hashing, user lookups.
|
|
"""
|
|
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import defaultdict
|
|
|
|
import bcrypt
|
|
|
|
from constants import (
|
|
DOWNLOAD_TOKEN_TTL_SECONDS,
|
|
LOGIN_ATTEMPT_WINDOW,
|
|
LOGIN_LOCKOUT_SECONDS,
|
|
LOGIN_MAX_ATTEMPTS,
|
|
STREAM_TOKEN_TTL_SECONDS,
|
|
)
|
|
from db import db_conn
|
|
|
|
|
|
SESSION_LIFETIME_DAYS = 30
|
|
_login_failures: dict[str, list[float]] = defaultdict(list)
|
|
_login_lockouts: dict[str, float] = {}
|
|
_login_lock = threading.Lock()
|
|
_login_last_cleanup = 0.0
|
|
_LOGIN_CLEANUP_INTERVAL = 60.0
|
|
|
|
|
|
def hash_password(plaintext: str) -> str:
|
|
return bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt()).decode()
|
|
|
|
|
|
_DUMMY_PASSWORD_HASH = hash_password("musicgrabber-invalid-password")
|
|
|
|
|
|
def verify_password(plaintext: str, hashed: str) -> bool:
|
|
try:
|
|
return bcrypt.checkpw(plaintext.encode(), hashed.encode())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _login_key(username: str) -> str:
|
|
return (username or "").strip().casefold()
|
|
|
|
|
|
def _prune_login_state(now: float) -> None:
|
|
global _login_last_cleanup
|
|
if now - _login_last_cleanup < _LOGIN_CLEANUP_INTERVAL:
|
|
return
|
|
|
|
window_start = now - LOGIN_ATTEMPT_WINDOW
|
|
for key, attempts in list(_login_failures.items()):
|
|
trimmed = [t for t in attempts if t > window_start]
|
|
if trimmed:
|
|
_login_failures[key] = trimmed
|
|
else:
|
|
_login_failures.pop(key, None)
|
|
for key, locked_until in list(_login_lockouts.items()):
|
|
if locked_until <= now:
|
|
_login_lockouts.pop(key, None)
|
|
_login_last_cleanup = now
|
|
|
|
|
|
def is_login_allowed(username: str) -> tuple[bool, int]:
|
|
"""Return whether login attempts are allowed for this username."""
|
|
now = time.time()
|
|
key = _login_key(username)
|
|
with _login_lock:
|
|
_prune_login_state(now)
|
|
locked_until = _login_lockouts.get(key, 0.0)
|
|
if locked_until > now:
|
|
return False, max(1, int(locked_until - now))
|
|
if locked_until:
|
|
_login_lockouts.pop(key, None)
|
|
return True, 0
|
|
|
|
|
|
def record_failed_login(username: str) -> int:
|
|
"""Record a failed attempt; returns lockout seconds if lockout was applied."""
|
|
now = time.time()
|
|
window_start = now - LOGIN_ATTEMPT_WINDOW
|
|
key = _login_key(username)
|
|
with _login_lock:
|
|
_prune_login_state(now)
|
|
attempts = [t for t in _login_failures.get(key, []) if t > window_start]
|
|
attempts.append(now)
|
|
if len(attempts) >= LOGIN_MAX_ATTEMPTS:
|
|
_login_lockouts[key] = now + LOGIN_LOCKOUT_SECONDS
|
|
_login_failures.pop(key, None)
|
|
return LOGIN_LOCKOUT_SECONDS
|
|
_login_failures[key] = attempts
|
|
return 0
|
|
|
|
|
|
def clear_failed_login(username: str) -> None:
|
|
key = _login_key(username)
|
|
with _login_lock:
|
|
_login_failures.pop(key, None)
|
|
_login_lockouts.pop(key, None)
|
|
|
|
|
|
def password_hash_for_timing(user: dict | None) -> str:
|
|
"""Always return a hash so login checks have similar bcrypt timing."""
|
|
if user and user.get("password_hash"):
|
|
return user["password_hash"]
|
|
return _DUMMY_PASSWORD_HASH
|
|
|
|
|
|
def create_session(user_id: str) -> str:
|
|
token = str(uuid.uuid4())
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO sessions (token, user_id, expires_at) "
|
|
"VALUES (?, ?, datetime('now', '+' || ? || ' days'))",
|
|
(token, user_id, str(SESSION_LIFETIME_DAYS)),
|
|
)
|
|
conn.commit()
|
|
return token
|
|
|
|
|
|
def create_download_token(user_id: str, job_id: str, ttl_seconds: int = DOWNLOAD_TOKEN_TTL_SECONDS) -> str:
|
|
token = str(uuid.uuid4())
|
|
ttl = max(5, int(ttl_seconds))
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO download_tokens (token, user_id, job_id, expires_at) "
|
|
"VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds'))",
|
|
(token, user_id, job_id, str(ttl)),
|
|
)
|
|
conn.commit()
|
|
return token
|
|
|
|
|
|
def consume_download_token(token: str, job_id: str) -> dict | None:
|
|
"""Consume a single-use download token and return the associated user context."""
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.execute(
|
|
"""UPDATE download_tokens
|
|
SET used_at = datetime('now')
|
|
WHERE token = ?
|
|
AND job_id = ?
|
|
AND used_at IS NULL
|
|
AND expires_at > datetime('now')""",
|
|
(token, job_id),
|
|
)
|
|
if cursor.rowcount != 1:
|
|
return None
|
|
|
|
row = conn.execute(
|
|
"""SELECT u.id, u.username, u.role, u.force_password_change, u.is_active
|
|
FROM download_tokens dt
|
|
JOIN users u ON u.id = dt.user_id
|
|
WHERE dt.token = ?""",
|
|
(token,),
|
|
).fetchone()
|
|
conn.commit()
|
|
|
|
if row is None:
|
|
return None
|
|
user = dict(row)
|
|
if not user.get("is_active"):
|
|
return None
|
|
return user
|
|
|
|
|
|
def cleanup_expired_download_tokens() -> int:
|
|
with db_conn() as conn:
|
|
cursor = conn.execute(
|
|
"DELETE FROM download_tokens WHERE used_at IS NOT NULL OR expires_at <= datetime('now')"
|
|
)
|
|
deleted = cursor.rowcount
|
|
conn.commit()
|
|
return deleted
|
|
|
|
|
|
def create_stream_token(user_id: str, ttl_seconds: int = STREAM_TOKEN_TTL_SECONDS) -> str:
|
|
"""Mint a reusable playback token for an <audio> element.
|
|
|
|
Reusable because players make range requests and seek about; a single-use
|
|
token would die somewhere in the first second of the track. It carries no
|
|
authority of its own beyond "this is who's asking", so the stream endpoints
|
|
still do their usual ownership checks.
|
|
"""
|
|
token = str(uuid.uuid4())
|
|
ttl = max(5, int(ttl_seconds))
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO stream_tokens (token, user_id, expires_at) "
|
|
"VALUES (?, ?, datetime('now', '+' || ? || ' seconds'))",
|
|
(token, user_id, str(ttl)),
|
|
)
|
|
conn.commit()
|
|
return token
|
|
|
|
|
|
def resolve_stream_token(token: str) -> dict | None:
|
|
"""The user context a live stream token belongs to, for the auth middleware."""
|
|
if not token:
|
|
return None
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"""SELECT u.id, u.username, u.role, u.force_password_change, u.is_active
|
|
FROM stream_tokens st
|
|
JOIN users u ON u.id = st.user_id
|
|
WHERE st.token = ?
|
|
AND st.expires_at > datetime('now')""",
|
|
(token,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
user = dict(row)
|
|
if not user.get("is_active"):
|
|
return None
|
|
return user
|
|
|
|
|
|
def cleanup_expired_stream_tokens() -> int:
|
|
with db_conn() as conn:
|
|
cursor = conn.execute(
|
|
"DELETE FROM stream_tokens WHERE expires_at <= datetime('now')"
|
|
)
|
|
deleted = cursor.rowcount
|
|
conn.commit()
|
|
return deleted
|
|
|
|
|
|
def get_session_user(token: str) -> dict | None:
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"""SELECT u.id, u.username, u.role, u.force_password_change, u.is_active,
|
|
(s.last_seen IS NULL OR s.last_seen < datetime('now', '-60 seconds')) AS should_touch
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token = ?
|
|
AND s.expires_at > datetime('now')""",
|
|
(token,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
# Avoid turning every authenticated API poll into a SQLite write.
|
|
# Queue polling can be frequent, and download workers already contend
|
|
# for the same database lock.
|
|
if row["should_touch"]:
|
|
conn.execute(
|
|
"UPDATE sessions SET last_seen = datetime('now') WHERE token = ?",
|
|
(token,),
|
|
)
|
|
conn.commit()
|
|
user = dict(row)
|
|
user.pop("should_touch", None)
|
|
return user
|
|
|
|
|
|
def delete_session(token: str) -> None:
|
|
with db_conn() as conn:
|
|
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
|
conn.commit()
|
|
|
|
|
|
def cleanup_expired_sessions() -> int:
|
|
with db_conn() as conn:
|
|
cursor = conn.execute("DELETE FROM sessions WHERE expires_at <= datetime('now')")
|
|
deleted = cursor.rowcount
|
|
conn.commit()
|
|
return deleted
|
|
|
|
|
|
def get_user_by_username(username: str) -> dict | None:
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM users WHERE username = ?", (username,)
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def get_user_by_id(user_id: str) -> dict | None:
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM users WHERE id = ?", (user_id,)
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def list_users() -> list[dict]:
|
|
with db_conn() as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT id, username, role, is_active, force_password_change, created_at "
|
|
"FROM users ORDER BY created_at"
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def create_user(username: str, password: str, role: str = "user") -> str:
|
|
user_id = str(uuid.uuid4())[:8]
|
|
pw_hash = hash_password(password)
|
|
try:
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (id, username, password_hash, role) VALUES (?, ?, ?, ?)",
|
|
(user_id, username, pw_hash, role),
|
|
)
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
raise ValueError(f"Username '{username}' already exists")
|
|
return user_id
|
|
|
|
|
|
def update_password(user_id: str, new_password: str, keep_session_token: str | None = None) -> None:
|
|
pw_hash = hash_password(new_password)
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET password_hash = ?, force_password_change = 0 WHERE id = ?",
|
|
(pw_hash, user_id),
|
|
)
|
|
if keep_session_token:
|
|
conn.execute(
|
|
"DELETE FROM sessions WHERE user_id = ? AND token != ?",
|
|
(user_id, keep_session_token),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"DELETE FROM sessions WHERE user_id = ?",
|
|
(user_id,),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def delete_user(user_id: str) -> None:
|
|
with db_conn() as conn:
|
|
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
conn.commit()
|