114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.
What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
degraded to a software transcode with nothing saying why; a failed audio probe
collapsed a multi-audio file to one track and the player showed no language
picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
own root, leaving every crashed session's segments on disk forever.
Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.
ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.
logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.
tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
85 lines
3.1 KiB
Python
85 lines
3.1 KiB
Python
import hashlib
|
|
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import VerifyMismatchError
|
|
from cryptography.fernet import Fernet
|
|
|
|
from app import logs
|
|
from app.config import settings
|
|
|
|
log = logs.get_logger(__name__)
|
|
|
|
# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
|
|
_ph = PasswordHasher()
|
|
|
|
|
|
def hash_token(raw: str) -> str:
|
|
"""SHA-256 hex digest for storing single-use tokens (email-link tokens, the setup token)
|
|
by hash rather than in cleartext. Not a password hash — these are high-entropy randoms."""
|
|
return hashlib.sha256(raw.encode()).hexdigest()
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
return _ph.hash(password)
|
|
|
|
|
|
def verify_password(password: str, hashed: str | None) -> bool:
|
|
if not hashed:
|
|
return False
|
|
try:
|
|
return _ph.verify(hashed, password)
|
|
except VerifyMismatchError:
|
|
return False # silent-ok: a wrong password. The commonest path through this function.
|
|
except Exception as exc: # noqa: BLE001 — never raise to the caller, whatever went wrong
|
|
# Everything else is a hash this process cannot USE — truncated in the column, written by
|
|
# a different argon2 build. It is answered as "wrong password", so the owner of that
|
|
# account is locked out by a bug and told they mistyped.
|
|
log.warning(
|
|
"a stored password hash could not be verified",
|
|
extra=logs.event("auth.password.hash_unusable", reason=type(exc).__name__),
|
|
)
|
|
return False
|
|
|
|
|
|
_fernet: Fernet | None = (
|
|
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
|
|
)
|
|
|
|
|
|
def _require_fernet() -> Fernet:
|
|
if _fernet is None:
|
|
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
|
|
return _fernet
|
|
|
|
|
|
def encrypt(value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return _require_fernet().encrypt(value.encode()).decode()
|
|
|
|
|
|
def decrypt(value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return _require_fernet().decrypt(value.encode()).decode()
|
|
|
|
|
|
def decrypt_optional(value: str | None) -> str | None:
|
|
"""Like decrypt() but returns None instead of raising when the value is absent or not a valid
|
|
ciphertext (e.g. a legacy plaintext token stored before encryption) — so callers fall back to
|
|
a refresh rather than erroring."""
|
|
if not value:
|
|
return None
|
|
try:
|
|
return _require_fernet().decrypt(value.encode()).decode()
|
|
except Exception as exc: # noqa: BLE001 — callers fall back to a refresh, never to an error
|
|
# Info rather than warning because the docstring's legacy-plaintext case comes through
|
|
# here too and is harmless. What is NOT harmless looks identical: a rotated
|
|
# TOKEN_ENCRYPTION_KEY turns every stored token unreadable at once, and every account
|
|
# then reads as "not connected" with nothing saying the key is the reason.
|
|
log.info(
|
|
"a stored ciphertext could not be decrypted",
|
|
extra=logs.event("security.decrypt.failed", reason=type(exc).__name__),
|
|
)
|
|
return None
|