A DVR for YouTube subscriptions, laid out so Jellyfin presents each channel as a show and each video as an episode. Cron-driven, idempotent, with a public admin UI for a non-operator. Verified end to end on susan against three real channels: PO tokens, h264 downloads, Jellyfin resolution from local NFOs with all providers disabled, retention and tombstones. Corrections to the original design handover (specs.md documents each with the evidence, and specs.handover-original.md preserves the original): - The format sort selected 360p. Ranking acodec above res makes `bv*` prefer the combined 360p stream, which carries AAC, over the 720p video-only stream whose acodec is none. vcodec now leads, so a video without h264 at 720p yields h264 lower down rather than VP9 this hardware cannot transcode. - yt-dlp now requires a JS runtime and the yt-dlp-ejs solver scripts, which only ship with the [default] extra. Without them the n challenge fails and the mweb formats disappear entirely. - --flat-playlist carries no upload dates, so the specced client-side date filter for backfill was impossible. Backfill is RSS-first. - skipped_old was terminal, so raising a channel's retention appeared to do nothing. Added an explicit rescan. - is_upcoming premieres now defer and retry instead of being skipped forever. - TubeArchivist is gone, so the media root and the tube.jihakuz.xyz vhost were both reclaimed; the latter still pointed at its dead port. 240 offline tests, no network and no real yt-dlp invocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
5.0 KiB
Python
174 lines
5.0 KiB
Python
"""Password hashing, session cookies and CSRF tokens.
|
|
|
|
The admin UI is publicly reachable over HTTPS, so this has to be real. The design
|
|
goal from specs.md §11 is "log in once per device, effectively never again",
|
|
which means a long-lived signed cookie rather than HTTP basic auth.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
import time
|
|
|
|
SCRYPT_N = 2**14
|
|
SCRYPT_R = 8
|
|
SCRYPT_P = 1
|
|
DKLEN = 32
|
|
|
|
SESSION_MAX_AGE = 365 * 24 * 3600 # one year
|
|
COOKIE_NAME = "yta_session"
|
|
|
|
# Login throttling: after this many consecutive failures from one address, refuse
|
|
# for LOCKOUT_SECONDS regardless of whether the password is right.
|
|
MAX_FAILURES = 5
|
|
LOCKOUT_SECONDS = 60
|
|
|
|
|
|
def _b64(raw: bytes) -> str:
|
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
|
|
|
|
def _unb64(text: str) -> bytes:
|
|
padding = "=" * (-len(text) % 4)
|
|
return base64.urlsafe_b64decode(text + padding)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# passwords
|
|
|
|
|
|
def hash_password(password: str, *, salt: bytes | None = None) -> str:
|
|
salt = salt if salt is not None else secrets.token_bytes(16)
|
|
derived = hashlib.scrypt(
|
|
password.encode("utf-8"),
|
|
salt=salt,
|
|
n=SCRYPT_N,
|
|
r=SCRYPT_R,
|
|
p=SCRYPT_P,
|
|
dklen=DKLEN,
|
|
)
|
|
return f"scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${_b64(salt)}${_b64(derived)}"
|
|
|
|
|
|
def verify_password(stored: str, password: str) -> bool:
|
|
if not stored:
|
|
return False
|
|
try:
|
|
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
|
|
if scheme != "scrypt":
|
|
return False
|
|
derived = hashlib.scrypt(
|
|
password.encode("utf-8"),
|
|
salt=_unb64(salt_b64),
|
|
n=int(n),
|
|
r=int(r),
|
|
p=int(p),
|
|
dklen=len(_unb64(hash_b64)),
|
|
)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
return hmac.compare_digest(derived, _unb64(hash_b64))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# sessions
|
|
|
|
|
|
def new_secret() -> str:
|
|
return _b64(secrets.token_bytes(32))
|
|
|
|
|
|
def _sign(secret: str, payload: bytes) -> str:
|
|
return _b64(hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest())
|
|
|
|
|
|
def issue_session(secret: str, *, issued_at: float | None = None) -> str:
|
|
payload = json.dumps(
|
|
{"iat": int(issued_at if issued_at is not None else time.time())},
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return f"{_b64(payload)}.{_sign(secret, payload)}"
|
|
|
|
|
|
def verify_session(secret: str, token: str, *, now: float | None = None) -> bool:
|
|
if not token or not secret:
|
|
return False
|
|
try:
|
|
payload_b64, signature = token.split(".", 1)
|
|
payload = _unb64(payload_b64)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
if not hmac.compare_digest(_sign(secret, payload), signature):
|
|
return False
|
|
try:
|
|
issued_at = int(json.loads(payload)["iat"])
|
|
except (ValueError, KeyError, TypeError):
|
|
return False
|
|
age = (now if now is not None else time.time()) - issued_at
|
|
return 0 <= age <= SESSION_MAX_AGE
|
|
|
|
|
|
def cookie_header(token: str, *, secure: bool = True) -> str:
|
|
parts = [
|
|
f"{COOKIE_NAME}={token}",
|
|
"Path=/",
|
|
"HttpOnly",
|
|
"SameSite=Lax",
|
|
f"Max-Age={SESSION_MAX_AGE}",
|
|
]
|
|
if secure:
|
|
parts.insert(2, "Secure")
|
|
return "; ".join(parts)
|
|
|
|
|
|
def clear_cookie_header() -> str:
|
|
return f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# CSRF
|
|
|
|
|
|
def csrf_token(secret: str, session_token: str) -> str:
|
|
return _sign(secret, b"csrf:" + session_token.encode("utf-8"))
|
|
|
|
|
|
def verify_csrf(secret: str, session_token: str, submitted: str) -> bool:
|
|
if not submitted:
|
|
return False
|
|
return hmac.compare_digest(csrf_token(secret, session_token), submitted)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# throttling
|
|
|
|
|
|
class LoginThrottle:
|
|
"""In-memory consecutive-failure tracker keyed by remote address."""
|
|
|
|
def __init__(self, max_failures: int = MAX_FAILURES, lockout: int = LOCKOUT_SECONDS):
|
|
self.max_failures = max_failures
|
|
self.lockout = lockout
|
|
self._state: dict[str, tuple[int, float]] = {}
|
|
|
|
def locked(self, key: str, *, now: float | None = None) -> bool:
|
|
failures, last = self._state.get(key, (0, 0.0))
|
|
if failures < self.max_failures:
|
|
return False
|
|
elapsed = (now if now is not None else time.time()) - last
|
|
if elapsed >= self.lockout:
|
|
self._state.pop(key, None)
|
|
return False
|
|
return True
|
|
|
|
def record_failure(self, key: str, *, now: float | None = None) -> None:
|
|
failures, _ = self._state.get(key, (0, 0.0))
|
|
self._state[key] = (failures + 1, now if now is not None else time.time())
|
|
|
|
def record_success(self, key: str) -> None:
|
|
self._state.pop(key, None)
|