fix(i18n): every refusal the API answers is structured and said in the reader's language

~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
This commit is contained in:
2026-09-19 07:34:17 +02:00
parent dc4e8b9fcd
commit 7ae5c57b6b
39 changed files with 716 additions and 378 deletions
+6 -8
View File
@@ -14,6 +14,7 @@ from app.db import get_db
from app.models import Invite, LogEvent, User from app.models import Invite, LogEvent, User
from app.security import decrypt_optional from app.security import decrypt_optional
from app.utils import canonical_email from app.utils import canonical_email
from app.refusal import refused
PASSWORD_MIN_LEN = 10 PASSWORD_MIN_LEN = 10
@@ -341,19 +342,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User: def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated") raise refused(401, "not_authenticated")
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None or not user.is_active or user.is_suspended: if user is None or not user.is_active or user.is_suspended:
# Suspended/deactivated mid-session → end it so the block takes effect at once. # Suspended/deactivated mid-session → end it so the block takes effect at once.
_end_account_session(request, user_id, is_default) _end_account_session(request, user_id, is_default)
raise HTTPException(status_code=401, detail="Not authenticated") raise refused(401, "not_authenticated")
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a # SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an # password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too. # account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
epochs = request.session.get("epochs") or {} epochs = request.session.get("epochs") or {}
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0): if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
_end_account_session(request, user_id, is_default) _end_account_session(request, user_id, is_default)
raise HTTPException(status_code=401, detail="Not authenticated") raise refused(401, "not_authenticated")
# Always keep the session default in the switchable list — covers sessions created before # Always keep the session default in the switchable list — covers sessions created before
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty. # multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
default_id = request.session.get("user_id") default_id = request.session.get("user_id")
@@ -418,10 +419,7 @@ def require_write_scope(user: User = Depends(require_human)) -> User:
checks that had drifted into three different 403 messages across channels/playlists. Layers on checks that had drifted into three different 403 messages across channels/playlists. Layers on
`require_human` because writing to YouTube needs a real, non-demo identity.""" `require_human` because writing to YouTube needs a real, non-demo identity."""
if not has_write_scope(user): if not has_write_scope(user):
raise HTTPException( raise refused(403, "youtube_editing_off")
status_code=403,
detail="Enable YouTube editing in Settings first.",
)
return user return user
@@ -429,7 +427,7 @@ def admin_user(user: User = Depends(current_user)) -> User:
"""Dependency: require the signed-in user to be an admin. Single source of truth for the """Dependency: require the signed-in user to be an admin. Single source of truth for the
admin gate — every admin route/action depends on this rather than re-checking the role.""" admin gate — every admin route/action depends on this rather than re-checking the role."""
if user.role != "admin": if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise refused(403, "admin_only")
return user return user
+37
View File
@@ -0,0 +1,37 @@
"""A request the app refuses, said in a way the UI can put in the reader's language.
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
whatever sentence the route happened to hand-write. `refused` is the generic family
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
languages — so the rule is enforced, not described.
"""
from fastapi import HTTPException
def refused(status: int, reason: str, **params: object) -> HTTPException:
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see."""
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's
# contract test can read every reason this can produce.
_SETTING_REASONS = {
"not_a_number": "setting_not_a_number",
"too_low": "setting_too_low",
"too_high": "setting_too_high",
"not_a_choice": "setting_not_a_choice",
"no_encryption_key": "no_encryption_key",
}
def setting_refused(exc) -> HTTPException:
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
+7 -5
View File
@@ -46,18 +46,20 @@ def owned_or_404(
) -> M: ) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone """Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger. else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
Generalizes the near-identical per-route ownership helpers (e.g. plex's It replaced the near-identical per-route ownership helpers, and is called directly — a thin
`_own_playlist_or_404`, the saved-views owner check).""" `_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
obj = get_or_404(db, model, id_, what=what) obj = get_or_404(db, model, id_, what=what)
if getattr(obj, owner_attr) != user.id: if getattr(obj, owner_attr) != user.id:
raise not_found(what) raise not_found(what)
return obj return obj
def youtube_refusal(exc: YouTubeError) -> HTTPException: def youtube_refusal(exc: Exception) -> HTTPException:
"""The answer to an action YouTube refused: a 422 whose reason the UI translates """The answer to an action YouTube refused: a 422 whose reason the UI translates
(`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it — (`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it —
a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at
all (a raw 500 from the manual syncs). The upstream failure is already logged where it all (a raw 500 from the manual syncs). The upstream failure is already logged where it
happened (`youtube.api.error`).""" happened (`youtube.api.error`). A failure that is not the Data API's (the search scraper's) is
return HTTPException(status_code=422, detail={"code": "youtube", "reason": refusal_reason(exc)}) YouTube being unavailable to us — the retryable answer."""
reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable"
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
+16 -20
View File
@@ -39,6 +39,7 @@ from app.routes.auth import (
send_verification_link, send_verification_link,
) )
from app.utils import canonical_email, iso, valid_email from app.utils import canonical_email, iso, valid_email
from app.refusal import refused
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -179,7 +180,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and # sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
# mailing a rejection to an already-active account. # mailing a rejection to an already-active account.
if inv.status != "pending": if inv.status != "pending":
raise HTTPException(status_code=409, detail="Invite already decided") raise refused(409, "invite_decided")
inv.status = "approved" if approved else "denied" inv.status = "approved" if approved else "denied"
inv.decided_at = datetime.now(timezone.utc) inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email inv.decided_by = admin.email
@@ -242,7 +243,7 @@ def add_invite(
"""Manually whitelist an email (approved straight away). Convenience for the admin.""" """Manually whitelist an email (approved straight away). Convenience for the admin."""
email = body.email.strip().lower() email = body.email.strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries # Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@), # exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
# which is how an admin ends up approving the same human twice and still seeing them bounce. # which is how an admin ends up approving the same human twice and still seeing them bounce.
@@ -366,10 +367,7 @@ def delete_invite(
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it.""" promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = get_or_404(db, Invite, invite_id, what="invite") inv = get_or_404(db, Invite, invite_id, what="invite")
if _accounts_for(db, [inv.email]): if _accounts_for(db, [inv.email]):
raise HTTPException( raise refused(409, "address_has_account")
status_code=409,
detail="That address already has an account — delete the account instead.",
)
still_allowed_by_env = _env_allowed(inv.email) still_allowed_by_env = _env_allowed(inv.email)
audit.record( audit.record(
db, db,
@@ -435,19 +433,19 @@ def set_user_role(
account, and can't remove the last remaining admin. Emails the user when it actually changes.""" account, and can't remove the last remaining admin. Emails the user when it actually changes."""
role = body.role role = body.role
if role not in ("user", "admin"): if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") raise refused(400, "invalid_role")
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.") raise refused(400, "demo_role")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.") raise refused(400, "own_role")
if ( if (
target.role == "admin" target.role == "admin"
and role == "user" and role == "user"
and not target.is_suspended and not target.is_suspended
and count_admins(db, active_only=True) <= 1 and count_admins(db, active_only=True) <= 1
): ):
raise HTTPException(status_code=400, detail="Can't remove the last admin.") raise refused(400, "last_admin")
changed = target.role != role changed = target.role != role
if changed: if changed:
audit.record( audit.record(
@@ -493,16 +491,16 @@ def set_user_suspended(
suspended = body.suspended suspended = body.suspended
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be suspended.") raise refused(400, "demo_suspend")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.") raise refused(400, "own_suspend")
if ( if (
suspended suspended
and target.role == "admin" and target.role == "admin"
and not target.is_suspended and not target.is_suspended
and count_admins(db, active_only=True) <= 1 and count_admins(db, active_only=True) <= 1
): ):
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") raise refused(400, "last_active_admin")
was_suspended = target.is_suspended was_suspended = target.is_suspended
if was_suspended != suspended: if was_suspended != suspended:
audit.record( audit.record(
@@ -537,17 +535,15 @@ def admin_delete_user(
not the demo account, not yourself (use Settings → Account), and not the last admin.""" not the demo account, not yourself (use Settings → Account), and not the last admin."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be deleted.") raise refused(400, "demo_delete")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException( raise refused(400, "own_delete")
status_code=400, detail="Delete your own account from Settings → Account."
)
if ( if (
target.role == "admin" target.role == "admin"
and not target.is_suspended and not target.is_suspended
and count_admins(db, active_only=True) <= 1 and count_admins(db, active_only=True) <= 1
): ):
raise HTTPException(status_code=400, detail="Can't delete the last admin.") raise refused(400, "last_admin")
# Record BEFORE the purge — the target row (and its email) is gone afterwards. # Record BEFORE the purge — the target row (and its email) is gone afterwards.
audit.record( audit.record(
db, db,
@@ -691,7 +687,7 @@ def activate_user(
`_activate_user_by_email`). What was missing was any way to know they had to.""" `_activate_user_by_email`). What was missing was any way to know they had to."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be activated.") raise refused(400, "demo_activate")
if target.is_active: if target.is_active:
return _serialize_user(target) # idempotent: a double click is not an error return _serialize_user(target) # idempotent: a double click is not an error
if not is_allowed(db, target.email): if not is_allowed(db, target.email):
@@ -788,7 +784,7 @@ def add_demo_whitelist(
sign-in). Idempotent — re-adding an existing email just returns it.""" sign-in). Idempotent — re-adding an existing email just returns it."""
email = body.email.strip().lower() email = body.email.strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none() row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
if row is None: if row is None:
row = DemoWhitelist( row = DemoWhitelist(
+8 -13
View File
@@ -43,6 +43,7 @@ from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter, client_key from app.ratelimit import RateLimiter, client_key
from app.security import encrypt, hash_password, hash_token, verify_password from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import canonical_email, valid_email from app.utils import canonical_email, valid_email
from app.refusal import refused
VERIFY_TTL = timedelta(hours=24) VERIFY_TTL = timedelta(hours=24)
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is # A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
@@ -378,7 +379,7 @@ async def callback(
userinfo = token.get("userinfo") userinfo = token.get("userinfo")
if not userinfo or not userinfo.get("sub"): if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google") raise refused(400, "google_no_userinfo")
sub = userinfo["sub"] sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower() email = (userinfo.get("email") or "").lower()
@@ -653,7 +654,7 @@ async def request_access(
endpoint can't be probed for throttling either.""" endpoint can't be probed for throttling either."""
email = (body.email or "").strip().lower() email = (body.email or "").strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
if not _request_access_limiter.allow(client_key(request)): if not _request_access_limiter.allow(client_key(request)):
return {"status": "pending"} return {"status": "pending"}
if is_allowed(db, email): if is_allowed(db, email):
@@ -815,14 +816,11 @@ def register(
password = body.password or "" password = body.password or ""
# Input validation is safe to reveal (independent of whether the email exists). # Input validation is safe to reveal (independent of whether the email exists).
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
if len(password) < PASSWORD_MIN_LEN: if len(password) < PASSWORD_MIN_LEN:
raise HTTPException( raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if not sysconfig.get_bool(db, "allow_registration"): if not sysconfig.get_bool(db, "allow_registration"):
raise HTTPException(status_code=403, detail="Registration is currently closed.") raise refused(403, "registration_closed")
if not _register_limiter.allow(client_key(request)): if not _register_limiter.allow(client_key(request)):
return {"status": "ok"} # silently throttle; uniform response return {"status": "ok"} # silently throttle; uniform response
@@ -1293,13 +1291,10 @@ def set_password(
raise HTTPException(status_code=403, detail={"code": "demo"}) raise HTTPException(status_code=403, detail={"code": "demo"})
new_password = body.password or "" new_password = body.password or ""
if len(new_password) < PASSWORD_MIN_LEN: if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException( raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if user.password_hash: if user.password_hash:
if not verify_password(body.current_password or "", user.password_hash): if not verify_password(body.current_password or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.") raise refused(403, "wrong_password")
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
user.password_revoked_at = None # same as the reset path: a password exists again user.password_revoked_at = None # same as the reset path: a password exists again
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so # SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
+6 -11
View File
@@ -4,7 +4,7 @@ hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side w
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -31,6 +31,7 @@ from app.sync.runner import backfill_channel_now, run_recent_backfill
from app.sync.subscriptions import apply_channel_details from app.sync.subscriptions import apply_channel_details
from app.utils import iso from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
router = APIRouter(prefix="/api/channels", tags=["channels"]) router = APIRouter(prefix="/api/channels", tags=["channels"])
@@ -383,12 +384,9 @@ def explore_channel(
backfill quota reserve; blocked for the demo account (it spends shared quota).""" backfill quota reserve; blocked for the demo account (it spends shared quota)."""
channel = get_or_404(db, Channel, channel_id, what="channel") channel = get_or_404(db, Channel, channel_id, what="channel")
if _is_blocked(db, user, channel_id): if _is_blocked(db, user, channel_id):
raise HTTPException(status_code=403, detail="You've blocked this channel.") raise refused(403, "channel_blocked")
if quota.backfill_reserve_reached(): if quota.backfill_reserve_reached():
raise HTTPException( raise refused(429, "quota_reserve")
status_code=429,
detail="The shared daily quota reserve is reached — try exploring later.",
)
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the # Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed. # videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
@@ -562,7 +560,7 @@ def subscribe(
) )
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None: if existing is not None:
raise HTTPException(status_code=409, detail="Already subscribed to this channel.") raise refused(409, "already_subscribed")
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
YouTubeClient(db, user) as yt, YouTubeClient(db, user) as yt,
@@ -634,10 +632,7 @@ def unsubscribe(
should Hide instead.""" should Hide instead."""
sub = _user_subscription(db, user, channel_id) sub = _user_subscription(db, user, channel_id)
if not sub.yt_subscription_id: if not sub.yt_subscription_id:
raise HTTPException( raise refused(400, "no_subscription_id")
status_code=400,
detail="No YouTube subscription id on record — sync subscriptions first.",
)
try: try:
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
+12 -12
View File
@@ -4,7 +4,7 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur
import re import re
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -17,6 +17,9 @@ from app.audit import AuditAction
from app.auth import admin_user from app.auth import admin_user
from app.db import get_db from app.db import get_db
from app.models import User from app.models import User
from app.refusal import refused
from app.routes._common import not_found
from app.refusal import setting_refused
router = APIRouter(prefix="/api/admin/config", tags=["admin"]) router = APIRouter(prefix="/api/admin/config", tags=["admin"])
@@ -59,19 +62,16 @@ def set_config(
) -> dict: ) -> dict:
s = sysconfig.spec(key) s = sysconfig.spec(key)
if s is None: if s is None:
raise HTTPException(status_code=404, detail="Unknown config key") raise not_found("config_key")
if "value" not in body.model_fields_set: if "value" not in body.model_fields_set:
raise HTTPException(status_code=400, detail="Missing 'value'") raise refused(400, "value_missing")
if s.secret and not sysconfig.secrets_manageable(): if s.secret and not sysconfig.secrets_manageable():
raise HTTPException( raise refused(400, "no_encryption_key")
status_code=400,
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
)
old = None if s.secret else sysconfig.get(db, key) old = None if s.secret else sysconfig.get(db, key)
try: try:
sysconfig.set_value(db, key, body.value) sysconfig.set_value(db, key, body.value)
except (TypeError, ValueError) as exc: except sysconfig.InvalidSetting as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") raise setting_refused(exc) from None
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged # Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
# but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when # but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when
# a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets # a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets
@@ -101,7 +101,7 @@ def reset_config(
) -> dict: ) -> dict:
s = sysconfig.spec(key) s = sysconfig.spec(key)
if s is None: if s is None:
raise HTTPException(status_code=404, detail="Unknown config key") raise not_found("config_key")
old = None if s.secret else sysconfig.get(db, key) old = None if s.secret else sysconfig.get(db, key)
sysconfig.reset(db, key) sysconfig.reset(db, key)
audit.record( audit.record(
@@ -126,8 +126,8 @@ def send_test_email(
"""Send a test email to the requesting admin using the current (DB or env) SMTP config — """Send a test email to the requesting admin using the current (DB or env) SMTP config —
lets the admin verify the settings actually work.""" lets the admin verify the settings actually work."""
if not email_mod.email_enabled(): if not email_mod.email_enabled():
raise HTTPException(status_code=400, detail="SMTP is not configured.") raise refused(400, "smtp_off")
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user)) sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
if not sent: if not sent:
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.") raise refused(422, "smtp_failed")
return {"sent": True, "to": user.email} return {"sent": True, "to": user.email}
+4 -4
View File
@@ -11,7 +11,7 @@ thing it exists to remove.
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -23,7 +23,7 @@ from app.discover import hero as hero_mod
from app.discover import pins, rails from app.discover import pins, rails
from app.discover.budget import Budget, DEFAULT_BUDGET from app.discover.budget import Budget, DEFAULT_BUDGET
from app.models import ComfortPin, User from app.models import ComfortPin, User
from app.routes._common import owned_or_404 from app.routes._common import owned_or_404, not_found
router = APIRouter(prefix="/api/discover", tags=["discover"]) router = APIRouter(prefix="/api/discover", tags=["discover"])
@@ -125,7 +125,7 @@ def add_pin(
constraint is the mechanism, not the message.""" constraint is the mechanism, not the message."""
resolved = pins.resolve_ref(db, user, body.kind, body.ref) resolved = pins.resolve_ref(db, user, body.kind, body.ref)
if resolved is None: if resolved is None:
raise HTTPException(status_code=404, detail="Unknown item") raise not_found("item")
column, value = resolved column, value = resolved
existing = db.execute( existing = db.execute(
@@ -157,7 +157,7 @@ def add_pin(
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed # between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check # item), which trips the FK. Semantically that is the same "no such thing" the pre-check
# returns above — re-raising handed the user a 500 they could do nothing with. # returns above — re-raising handed the user a 500 they could do nothing with.
raise HTTPException(status_code=404, detail="Unknown item") raise not_found("item")
return {"ok": True, "id": winner.id, "created": False} return {"ok": True, "id": winner.id, "created": False}
return {"ok": True, "id": pin.id, "created": True} return {"ok": True, "id": pin.id, "created": True}
+26 -25
View File
@@ -42,6 +42,7 @@ from app.routes._common import get_or_404, not_found, owned_or_404
from app.security import hash_password from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso from app.utils import iso
from app.refusal import refused
router = APIRouter(prefix="/api/downloads", tags=["downloads"]) router = APIRouter(prefix="/api/downloads", tags=["downloads"])
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"]) admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
@@ -63,7 +64,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
as a raw URL for yt-dlp's generic extractor.""" as a raw URL for yt-dlp's generic extractor."""
raw = (raw or "").strip() raw = (raw or "").strip()
if not raw: if not raw:
raise HTTPException(status_code=400, detail="A video link or id is required.") raise refused(400, "link_required")
if _VIDEO_ID.match(raw): if _VIDEO_ID.match(raw):
return "youtube", raw return "youtube", raw
if raw.startswith(("http://", "https://")): if raw.startswith(("http://", "https://")):
@@ -93,7 +94,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
if _VIDEO_ID.match(vid): if _VIDEO_ID.match(vid):
return "youtube", vid return "youtube", vid
return "url", raw return "url", raw
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.") raise refused(400, "invalid_link")
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
@@ -233,7 +234,7 @@ def create_profile(
name = body.name.strip() name = body.name.strip()
spec = body.spec spec = body.spec
if not name: if not name:
raise HTTPException(status_code=400, detail="A profile name is required.") raise refused(400, "profile_name_required")
maxpos = db.execute( maxpos = db.execute(
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where( select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
DownloadProfile.user_id == user.id DownloadProfile.user_id == user.id
@@ -261,16 +262,16 @@ def update_profile(
) -> dict: ) -> dict:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
if p is None or p.is_builtin or p.user_id != user.id: if p is None or p.is_builtin or p.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown profile") raise not_found("profile")
sent = body.model_fields_set sent = body.model_fields_set
if "name" in sent: if "name" in sent:
name = (body.name or "").strip() name = (body.name or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="A profile name is required.") raise refused(400, "profile_name_required")
p.name = name[:80] p.name = name[:80]
if "spec" in sent: if "spec" in sent:
if body.spec is None: if body.spec is None:
raise HTTPException(status_code=400, detail="spec must be an object") raise refused(400, "spec_not_object")
p.spec = body.spec p.spec = body.spec
db.commit() db.commit()
return _serialize_profile(p) return _serialize_profile(p)
@@ -282,7 +283,7 @@ def delete_profile(
) -> dict: ) -> dict:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
if p is None or p.is_builtin or p.user_id != user.id: if p is None or p.is_builtin or p.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown profile") raise not_found("profile")
db.delete(p) db.delete(p)
db.commit() db.commit()
return {"deleted": profile_id} return {"deleted": profile_id}
@@ -312,7 +313,7 @@ def set_credential(
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is """Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
never echoed back, logged, or audited — only the fact that it changed.""" never echoed back, logged, or audited — only the fact that it changed."""
if not dlcreds.is_supported_host(host_key): if not dlcreds.is_supported_host(host_key):
raise HTTPException(status_code=404, detail="Unknown host") raise not_found("host")
try: try:
cleaned = dlcreds.validate_cookies(body.cookies) cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e: except dlcreds.InvalidCookies as e:
@@ -334,7 +335,7 @@ def delete_credential(
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db) host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict: ) -> dict:
if not dlcreds.is_supported_host(host_key): if not dlcreds.is_supported_host(host_key):
raise HTTPException(status_code=404, detail="Unknown host") raise not_found("host")
if dlcreds.delete_credential(db, user.id, host_key): if dlcreds.delete_credential(db, user.id, host_key):
audit.record( audit.record(
db, db,
@@ -355,7 +356,7 @@ def _resolve_spec(
if profile_id is not None: if profile_id is not None:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
if p is None or (not p.is_builtin and p.user_id != user.id): if p is None or (not p.is_builtin and p.user_id != user.id):
raise HTTPException(status_code=404, detail="Unknown profile") raise not_found("profile")
return p.spec, p.id return p.spec, p.id
if spec is not None: if spec is not None:
return spec, None return spec, None
@@ -366,7 +367,7 @@ def _resolve_spec(
.limit(1) .limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if fallback is None: if fallback is None:
raise HTTPException(status_code=400, detail="No download profile available.") raise refused(400, "no_profile")
return fallback.spec, fallback.id return fallback.spec, fallback.id
@@ -477,7 +478,7 @@ def enqueue_edit(
source file is only read, never modified, so editing a shared video is safe.""" source file is only read, never modified, so editing a shared video is safe."""
source_job = _accessible_job(db, user, body.source_job_id) source_job = _accessible_job(db, user, body.source_job_id)
if source_job.status != "done" or source_job.asset_id is None: if source_job.status != "done" or source_job.asset_id is None:
raise HTTPException(status_code=409, detail="You can only edit a finished download.") raise refused(409, "edit_unfinished")
spec = body.edit_spec spec = body.edit_spec
display_name = (body.display_name or "").strip() or None display_name = (body.display_name or "").strip() or None
try: try:
@@ -661,9 +662,9 @@ def redownload_download(
if job.job_kind in ("edit", "upload"): if job.job_kind in ("edit", "upload"):
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the # Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail. # source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.") raise refused(409, "not_redownloadable")
if job.status in ("queued", "running", "paused"): if job.status in ("queued", "running", "paused"):
raise HTTPException(status_code=409, detail="This download is still in progress.") raise refused(409, "still_downloading")
service.redownload(db, job) service.redownload(db, job)
return _serialize_job(db, job) return _serialize_job(db, job)
@@ -715,10 +716,10 @@ def download_file(
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=409, detail="This download isn't ready.") raise refused(409, "download_not_ready")
path = storage.safe_abs_path(settings.download_root, asset.rel_path) path = storage.safe_abs_path(settings.download_root, asset.rel_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="File is no longer available.") raise not_found("file")
filename = storage.download_filename( filename = storage.download_filename(
job.display_name or asset.title or asset.source_ref, asset.container, path job.display_name or asset.title or asset.source_ref, asset.container, path
@@ -757,7 +758,7 @@ def storyboard_meta(
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=409, detail="This download isn't ready.") raise refused(409, "download_not_ready")
meta = editmod.ensure_storyboard(asset, settings.download_root) meta = editmod.ensure_storyboard(asset, settings.download_root)
if meta is None: if meta is None:
return {"available": False} return {"available": False}
@@ -778,10 +779,10 @@ def poster_image(job_id: int, user: User = Depends(require_human), db: Session =
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.poster_path: if asset is None or not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.") raise not_found("poster")
path = storage.safe_abs_path(settings.download_root, asset.poster_path) path = storage.safe_abs_path(settings.download_root, asset.poster_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="No poster.") raise not_found("poster")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@@ -792,10 +793,10 @@ def storyboard_image(
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.storyboard_path: if asset is None or not asset.storyboard_path:
raise HTTPException(status_code=404, detail="No filmstrip.") raise not_found("filmstrip")
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path) path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="No filmstrip.") raise not_found("filmstrip")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@@ -815,10 +816,10 @@ def share_download(
) -> dict: ) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download") job = owned_or_404(db, DownloadJob, job_id, user, what="download")
if job.status != "done" or job.asset_id is None: if job.status != "done" or job.asset_id is None:
raise HTTPException(status_code=409, detail="Only a finished download can be shared.") raise refused(409, "share_unfinished")
target = body.email.strip().lower() target = body.email.strip().lower()
if not target: if not target:
raise HTTPException(status_code=400, detail="A recipient email is required.") raise refused(400, "recipient_required")
recipient = db.execute( recipient = db.execute(
select(User).where(func.lower(User.email) == target) select(User).where(func.lower(User.email) == target)
).scalar_one_or_none() ).scalar_one_or_none()
@@ -827,7 +828,7 @@ def share_download(
if not is_messageable_user(recipient): if not is_messageable_user(recipient):
raise not_found("recipient") raise not_found("recipient")
if recipient.id == user.id: if recipient.id == user.id:
raise HTTPException(status_code=400, detail="That's already your download.") raise refused(400, "share_own")
exists = db.execute( exists = db.execute(
select(DownloadShare.id).where( select(DownloadShare.id).where(
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
@@ -972,7 +973,7 @@ def create_link(
) -> dict: ) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download") job = owned_or_404(db, DownloadJob, job_id, user, what="download")
if job.status != "done" or job.asset_id is None: if job.status != "done" or job.asset_id is None:
raise HTTPException(status_code=409, detail="Only a finished download can be shared.") raise refused(409, "share_unfinished")
pw = body.password.strip() pw = body.password.strip()
link = DownloadLink( link = DownloadLink(
job_id=job_id, job_id=job_id,
+7 -6
View File
@@ -1,6 +1,6 @@
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -29,10 +29,11 @@ from app.models import (
Video, Video,
VideoState, VideoState,
) )
from app.routes._common import get_or_404, youtube_refusal from app.routes._common import get_or_404, youtube_refusal, not_found
from app.sync.videos import parse_iso8601_duration from app.sync.videos import parse_iso8601_duration
from app.utils import iso from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
router = APIRouter(prefix="/api", tags=["feed"]) router = APIRouter(prefix="/api", tags=["feed"])
@@ -120,7 +121,7 @@ def get_feed(
vals, cursor_id = decode_cursor(keys, cursor) vals, cursor_id = decode_cursor(keys, cursor)
except ValueError: except ValueError:
# `from None`: the ValueError is an expected bad-input signal, not an error to chain. # `from None`: the ValueError is an expected bad-input signal, not an error to chain.
raise HTTPException(status_code=400, detail="Invalid feed cursor") from None raise refused(400, "invalid_cursor") from None
for k, v in zip(keys, vals): for k, v in zip(keys, vals):
k["value"] = v k["value"] = v
query = query.where(keyset_predicate(keys, cursor_id)) query = query.where(keyset_predicate(keys, cursor_id))
@@ -235,7 +236,7 @@ def set_video_state(
) -> dict: ) -> dict:
status = body.status status = body.status
if status not in VALID_STATES: if status not in VALID_STATES:
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}") raise refused(400, "invalid_status")
get_or_404(db, Video, video_id, what="video") get_or_404(db, Video, video_id, what="video")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -402,7 +403,7 @@ def get_video_detail(
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the # The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the
# demo account can't reach it — it just sees "unknown" for a video we don't already store. # demo account can't reach it — it just sees "unknown" for a video we don't already store.
if user.is_demo: if user.is_demo:
raise HTTPException(status_code=404, detail="Unknown video") raise not_found("video")
try: try:
with ( with (
@@ -413,7 +414,7 @@ def get_video_detail(
except YouTubeError as exc: except YouTubeError as exc:
raise youtube_refusal(exc) raise youtube_refusal(exc)
if not items: if not items:
raise HTTPException(status_code=404, detail="Unknown video") raise not_found("video")
snippet = items[0].get("snippet", {}) snippet = items[0].get("snippet", {})
stats = items[0].get("statistics", {}) stats = items[0].get("statistics", {})
+9 -12
View File
@@ -24,6 +24,8 @@ from app import sysconfig
from app.db import get_db from app.db import get_db
from app.models import Invite, User from app.models import Invite, User
from app.utils import iso from app.utils import iso
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/me", tags=["me"]) router = APIRouter(prefix="/api/me", tags=["me"])
@@ -70,7 +72,7 @@ def switch_account(
here) are allowed, and access is re-checked in case the invite was revoked since.""" here) are allowed, and access is re-checked in case the invite was revoked since."""
target = body.user_id target = body.user_id
if target not in (request.session.get("account_ids") or []): if target not in (request.session.get("account_ids") or []):
raise HTTPException(status_code=403, detail="Not an account you've signed into here.") raise refused(403, "not_your_account")
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is # An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session # refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole # default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
@@ -78,22 +80,20 @@ def switch_account(
u = db.get(User, target) u = db.get(User, target)
if u is None: if u is None:
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException(status_code=404, detail="That account no longer exists.") raise not_found("account")
if not is_allowed(db, u.email): if not is_allowed(db, u.email):
raise HTTPException(status_code=403, detail="That account no longer has access.") raise refused(403, "account_no_access")
if not u.is_active: if not u.is_active:
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException(status_code=403, detail="That account no longer has access.") raise refused(403, "account_no_access")
if u.is_suspended: if u.is_suspended:
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException(status_code=403, detail="That account is suspended.") raise refused(403, "account_suspended")
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch. # Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
epochs = request.session.get("epochs") or {} epochs = request.session.get("epochs") or {}
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0): if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException( raise refused(403, "account_signed_out")
status_code=403, detail="That account was signed out elsewhere — sign in again."
)
request.session["user_id"] = target request.session["user_id"] = target
return {"ok": True, "user_id": target} return {"ok": True, "user_id": target}
@@ -181,10 +181,7 @@ def delete_my_account(
if user.is_demo: if user.is_demo:
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"}) raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
if user.role == "admin" and count_admins(db) <= 1: if user.role == "admin" and count_admins(db) <= 1:
raise HTTPException( raise refused(400, "only_admin_delete")
status_code=400,
detail="You're the only admin — promote another admin before deleting your account.",
)
user_id = user.id user_id = user.id
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row. # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
+15 -13
View File
@@ -16,7 +16,7 @@ routing and unread counts.
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import and_, case, delete, func, or_, select from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -29,6 +29,8 @@ from app.realtime import manager
from app.security import verify_password from app.security import verify_password
from app.userscope import is_messageable_user, messageable_clauses from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso from app.utils import iso
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/messages", tags=["messages"]) router = APIRouter(prefix="/api/messages", tags=["messages"])
@@ -152,7 +154,7 @@ def setup_keys(
"""Store this user's public key + passphrase-wrapped private key. Set-once: changing the """Store this user's public key + passphrase-wrapped private key. Set-once: changing the
key would orphan every existing conversation key, so re-setup is rejected here.""" key would orphan every existing conversation key, so re-setup is rejected here."""
if db.get(MessageKey, user.id) is not None: if db.get(MessageKey, user.id) is not None:
raise HTTPException(status_code=409, detail="Secure messaging is already set up.") raise refused(409, "messaging_set_up")
db.add( db.add(
MessageKey( MessageKey(
user_id=user.id, user_id=user.id,
@@ -185,11 +187,11 @@ def reset_keys(
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
client-side danger confirmation.""" client-side danger confirmation."""
if not _reset_key_limiter.allow(str(user.id)): if not _reset_key_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") raise refused(429, "too_many_attempts")
if user.password_hash and not verify_password( if user.password_hash and not verify_password(
payload.current_password or "", user.password_hash payload.current_password or "", user.password_hash
): ):
raise HTTPException(status_code=403, detail="Current password is incorrect.") raise refused(403, "wrong_password")
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a # Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit. # fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
db.execute( db.execute(
@@ -223,10 +225,10 @@ def public_key(
p = db.get(User, user_id) p = db.get(User, user_id)
has_history = db.scalar(select(Message.id).where(cond).limit(1)) has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history): if p is None or (not is_messageable_user(p) and not has_history):
raise HTTPException(status_code=404, detail="User not found") raise not_found("user")
k = db.get(MessageKey, user_id) k = db.get(MessageKey, user_id)
if k is None: if k is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") raise refused(404, "no_messaging")
return {"user_id": user_id, "public_key": k.public_key} return {"user_id": user_id, "public_key": k.public_key}
@@ -344,7 +346,7 @@ def get_thread(
# still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle. # still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle.
has_history = db.scalar(select(Message.id).where(cond).limit(1)) has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history): if p is None or (not is_messageable_user(p) and not has_history):
raise HTTPException(status_code=404, detail="User not found") raise not_found("user")
partner = _serialize_user(p) partner = _serialize_user(p)
msgs = ( msgs = (
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all() db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
@@ -374,20 +376,20 @@ def send_message(
"""Send an end-to-end-encrypted message. The server stores only the ciphertext the client """Send an end-to-end-encrypted message. The server stores only the ciphertext the client
produced, then pushes it live to the recipient (and the sender's other devices).""" produced, then pushes it live to the recipient (and the sender's other devices)."""
if not _send_limiter.allow(f"msg:{user.id}"): if not _send_limiter.allow(f"msg:{user.id}"):
raise HTTPException(status_code=429, detail="Too many messages — slow down a moment.") raise refused(429, "message_rate")
ct = (payload.ciphertext or "").strip() ct = (payload.ciphertext or "").strip()
iv = (payload.iv or "").strip() iv = (payload.iv or "").strip()
if not ct or not iv: if not ct or not iv:
raise HTTPException(status_code=400, detail="Message is empty.") raise refused(400, "message_empty")
if len(ct) > MAX_CIPHERTEXT: if len(ct) > MAX_CIPHERTEXT:
raise HTTPException(status_code=400, detail="Message is too long.") raise refused(400, "message_too_long")
if payload.recipient_id == user.id: if payload.recipient_id == user.id:
raise HTTPException(status_code=400, detail="You can't message yourself.") raise refused(400, "message_self")
recipient = db.get(User, payload.recipient_id) recipient = db.get(User, payload.recipient_id)
if not is_messageable_user(recipient): if not is_messageable_user(recipient):
raise HTTPException(status_code=404, detail="Recipient not available.") raise not_found("recipient")
if db.get(MessageKey, recipient.id) is None: if db.get(MessageKey, recipient.id) is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") raise refused(404, "no_messaging")
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv) m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
db.add(m) db.add(m)
db.commit() db.commit()
+2 -2
View File
@@ -1,11 +1,11 @@
"""Shared helpers, serializers and constants for the plex route package.""" """Shared helpers, serializers and constants for the plex route package."""
from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs from app import logs
from app.models import PlexItem from app.models import PlexItem
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.routes._common import not_found
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
@@ -13,7 +13,7 @@ log = logs.get_logger(__name__)
def item_or_404(db: Session, rating_key: str) -> PlexItem: def item_or_404(db: Session, rating_key: str) -> PlexItem:
it = by_rating_key(db, PlexItem, rating_key) it = by_rating_key(db, PlexItem, rating_key)
if it is None: if it is None:
raise HTTPException(status_code=404, detail="Unknown Plex item") raise not_found("plex_item")
return it return it
+15 -7
View File
@@ -2,12 +2,12 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import logs, sysconfig
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
@@ -20,6 +20,9 @@ from app.plex import sync as plex_sync
from app.plex import watch_sync as plex_watch from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.utils import iso from app.utils import iso
from app.refusal import refused
log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -35,10 +38,15 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)
info = plex.server_info() info = plex.server_info()
sections = plex.sections() sections = plex.sections()
media_check = _media_check(db, plex, sections) media_check = _media_check(db, plex, sections)
except PlexNotConfigured as e: except PlexNotConfigured:
raise HTTPException(status_code=400, detail=str(e)) raise refused(400, "plex_not_configured") from None
except PlexError as e: except PlexError as e:
raise HTTPException(status_code=422, detail=f"Plex connection failed: {e}") # What the upstream said is for the log, not the toast: it can quote the server URL back.
log.warning(
"the Plex connection test failed",
extra=logs.event("plex.admin.connection_failed", reason=type(e).__name__, error=str(e)),
)
raise refused(422, "plex_unreachable") from None
return { return {
"ok": True, "ok": True,
"server_name": info.get("friendlyName") or info.get("title1") or "Plex", "server_name": info.get("friendlyName") or info.get("title1") or "Plex",
@@ -129,7 +137,7 @@ def watch_link_set(
admin token, so no separate Plex login. Enabling it for the first time runs the one-time admin token, so no separate Plex login. Enabling it for the first time runs the one-time
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode.""" "Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
if not sysconfig.get_bool(db, "plex_enabled"): if not sysconfig.get_bool(db, "plex_enabled"):
raise HTTPException(status_code=409, detail="The Plex module is disabled.") raise refused(409, "plex_disabled")
enabled = body.enabled enabled = body.enabled
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first() link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
if link is None: if link is None:
@@ -150,6 +158,6 @@ def watch_import_now(user: User = Depends(admin_user), db: Session = Depends(get
intersection; Siftlode-only states are preserved. Idempotent.""" intersection; Siftlode-only states are preserved. Idempotent."""
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first() link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
if link is None or not link.sync_enabled: if link is None or not link.sync_enabled:
raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.") raise refused(409, "watch_sync_off")
result = plex_watch.import_owner_watch_state(db, link) result = plex_watch.import_owner_watch_state(db, link)
return {**_link_dict(link), "import": result} return {**_link_dict(link), "import": result}
+21 -20
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
@@ -26,6 +27,10 @@ from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.plex.query import content_libs, runtime_by_key, top_art_keys from app.plex.query import content_libs, runtime_by_key, top_art_keys
from app.utils import like_escape from app.utils import like_escape
from app.refusal import refused
from app.routes._common import not_found
log = logs.get_logger(__name__)
# The most-restrictive → least ordering used to pick a collection's effective content rating from its # The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it). # members (Plex shows one cert on the collection; we don't store it, so derive it).
@@ -234,7 +239,7 @@ def collection_detail(
endpoints below.""" endpoints below."""
col = by_rating_key(db, PlexCollection, rating_key) col = by_rating_key(db, PlexCollection, rating_key)
if col is None: if col is None:
raise HTTPException(status_code=404, detail="Unknown collection") raise not_found("collection")
movies = ( movies = (
db.execute( db.execute(
select(PlexItem) select(PlexItem)
@@ -325,9 +330,9 @@ def collection_detail(
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
col = by_rating_key(db, PlexCollection, rating_key) col = by_rating_key(db, PlexCollection, rating_key)
if col is None: if col is None:
raise HTTPException(status_code=404, detail="Unknown collection") raise not_found("collection")
if not _collection_editable(col): if not _collection_editable(col):
raise HTTPException(status_code=403, detail="This collection is read-only") raise refused(403, "collection_read_only")
return col return col
@@ -362,14 +367,13 @@ def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a
sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary
action-error toast, which is what both of these are — the app answered, it didn't go unreachable.""" action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
if wrote: log.warning(
detail = ( "a Plex collection write did not settle",
"Collection updated in Plex, but refreshing the local copy failed; " extra=logs.event(
f"it will reconcile on the next sync ({e})" "plex.collections.write_failed", wrote=wrote, reason=type(e).__name__, error=str(e)
) ),
else: )
detail = f"Plex write failed: {e}" return refused(422, "collection_pending_sync") if wrote else refused(422, "plex_write_failed")
return HTTPException(status_code=422, detail=detail)
class CreateCollectionIn(BaseModel): class CreateCollectionIn(BaseModel):
@@ -389,7 +393,7 @@ def create_collection(
title = body.title.strip() title = body.title.strip()
seed = body.item_rating_key seed = body.item_rating_key
if not title or not seed: if not title or not seed:
raise HTTPException(status_code=400, detail="title and item_rating_key are required") raise refused(400, "collection_fields_required")
if library: if library:
lib = ( lib = (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first() db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
@@ -399,7 +403,7 @@ def create_collection(
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed) seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
if lib is None: if lib is None:
raise HTTPException(status_code=404, detail="Unknown library") raise not_found("library")
_reject_duplicate_name(db, lib.id, title) _reject_duplicate_name(db, lib.id, title)
wrote = False wrote = False
try: try:
@@ -473,7 +477,7 @@ def collection_rename(
col = _editable_collection_or_403(db, rating_key) col = _editable_collection_or_403(db, rating_key)
title = body.title.strip() title = body.title.strip()
if not title: if not title:
raise HTTPException(status_code=400, detail="title is required") raise refused(400, "title_required")
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key) _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = db.get(PlexLibrary, col.library_id)
wrote = False wrote = False
@@ -502,7 +506,7 @@ def collection_delete(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") raise _write_or_pending_error(False, e) from None
return {"deleted": rating_key} return {"deleted": rating_key}
@@ -521,13 +525,10 @@ def collection_set_editable(
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
col = by_rating_key(db, PlexCollection, rating_key) col = by_rating_key(db, PlexCollection, rating_key)
if col is None: if col is None:
raise HTTPException(status_code=404, detail="Unknown collection") raise not_found("collection")
want = body.editable want = body.editable
if want and (col.smart or _collection_source(col) != "collection"): if want and (col.smart or _collection_source(col) != "collection"):
raise HTTPException( raise refused(400, "collection_smart")
status_code=400,
detail="Smart / auto-list collections can't be made editable",
)
col.editable = want col.editable = want
db.commit() db.commit()
return _collection_card(col) return _collection_card(col)
+3 -2
View File
@@ -5,7 +5,7 @@ import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -30,6 +30,7 @@ from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404 from app.routes.plex._shared import _norm_lang, item_or_404
from app.routes.plex.collections import _collection_source from app.routes.plex.collections import _collection_source
from app.routes._common import not_found
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
@@ -109,7 +110,7 @@ def show_detail(
hu = _norm_lang(lang) == "hu" hu = _norm_lang(lang) == "hu"
sh = by_rating_key(db, PlexShow, rating_key) sh = by_rating_key(db, PlexShow, rating_key)
if sh is None: if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show") raise not_found("show")
seasons = ( seasons = (
db.execute( db.execute(
select(PlexSeason) select(PlexSeason)
+23 -19
View File
@@ -39,6 +39,8 @@ from app.plex import stream as plex_stream
from app.plex import watch_sync as plex_watch from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.plex._shared import item_or_404 from app.routes.plex._shared import item_or_404
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -49,7 +51,7 @@ def _require_session(request: Request) -> int:
low-sensitivity; a valid session is enough.""" low-sensitivity; a valid session is enough."""
uid, _ = resolved_user_id(request) uid, _ = resolved_user_id(request)
if not uid: if not uid:
raise HTTPException(status_code=401, detail="Not authenticated") raise refused(401, "not_authenticated")
return uid return uid
@@ -78,15 +80,17 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
with SessionLocal() as db: with SessionLocal() as db:
key = imgcache.key_for(db, str(rating_key), v) key = imgcache.key_for(db, str(rating_key), v)
if not key: if not key:
raise HTTPException(status_code=404, detail="No image") raise not_found("image")
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
except PlexNotConfigured as e: except PlexNotConfigured:
raise HTTPException(status_code=400, detail=str(e)) raise refused(400, "plex_not_configured") from None
try: try:
with plex: with plex:
data, ct = plex.image_bytes(key, w, h) data, ct = plex.image_bytes(key, w, h)
except PlexError as e: # silent-ok: a byte proxy for an <img>; the browser shows the placeholder and no reader sees the
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}") # detail — the 502 is the whole answer, as for the person-image proxy below.
except PlexError:
raise refused(502, "image_fetch_failed") from None
imgcache.write(cache_key, data, ct) imgcache.write(cache_key, data, ct)
return imgcache.response(data, ct) return imgcache.response(data, ct)
@@ -214,7 +218,7 @@ def person_image(u: str, request: Request) -> Response:
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch.""" Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
_require_session(request) _require_session(request)
if not u.startswith(plex_people.PERSON_IMG_HOST): if not u.startswith(plex_people.PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host") raise refused(400, "image_host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest() cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
hit = imgcache.read(cache_key) hit = imgcache.read(cache_key)
if hit: if hit:
@@ -224,7 +228,7 @@ def person_image(u: str, request: Request) -> Response:
r = c.get(u) r = c.get(u)
r.raise_for_status() r.raise_for_status()
except httpx.HTTPError: except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Image fetch failed") raise refused(502, "image_fetch_failed")
ct = r.headers.get("content-type", "image/jpeg") ct = r.headers.get("content-type", "image/jpeg")
imgcache.write(cache_key, r.content, ct) imgcache.write(cache_key, r.content, ct)
return imgcache.response(r.content, ct) return imgcache.response(r.content, ct)
@@ -444,7 +448,7 @@ def item_state(
it = item_or_404(db, rating_key) it = item_or_404(db, rating_key)
status = body.status status = body.status
if status not in ("new", "watched", "hidden"): if status not in ("new", "watched", "hidden"):
raise HTTPException(status_code=400, detail="Invalid status") raise refused(400, "invalid_status")
st = ( st = (
db.execute( db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id) select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
@@ -546,7 +550,7 @@ def show_state(
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex.""" """Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
sh = by_rating_key(db, PlexShow, rating_key) sh = by_rating_key(db, PlexShow, rating_key)
if sh is None: if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show") raise not_found("show")
watched = body.watched watched = body.watched
eps = ( eps = (
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")) db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
@@ -568,7 +572,7 @@ def season_state(
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex.""" """Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
se = by_rating_key(db, PlexSeason, rating_key) se = by_rating_key(db, PlexSeason, rating_key)
if se is None: if se is None:
raise HTTPException(status_code=404, detail="Unknown Plex season") raise not_found("season")
watched = body.watched watched = body.watched
eps = ( eps = (
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode")) db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
@@ -606,10 +610,10 @@ def stream_session(
"start_s": 0.0, "start_s": 0.0,
} }
if plex_paths.local_media_path(db, it.file_path) is None: if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise refused(404, "media_missing")
s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode) s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode)
if s is None: if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise refused(404, "media_missing")
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the # Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this. # requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and # `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
@@ -640,7 +644,7 @@ def stream_file(
it = item_or_404(db, rating_key) it = item_or_404(db, rating_key)
src = plex_paths.local_media_path(db, it.file_path) src = plex_paths.local_media_path(db, it.file_path)
if src is None: if src is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise refused(404, "media_missing")
if download: if download:
# A clean, safe download name from the title + the real file extension. # A clean, safe download name from the title + the real file extension.
ext = src.suffix or (f".{it.container}" if it.container else "") ext = src.suffix or (f".{it.container}" if it.container else "")
@@ -694,10 +698,10 @@ def stream_hls_file(
or "\\" in filename or "\\" in filename
or ".." in filename or ".." in filename
): ):
raise HTTPException(status_code=404, detail="Not found") raise not_found("file")
s = plex_stream.current_session(user.id, str(rating_key)) s = plex_stream.current_session(user.id, str(rating_key))
if s is None or s.id != session_id: if s is None or s.id != session_id:
raise HTTPException(status_code=404, detail="No active playback session") raise refused(404, "session_gone")
path = s.dir / filename path = s.dir / filename
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a # Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them # segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
@@ -707,14 +711,14 @@ def stream_hls_file(
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool # past the produced region — or a deliberate flood of hopeless names — from parking threadpool
# workers and their DB connections (see `artifact_may_arrive`). # workers and their DB connections (see `artifact_may_arrive`).
if not plex_stream.artifact_may_arrive(s, filename): if not plex_stream.artifact_may_arrive(s, filename):
raise HTTPException(status_code=404, detail="Not available yet") raise refused(404, "not_ready")
timeout = 30 if ext == ".m3u8" else 20 timeout = 30 if ext == ".m3u8" else 20
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the # `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection # whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek # with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
# past the produced region was enough to do it (round 5). # past the produced region was enough to do it (round 5).
if not plex_stream.wait_for(path, timeout, proc=s.proc): if not plex_stream.wait_for(path, timeout, proc=s.proc):
raise HTTPException(status_code=404, detail="Not available yet") raise refused(404, "not_ready")
try: try:
content = path.read_bytes() content = path.read_bytes()
except OSError: except OSError:
@@ -725,7 +729,7 @@ def stream_hls_file(
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which # restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing # kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
# instance's in-flight segment request races the rmtree on every seek-restart. # instance's in-flight segment request races the rmtree on every seek-restart.
raise HTTPException(status_code=404, detail="No active playback session") from None raise refused(404, "session_gone") from None
return Response( return Response(
content=content, content=content,
media_type=_HLS_CT[ext], media_type=_HLS_CT[ext],
+6 -5
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import delete, func, select, update from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,7 +19,8 @@ from app.models import (
) )
from app.plex.cards import leaf_card from app.plex.cards import leaf_card
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.routes._common import owned_or_404 from app.routes._common import owned_or_404, not_found
from app.refusal import refused
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -116,7 +117,7 @@ def create_playlist(
) -> dict: ) -> dict:
title = body.title.strip() title = body.title.strip()
if not title: if not title:
raise HTTPException(status_code=400, detail="title is required") raise refused(400, "title_required")
pl = PlexPlaylist(user_id=user.id, title=title) pl = PlexPlaylist(user_id=user.id, title=title)
db.add(pl) db.add(pl)
db.flush() db.flush()
@@ -182,7 +183,7 @@ def rename_playlist(
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
title = body.title.strip() title = body.title.strip()
if not title: if not title:
raise HTTPException(status_code=400, detail="title is required") raise refused(400, "title_required")
pl.title = title pl.title = title
db.commit() db.commit()
count = ( count = (
@@ -219,7 +220,7 @@ def playlist_add_item(
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
it = by_rating_key(db, PlexItem, body.item_rating_key) it = by_rating_key(db, PlexItem, body.item_rating_key)
if it is None: if it is None:
raise HTTPException(status_code=404, detail="Unknown item") raise not_found("item")
exists = ( exists = (
db.execute( db.execute(
select(PlexPlaylistItem).where( select(PlexPlaylistItem).where(
+13 -11
View File
@@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header). a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
""" """
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@@ -19,6 +19,8 @@ from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset from app.models import DownloadJob, DownloadLink, MediaAsset
from app.ratelimit import RateLimiter, client_key from app.ratelimit import RateLimiter, client_key
from app.security import verify_password from app.security import verify_password
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/public", tags=["public"]) router = APIRouter(prefix="/api/public", tags=["public"])
@@ -37,7 +39,7 @@ _unlock_failures = RateLimiter(max_events=50, window_seconds=300)
def _link_or_404(db: Session, token: str) -> DownloadLink: def _link_or_404(db: Session, token: str) -> DownloadLink:
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none() link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
if link is None or linksmod.is_expired(link): if link is None or linksmod.is_expired(link):
raise HTTPException(status_code=404, detail="This link is no longer available.") raise refused(404, "link_gone")
return link return link
@@ -45,12 +47,12 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
job = db.get(DownloadJob, link.job_id) job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready" or not asset.rel_path: if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=404, detail="This video is no longer available.") raise refused(404, "video_gone")
# Verify the file is actually on disk here (not just flagged ready), so the watch page never # Verify the file is actually on disk here (not just flagged ready), so the watch page never
# loads a player for a missing file — meta and file agree. # loads a player for a missing file — meta and file agree.
path = storage.safe_abs_path(settings.download_root, asset.rel_path) path = storage.safe_abs_path(settings.download_root, asset.rel_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="This video is no longer available.") raise refused(404, "video_gone")
return job, asset return job, asset
@@ -100,7 +102,7 @@ def watch_unlock(
link = _link_or_404(db, token) link = _link_or_404(db, token)
viewer = f"{token}|{client_key(request)}" viewer = f"{token}|{client_key(request)}"
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer): if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.") raise refused(429, "too_many_attempts")
if not link.password_hash: if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta. # Not a password link — nothing to unlock; just hand back the plain meta.
job, asset = _ready_target(db, link) job, asset = _ready_target(db, link)
@@ -110,7 +112,7 @@ def watch_unlock(
} }
if not verify_password(body.password, link.password_hash): if not verify_password(body.password, link.password_hash):
_unlock_failures.allow(token) _unlock_failures.allow(token)
raise HTTPException(status_code=403, detail="Wrong password.") raise refused(403, "wrong_share_password")
job, asset = _ready_target(db, link) job, asset = _ready_target(db, link)
link.view_count += 1 link.view_count += 1
db.commit() db.commit()
@@ -129,11 +131,11 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
`allow_download` link serves it as an attachment. Password links require a valid grant.""" `allow_download` link serves it as an attachment. Password links require a valid grant."""
link = _link_or_404(db, token) link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g, link.password_version): if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise HTTPException(status_code=403, detail="This link needs a password.") raise refused(403, "share_password_required")
asset = _ready_asset(db, link) asset = _ready_asset(db, link)
path = storage.safe_abs_path(settings.download_root, asset.rel_path) path = storage.safe_abs_path(settings.download_root, asset.rel_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="This video is no longer available.") raise refused(404, "video_gone")
filename = storage.download_filename(asset.title or "video", asset.container, path) filename = storage.download_filename(asset.title or "video", asset.container, path)
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container # The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
@@ -163,11 +165,11 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
and the link-preview image). Password links require a valid grant, like the file.""" and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token) link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g, link.password_version): if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise HTTPException(status_code=403, detail="This link needs a password.") raise refused(403, "share_password_required")
job, asset = _ready_target(db, link) job, asset = _ready_target(db, link)
if not asset.poster_path: if not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.") raise not_found("poster")
path = storage.safe_abs_path(settings.download_root, asset.poster_path) path = storage.safe_abs_path(settings.download_root, asset.poster_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="No poster.") raise not_found("poster")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
+7 -6
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -8,6 +8,7 @@ from app.auth import require_human
from app.db import get_db from app.db import get_db
from app.models import SavedView, User from app.models import SavedView, User
from app.routes._common import owned_or_404 from app.routes._common import owned_or_404
from app.refusal import refused
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by # Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list # `source`). Gated behind require_human so the shared demo account can't pollute a communal list
@@ -66,7 +67,7 @@ def create_view(
) -> dict: ) -> dict:
name = body.name.strip() name = body.name.strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name is required") raise refused(400, "name_required")
source = _norm_source(body.source) source = _norm_source(body.source)
# Position is per (user, source), so each module's views number 0..N independently. # Position is per (user, source), so each module's views number 0..N independently.
maxpos = db.execute( maxpos = db.execute(
@@ -86,7 +87,7 @@ def create_view(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a view named “{name}”.") raise refused(409, "view_name_taken", name=name)
return _serialize(view) return _serialize(view)
@@ -110,11 +111,11 @@ def update_view(
if "name" in sent: if "name" in sent:
name = (body.name or "").strip() name = (body.name or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name cannot be empty") raise refused(400, "name_required")
view.name = name[:80] view.name = name[:80]
if "filters" in sent: if "filters" in sent:
if body.filters is None: if body.filters is None:
raise HTTPException(status_code=400, detail="filters must be an object") raise refused(400, "filters_not_object")
view.filters = body.filters view.filters = body.filters
if "is_default" in sent: if "is_default" in sent:
make_default = bool(body.is_default) make_default = bool(body.is_default)
@@ -134,7 +135,7 @@ def update_view(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a view named “{view.name}”.") raise refused(409, "view_name_taken", name=view.name)
return _serialize(view) return _serialize(view)
+7 -8
View File
@@ -2,7 +2,7 @@
per-job run activity (from the in-process scheduler), the work still queued (DB-derived), per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
and the shared quota picture.""" and the shared quota picture."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -25,6 +25,8 @@ from app.scheduler import (
from app.sync.explore import followed_channel from app.sync.explore import followed_channel
from app.sync.runner import estimate_deep_backfill from app.sync.runner import estimate_deep_backfill
from app.routes._common import not_found from app.routes._common import not_found
from app.refusal import refused
from app.refusal import setting_refused
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"]) router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
@@ -46,10 +48,7 @@ def set_job_interval(
raise not_found("job") raise not_found("job")
minutes = body.interval_minutes minutes = body.interval_minutes
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL): if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise HTTPException( raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
status_code=400,
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
)
applied = apply_interval(db, job_id, minutes) applied = apply_interval(db, job_id, minutes)
audit.record( audit.record(
db, db,
@@ -77,7 +76,7 @@ def run_job_now(
except KeyError: except KeyError:
raise not_found("job") raise not_found("job")
if result == "already_running": if result == "already_running":
raise HTTPException(status_code=409, detail="Job is already running") raise refused(409, "job_running")
return {"id": job_id, "started": True} return {"id": job_id, "started": True}
@@ -102,8 +101,8 @@ def set_maintenance_settings(
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH. # set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
try: try:
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch) sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
except (TypeError, ValueError) as exc: except sysconfig.InvalidSetting as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") raise setting_refused(exc) from None
new = sysconfig.get_int(db, "maintenance_revalidate_batch") new = sysconfig.get_int(db, "maintenance_revalidate_batch")
audit.record( audit.record(
db, db,
+15 -16
View File
@@ -16,7 +16,7 @@ dropped from the search response up front)."""
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -44,6 +44,8 @@ from app.sync.videos import insert_stubs, apply_video_details, parse_dt
from app.youtube import search_scrape from app.youtube import search_scrape
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
from app.youtube.shorts import make_client, probe_is_short from app.youtube.shorts import make_client, probe_is_short
from app.refusal import refused
from app.routes._common import youtube_refusal
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/search", tags=["search"]) router = APIRouter(prefix="/api/search", tags=["search"])
@@ -258,7 +260,7 @@ def search_youtube(
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further.""" page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
term = (q or "").strip() term = (q or "").strip()
if not term: if not term:
raise HTTPException(status_code=400, detail="A search term is required.") raise refused(400, "search_term_required")
limit = max(1, min(limit, 100)) limit = max(1, min(limit, 100))
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page). # Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
@@ -275,20 +277,14 @@ def search_youtube(
"a live search was refused: the per-user daily cap is spent", "a live search was refused: the per-user daily cap is spent",
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit), extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
) )
raise HTTPException( raise refused(429, "search_daily_limit", limit=daily_limit)
status_code=429,
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
)
# The 100-unit budget pre-check only applies to the API source. # The 100-unit budget pre-check only applies to the API source.
if source == "api" and not quota.can_spend(db, SEARCH_COST): if source == "api" and not quota.can_spend(db, SEARCH_COST):
log.info( log.info(
"a live search was refused: the shared YouTube budget is spent", "a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST), extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
) )
raise HTTPException( raise refused(429, "quota_spent")
status_code=429,
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
)
# Channels this user has blocked are dropped before ingestion — never shown, never stored. # Channels this user has blocked are dropped before ingestion — never shown, never stored.
blocked = set( blocked = set(
@@ -313,9 +309,14 @@ def search_youtube(
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY), # dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
# which hides the real message and burns a second quota-costed search. Upstream failure # which hides the real message and burns a second quota-costed search. Upstream failure
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1) # on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
raise HTTPException( # A Data API failure is logged where it happened; the scraper's is not, and its text
status_code=422, detail=f"YouTube search failed: {exc}" # no longer rides the answer — so it is said here.
) from exc if isinstance(exc, search_scrape.ScrapeError):
log.warning(
"the YouTube search scrape failed",
extra=logs.event("search.scrape_failed", error=str(exc)),
)
raise youtube_refusal(exc) from exc
# Drop currently-live/upcoming, channel-less, and blocked-channel results. # Drop currently-live/upcoming, channel-less, and blocked-channel results.
candidates = [ candidates = [
@@ -357,9 +358,7 @@ def search_youtube(
) )
if collected: if collected:
break # keep what earlier pages already gathered break # keep what earlier pages already gathered
raise HTTPException( raise youtube_refusal(exc) from exc
status_code=422, detail=f"YouTube search failed: {exc}"
) from exc
next_cursor = page["next_page_token"] next_cursor = page["next_page_token"]
# The API source pages cost 100 units each — never auto-page; one page per request. # The API source pages cost 100 units each — never auto-page; one page per request.
if source == "api" or len(collected) >= limit or not next_cursor: if source == "api" or len(collected) >= limit or not next_cursor:
+13 -13
View File
@@ -8,7 +8,7 @@ X-Setup-Token header).
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException from fastapi import APIRouter, Depends, Header
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,6 +21,8 @@ from app.auth import PASSWORD_MIN_LEN
from app.db import get_db from app.db import get_db
from app.models import User from app.models import User
from app.security import hash_password from app.security import hash_password
from app.refusal import refused
from app.refusal import setting_refused
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
@@ -31,9 +33,9 @@ def require_setup(
) -> Session: ) -> Session:
"""Gate for setup steps. Returns the db session for the handler to reuse.""" """Gate for setup steps. Returns the db session for the handler to reuse."""
if state.is_configured(db): if state.is_configured(db):
raise HTTPException(status_code=409, detail="This instance is already set up.") raise refused(409, "already_set_up")
if not state.check_setup_token(db, x_setup_token): if not state.check_setup_token(db, x_setup_token):
raise HTTPException(status_code=403, detail="Invalid or missing setup token.") raise refused(403, "setup_token")
return db return db
@@ -66,11 +68,9 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
email = body.email.strip().lower() email = body.email.strip().lower()
password = body.password password = body.password
if "@" not in email: if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
if len(password) < PASSWORD_MIN_LEN: if len(password) < PASSWORD_MIN_LEN:
raise HTTPException( raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
)
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's # Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here # whole point is that two spellings of one Gmail mailbox are one person — matching literally here
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists # would let a second run under the other spelling create the DUPLICATE admin row the epic exists
@@ -97,13 +97,13 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
saved: list[str] = [] saved: list[str] = []
for key, value in settings_map.items(): for key, value in settings_map.items():
if sysconfig.spec(key) is None: if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}") raise refused(400, "unknown_setting", key=key)
if value is None or value == "": if value is None or value == "":
continue continue
try: try:
sysconfig.set_value(db, key, value) sysconfig.set_value(db, key, value)
except (ValueError, RuntimeError) as exc: except sysconfig.InvalidSetting as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise setting_refused(exc) from None
saved.append(key) saved.append(key)
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
return {"saved": saved} return {"saved": saved}
@@ -121,9 +121,9 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
"""Send a test email to confirm the SMTP settings the wizard just saved.""" """Send a test email to confirm the SMTP settings the wizard just saved."""
to = body.to.strip() to = body.to.strip()
if "@" not in to: if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
if not email_mod.send_test(to, mailtext.normalize(body.lang)): if not email_mod.send_test(to, mailtext.normalize(body.lang)):
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.") raise refused(400, "smtp_failed")
return {"ok": True} return {"ok": True}
@@ -133,6 +133,6 @@ def setup_finish(db: Session = Depends(require_setup)) -> dict:
invalidates the setup token and disables the wizard).""" invalidates the setup token and disables the wizard)."""
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
if not has_admin: if not has_admin:
raise HTTPException(status_code=400, detail="Create the admin account first.") raise refused(400, "admin_first")
state.mark_configured(db) state.mark_configured(db)
return {"ok": True} return {"ok": True}
+2 -4
View File
@@ -13,6 +13,7 @@ from app.sync.runner import estimate_deep_backfill
from app.sync.subscriptions import import_subscriptions from app.sync.subscriptions import import_subscriptions
from app.scheduler import running_job_ids from app.scheduler import running_job_ids
from app.youtube.client import YouTubeError from app.youtube.client import YouTubeError
from app.refusal import refused
router = APIRouter(prefix="/api/sync", tags=["sync"]) router = APIRouter(prefix="/api/sync", tags=["sync"])
@@ -28,10 +29,7 @@ _sync_limiter = RateLimiter(max_events=3, window_seconds=600)
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
if not has_read_scope(user): if not has_read_scope(user):
# No YouTube read grant yet — the onboarding wizard hasn't been completed. # No YouTube read grant yet — the onboarding wizard hasn't been completed.
raise HTTPException( raise refused(403, "youtube_read_scope")
status_code=403,
detail="Connect your YouTube account (read access) to import subscriptions.",
)
if not _sync_limiter.allow(str(user.id)): if not _sync_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"}) raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
try: try:
+6 -5
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -9,6 +9,7 @@ from app.db import get_db
from app.models import ChannelTag, Tag, User from app.models import ChannelTag, Tag, User
from app.routes._common import owned_or_404 from app.routes._common import owned_or_404
from app.sync.tagsuggest import compute_tag_suggestions from app.sync.tagsuggest import compute_tag_suggestions
from app.refusal import refused
router = APIRouter(prefix="/api/tags", tags=["tags"]) router = APIRouter(prefix="/api/tags", tags=["tags"])
@@ -60,7 +61,7 @@ def create_tag(
) -> dict: ) -> dict:
name = body.name.strip() name = body.name.strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name is required") raise refused(400, "name_required")
# An unknown category silently falls back to "other" (unchanged behaviour) rather than 422. # An unknown category silently falls back to "other" (unchanged behaviour) rather than 422.
category = body.category if body.category in ("language", "topic", "other") else "other" category = body.category if body.category in ("language", "topic", "other") else "other"
tag = Tag( tag = Tag(
@@ -74,7 +75,7 @@ def create_tag(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a tag named “{name}”.") raise refused(409, "tag_name_taken", name=name)
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
@@ -98,7 +99,7 @@ def update_tag(
if "name" in sent: if "name" in sent:
name = (body.name or "").strip() name = (body.name or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name cannot be empty") raise refused(400, "name_required")
tag.name = name tag.name = name
if "color" in sent: if "color" in sent:
tag.color = body.color tag.color = body.color
@@ -106,7 +107,7 @@ def update_tag(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a tag named “{tag.name}”.") raise refused(409, "tag_name_taken", name=tag.name)
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
+21 -6
View File
@@ -212,11 +212,23 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr) return getattr(settings, s.default_attr)
class InvalidSetting(ValueError):
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
plus what the sentence needs — the English message is for logs only. A ValueError still, so a
caller that only needs "refused or not" keeps working."""
def __init__(self, key: str, reason: str, **params: object):
super().__init__(f"{key}: {reason} {params}")
self.key = key
self.reason = reason
self.params = params
def _choice(s: ConfigSpec, raw: object) -> str: def _choice(s: ConfigSpec, raw: object) -> str:
"""The canonical spelling of `raw` among the key's choices (any letter case), or ValueError.""" """The canonical spelling of `raw` among the key's choices (any letter case), or InvalidSetting."""
option = canonical_choice(s.choices, raw) option = canonical_choice(s.choices, raw)
if option is None: if option is None:
raise ValueError(f"{s.key} must be one of: {', '.join(s.choices)}") raise InvalidSetting(s.key, "not_a_choice", choices=", ".join(s.choices))
return option return option
@@ -306,11 +318,14 @@ def set_value(db: Session, key: str, value) -> None:
(B-core-10). The staged value is visible to a same-session `get()` before the commit.""" (B-core-10). The staged value is visible to a same-session `get()` before the commit."""
s = _BY_KEY[key] s = _BY_KEY[key]
if s.type == "int": if s.type == "int":
v = int(value) try:
v = int(value)
except (TypeError, ValueError):
raise InvalidSetting(key, "not_a_number") from None
if s.min is not None and v < s.min: if s.min is not None and v < s.min:
raise ValueError(f"{key} must be >= {s.min}") raise InvalidSetting(key, "too_low", min=s.min)
if s.max is not None and v > s.max: if s.max is not None and v > s.max:
raise ValueError(f"{key} must be <= {s.max}") raise InvalidSetting(key, "too_high", max=s.max)
raw = str(v) raw = str(v)
elif s.type == "bool": elif s.type == "bool":
raw = "true" if value else "false" raw = "true" if value else "false"
@@ -322,7 +337,7 @@ def set_value(db: Session, key: str, value) -> None:
stored, encrypted = raw, False stored, encrypted = raw, False
if s.secret: if s.secret:
if not secrets_manageable(): if not secrets_manageable():
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured") raise InvalidSetting(key, "no_encryption_key")
stored, encrypted = security.encrypt(raw) or "", True stored, encrypted = security.encrypt(raw) or "", True
row = db.get(SystemConfig, key) row = db.get(SystemConfig, key)
+1 -1
View File
@@ -34,7 +34,7 @@ def test_malformed_feed_cursor_returns_400(db, monkeypatch):
db.commit() db.commit()
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor") resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor")
assert resp.status_code == 400 assert resp.status_code == 400
assert resp.json()["detail"] == "Invalid feed cursor" assert resp.json()["detail"] == {"code": "refused", "reason": "invalid_cursor"}
def test_feed_without_cursor_is_200(db, monkeypatch): def test_feed_without_cursor_is_200(db, monkeypatch):
+53
View File
@@ -0,0 +1,53 @@
"""No refusal answers in a hand-written English sentence.
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources.
"""
import ast
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
def _is_text(node: ast.AST) -> bool:
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return True
if isinstance(node, ast.JoinedStr):
return True
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
return True
if isinstance(node, ast.BoolOp):
return any(_is_text(v) for v in node.values)
if isinstance(node, ast.BinOp):
return _is_text(node.left) or _is_text(node.right)
return False
def test_no_http_exception_carries_a_sentence():
offenders = []
for path in sorted(APP.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not (
isinstance(node, ast.Call)
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
):
continue
kw = {k.arg: k.value for k in node.keywords}
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
if detail is not None and _is_text(detail):
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
assert offenders == [], (
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
f"(app/refusal.py) and name the reason in both locales: {offenders}"
)
def test_the_scan_sees_a_sentence_when_there_is_one():
"""A guard that matches nothing is green over nothing."""
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
assert _is_text(sample.keywords[1].value)
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
@@ -346,7 +346,7 @@ def test_create_resync_failure_is_422(db, monkeypatch):
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
) )
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
assert "reconcile" in r.json()["detail"].lower() assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "create_collection" in stub.calls assert "create_collection" in stub.calls
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -361,7 +361,7 @@ def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
try: try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
assert "write failed" in r.json()["detail"].lower() assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -389,7 +389,7 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
try: try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"}) r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 422, r.text assert r.status_code == 422, r.text
assert "reconcile" in r.json()["detail"].lower() assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "rename_collection" in stub.calls # the write DID happen assert "rename_collection" in stub.calls # the write DID happen
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
+1 -1
View File
@@ -153,4 +153,4 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
app.dependency_overrides.clear() app.dependency_overrides.clear()
assert resp.status_code == 422, resp.text assert resp.status_code == 422, resp.text
assert "YouTube search failed" in resp.json()["detail"] assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}
+21
View File
@@ -44,6 +44,25 @@ const networkProperties = [
}, },
]; ];
// A `//` or `/* */` inside JSX children is not a comment — it is TEXT, rendered on the page. One
// slipped in when a return was wrapped in a fragment (Playlists.tsx) and only a visual check caught
// it: tsc, eslint and every test stayed green. `eslint-plugin-react` has this rule, but it would bring
// ~1800 lockfile lines of dependencies for ten lines of logic, so it lives here.
const jsxCommentText = {
meta: {
type: "problem",
messages: { text: "A comment inside JSX children renders as text. Wrap it in {/* */}." },
},
create(context) {
return {
JSXText(node) {
if (/^\s*(\/\/|\/\*)/m.test(node.value)) context.report({ node, messageId: "text" });
},
};
},
};
const localRules = { rules: { "jsx-comment-text": jsxCommentText } };
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
{ {
@@ -56,12 +75,14 @@ export default tseslint.config(
plugins: { plugins: {
"react-hooks": reactHooks, "react-hooks": reactHooks,
"react-refresh": reactRefresh, "react-refresh": reactRefresh,
local: localRules,
}, },
linterOptions: { linterOptions: {
reportUnusedDisableDirectives: "error", reportUnusedDisableDirectives: "error",
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"local/jsx-comment-text": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a // tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
// finding is reported once, by one tool, and this config stays about hooks + refresh safety. // finding is reported once, by one tool, and this config stays about hooks + refresh safety.
@@ -105,6 +105,31 @@ const goneWhats = [
), ),
), ),
]; ];
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
// `setting_refused` picks from at runtime.
const appSources = (dir = ""): string[] =>
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
withFileTypes: true,
}).flatMap((e) =>
e.isDirectory()
? appSources(`${dir}${e.name}/`)
: e.name.endsWith(".py")
? [backend(`${dir}${e.name}`)]
: [],
);
const refusedReasons = [
...new Set([
...appSources().flatMap((src) =>
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
),
...[
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
/:\s*"(\w+)"/g,
),
].map((m) => m[1]!),
]),
];
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal. // A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
const youtubeReasons = [ const youtubeReasons = [
...( ...(
@@ -134,6 +159,9 @@ describe("backend ids have their names in both languages", () => {
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]); expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
expect(refusedReasons).toEqual(
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
);
// Every code the frontend localizes is raised somewhere the scan can see. // Every code the frontend localizes is raised somewhere the scan can see.
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]); expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
}); });
@@ -200,6 +228,13 @@ describe("backend ids have their names in both languages", () => {
).toEqual([]); ).toEqual([]);
}); });
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
).toEqual([]);
});
it(`${lang}: every admin refusal reason`, () => { it(`${lang}: every admin refusal reason`, () => {
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]); expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual( expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
+108 -1
View File
@@ -21,7 +21,21 @@
"job": "This job doesn't exist any more.", "job": "This job doesn't exist any more.",
"download": "This download doesn't exist any more.", "download": "This download doesn't exist any more.",
"link": "This share link doesn't exist any more.", "link": "This share link doesn't exist any more.",
"notification": "This notification doesn't exist any more." "notification": "This notification doesn't exist any more.",
"account": "That account no longer exists.",
"collection": "This collection doesn't exist any more.",
"config_key": "There's no such setting.",
"file": "The file is no longer available.",
"filmstrip": "There's no filmstrip for this.",
"host": "This host isn't on the list any more.",
"image": "There's no image for this.",
"item": "This item doesn't exist any more.",
"library": "This library doesn't exist any more.",
"plex_item": "This Plex item isn't in the library any more.",
"poster": "There's no poster for this.",
"profile": "This download profile doesn't exist any more.",
"season": "This season isn't in the library any more.",
"show": "This show isn't in the library any more."
}, },
"admin": { "admin": {
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.", "not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
@@ -99,5 +113,98 @@
"quota": "Today's YouTube budget for this instance is spent. It resets at midnight Pacific time — try again then.", "quota": "Today's YouTube budget for this instance is spent. It resets at midnight Pacific time — try again then.",
"reconnect": "Google no longer accepts this account's YouTube connection. Sign out and sign in with Google again to reconnect it.", "reconnect": "Google no longer accepts this account's YouTube connection. Sign out and sign in with Google again to reconnect it.",
"unavailable": "YouTube didn't accept that just now. Please try again." "unavailable": "YouTube didn't accept that just now. Please try again."
},
"refused": {
"account_no_access": "That account no longer has access.",
"account_signed_out": "That account was signed out elsewhere — sign in again.",
"account_suspended": "That account is suspended.",
"address_has_account": "That address already has an account — delete the account instead.",
"admin_first": "Create the admin account first.",
"admin_only": "Only an admin can do that.",
"already_set_up": "This instance is already set up.",
"already_subscribed": "You're already subscribed to this channel.",
"channel_blocked": "You've blocked this channel.",
"collection_fields_required": "A title and an item are both needed.",
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync.",
"collection_read_only": "This collection is read-only.",
"collection_smart": "Smart and auto-list collections can't be made editable.",
"demo_activate": "The demo account can't be activated.",
"demo_delete": "The demo account can't be deleted.",
"demo_role": "The demo account's role can't be changed.",
"demo_suspend": "The demo account can't be suspended.",
"download_not_ready": "This download isn't ready.",
"edit_unfinished": "Only a finished download can be edited.",
"filters_not_object": "The view's filters are malformed.",
"google_no_userinfo": "Google didn't send back the account details. Try signing in again.",
"image_fetch_failed": "The image couldn't be loaded.",
"image_host": "Images from that address aren't loaded.",
"invalid_cursor": "The list position is out of date — reload the page.",
"invalid_email": "Enter a valid email address.",
"invalid_interval": "The interval must be between {{min}} and {{max}} minutes.",
"invalid_link": "That doesn't look like a valid video link.",
"invalid_role": "Unknown role.",
"invalid_status": "Unknown status.",
"invite_decided": "This request has already been decided.",
"job_running": "This job is already running.",
"last_active_admin": "This is the only active admin — make someone else admin first.",
"last_admin": "This is the only admin — make someone else admin first.",
"link_gone": "This link is no longer available.",
"link_required": "A video link or id is required.",
"media_missing": "The media file isn't on disk.",
"message_empty": "The message is empty.",
"message_rate": "Too many messages — slow down a moment.",
"message_self": "You can't message yourself.",
"message_too_long": "The message is too long.",
"messaging_set_up": "Secure messaging is already set up.",
"name_required": "A name is required.",
"no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
"no_messaging": "That member hasn't set up messaging yet.",
"no_profile": "There's no download profile to use.",
"no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first.",
"not_authenticated": "You're not signed in.",
"not_ready": "Not available yet.",
"not_redownloadable": "This item can't be downloaded again.",
"not_your_account": "That's not an account you've signed into here.",
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account.",
"own_delete": "Delete your own account in Settings → Account.",
"own_role": "You can't change your own role.",
"own_suspend": "You can't suspend your own account.",
"plex_disabled": "The Plex module is turned off.",
"plex_not_configured": "Plex isn't set up — add the server address and token in Configuration → Plex.",
"plex_unreachable": "The Plex server didn't answer. Check the address and the token, then try again.",
"plex_write_failed": "Plex didn't accept the change. Try again.",
"profile_name_required": "A profile name is required.",
"quota_reserve": "Today's shared YouTube budget is down to its reserve — try again later.",
"quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.",
"recipient_required": "A recipient email is required.",
"registration_closed": "Registration is currently closed.",
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
"search_term_required": "Enter something to search for.",
"session_gone": "The playback session ended.",
"setting_not_a_choice": "Pick one of: {{choices}}.",
"setting_not_a_number": "Enter a whole number.",
"setting_too_high": "The value can be at most {{max}}.",
"setting_too_low": "The value must be at least {{min}}.",
"setup_token": "The setup token is missing or wrong.",
"share_own": "That's already your download.",
"share_password_required": "This link needs a password.",
"share_unfinished": "Only a finished download can be shared.",
"smtp_failed": "The email couldn't be sent — check the SMTP settings.",
"smtp_off": "SMTP isn't configured.",
"spec_not_object": "The profile's settings are malformed.",
"still_downloading": "This download is still in progress.",
"tag_name_taken": "You already have a tag named “{{name}}”.",
"title_required": "A title is required.",
"too_many_attempts": "Too many attempts. Try again shortly.",
"unknown_setting": "Unknown setting: {{key}}",
"value_missing": "A value is required.",
"video_gone": "This video is no longer available.",
"view_name_taken": "You already have a view named “{{name}}”.",
"watch_sync_off": "Turn on Plex watch-sync first.",
"weak_password": "The password must be at least {{min}} characters.",
"wrong_password": "The current password is incorrect.",
"wrong_share_password": "Wrong password.",
"youtube_editing_off": "Turn on YouTube editing in Settings first.",
"youtube_read_scope": "Connect your YouTube account (read access) first."
} }
} }
+108 -1
View File
@@ -21,7 +21,21 @@
"job": "Ez a feladat már nem létezik.", "job": "Ez a feladat már nem létezik.",
"download": "Ez a letöltés már nem létezik.", "download": "Ez a letöltés már nem létezik.",
"link": "Ez a megosztási link már nem létezik.", "link": "Ez a megosztási link már nem létezik.",
"notification": "Ez az értesítés már nem létezik." "notification": "Ez az értesítés már nem létezik.",
"account": "Ez a fiók már nem létezik.",
"collection": "Ez a gyűjtemény már nem létezik.",
"config_key": "Nincs ilyen beállítás.",
"file": "A fájl már nem érhető el.",
"filmstrip": "Ehhez nincs képsor.",
"host": "Ez a kiszolgáló már nincs a listán.",
"image": "Ehhez nincs kép.",
"item": "Ez az elem már nem létezik.",
"library": "Ez a könyvtár már nem létezik.",
"plex_item": "Ez a Plex-elem már nincs a könyvtárban.",
"poster": "Ehhez nincs poszter.",
"profile": "Ez a letöltési profil már nem létezik.",
"season": "Ez az évad már nincs a könyvtárban.",
"show": "Ez a sorozat már nincs a könyvtárban."
}, },
"admin": { "admin": {
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.", "not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
@@ -99,5 +113,98 @@
"quota": "Ennek a példánynak elfogyott a mai YouTube-kerete. Csendes-óceáni idő szerint éjfélkor újraindul — akkor próbáld újra.", "quota": "Ennek a példánynak elfogyott a mai YouTube-kerete. Csendes-óceáni idő szerint éjfélkor újraindul — akkor próbáld újra.",
"reconnect": "A Google már nem fogadja el ennek a fióknak a YouTube-kapcsolatát. Jelentkezz ki, majd lépj be újra Google-lel az összekötéshez.", "reconnect": "A Google már nem fogadja el ennek a fióknak a YouTube-kapcsolatát. Jelentkezz ki, majd lépj be újra Google-lel az összekötéshez.",
"unavailable": "A YouTube most nem fogadta el. Próbáld újra." "unavailable": "A YouTube most nem fogadta el. Próbáld újra."
},
"refused": {
"account_no_access": "Ennek a fióknak már nincs hozzáférése.",
"account_signed_out": "Ezt a fiókot máshol kijelentkeztették — lépj be újra.",
"account_suspended": "Ez a fiók fel van függesztve.",
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette.",
"admin_first": "Előbb hozd létre az admin fiókot.",
"admin_only": "Ezt csak admin teheti meg.",
"already_set_up": "Ez a példány már be van állítva.",
"already_subscribed": "Erre a csatornára már feliratkoztál.",
"channel_blocked": "Ezt a csatornát letiltottad.",
"collection_fields_required": "A címet és egy elemet is meg kell adni.",
"collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja.",
"collection_read_only": "Ez a gyűjtemény csak olvasható.",
"collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé.",
"demo_activate": "A bemutató fiók nem aktiválható.",
"demo_delete": "A bemutató fiók nem törölhető.",
"demo_role": "A bemutató fiók szerepköre nem módosítható.",
"demo_suspend": "A bemutató fiók nem függeszthető fel.",
"download_not_ready": "Ez a letöltés még nincs kész.",
"edit_unfinished": "Csak befejezett letöltés szerkeszthető.",
"filters_not_object": "A nézet szűrői hibás formátumúak.",
"google_no_userinfo": "A Google nem küldte vissza a fiók adatait. Próbálj újra belépni.",
"image_fetch_failed": "A képet nem sikerült betölteni.",
"image_host": "Erről a címről nem töltünk be képet.",
"invalid_cursor": "A lista pozíciója elavult — töltsd újra az oldalt.",
"invalid_email": "Adj meg egy érvényes e-mail címet.",
"invalid_interval": "Az intervallumnak {{min}} és {{max}} perc között kell lennie.",
"invalid_link": "Ez nem tűnik érvényes videólinknek.",
"invalid_role": "Ismeretlen szerepkör.",
"invalid_status": "Ismeretlen állapot.",
"invite_decided": "Erről a kérésről már döntöttek.",
"job_running": "Ez a feladat már fut.",
"last_active_admin": "Ez az egyetlen aktív admin — előbb tegyél meg valaki mást adminnak.",
"last_admin": "Ez az egyetlen admin — előbb tegyél meg valaki mást adminnak.",
"link_gone": "Ez a link már nem érhető el.",
"link_required": "Adj meg egy videólinket vagy -azonosítót.",
"media_missing": "A médiafájl nincs a lemezen.",
"message_empty": "Az üzenet üres.",
"message_rate": "Túl sok üzenet — várj egy kicsit.",
"message_self": "Magadnak nem küldhetsz üzenetet.",
"message_too_long": "Az üzenet túl hosszú.",
"messaging_set_up": "A biztonságos üzenetküldés már be van állítva.",
"name_required": "Adj meg egy nevet.",
"no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t.",
"no_messaging": "Ez a tag még nem állította be az üzenetküldést.",
"no_profile": "Nincs használható letöltési profil.",
"no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat.",
"not_authenticated": "Nem vagy bejelentkezve.",
"not_ready": "Még nem érhető el.",
"not_redownloadable": "Ez az elem nem tölthető le újra.",
"not_your_account": "Ebbe a fiókba itt nem léptél be.",
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak.",
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted.",
"own_role": "A saját szerepkörödet nem módosíthatod.",
"own_suspend": "A saját fiókodat nem függesztheted fel.",
"plex_disabled": "A Plex modul ki van kapcsolva.",
"plex_not_configured": "A Plex nincs beállítva — add meg a szerver címét és a tokent a Konfiguráció → Plex alatt.",
"plex_unreachable": "A Plex-szerver nem válaszolt. Ellenőrizd a címet és a tokent, majd próbáld újra.",
"plex_write_failed": "A Plex nem fogadta el a módosítást. Próbáld újra.",
"profile_name_required": "Adj meg egy profilnevet.",
"quota_reserve": "A mai közös YouTube-keret a tartalékánál tart — próbáld később.",
"quota_spent": "A mai közös YouTube-keret elfogyott. Próbáld holnap.",
"recipient_required": "Adj meg egy címzett e-mail címet.",
"registration_closed": "A regisztráció jelenleg zárva van.",
"search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.",
"search_term_required": "Írj be egy keresőkifejezést.",
"session_gone": "A lejátszási munkamenet véget ért.",
"setting_not_a_choice": "Válassz ezek közül: {{choices}}.",
"setting_not_a_number": "Adj meg egy egész számot.",
"setting_too_high": "Az érték nem lehet nagyobb, mint {{max}}.",
"setting_too_low": "Az érték nem lehet kisebb, mint {{min}}.",
"setup_token": "A beállítási token hiányzik vagy hibás.",
"share_own": "Ez már a te letöltésed.",
"share_password_required": "Ehhez a linkhez jelszó kell.",
"share_unfinished": "Csak befejezett letöltés osztható meg.",
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat.",
"smtp_off": "Az SMTP nincs beállítva.",
"spec_not_object": "A profil beállításai hibás formátumúak.",
"still_downloading": "Ez a letöltés még folyamatban van.",
"tag_name_taken": "Már van „{{name}}” nevű címkéd.",
"title_required": "Adj meg egy címet.",
"too_many_attempts": "Túl sok próbálkozás. Próbáld kicsit később.",
"unknown_setting": "Ismeretlen beállítás: {{key}}",
"value_missing": "Adj meg egy értéket.",
"video_gone": "Ez a videó már nem érhető el.",
"view_name_taken": "Már van „{{name}}” nevű nézeted.",
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront.",
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia.",
"wrong_password": "A jelenlegi jelszó helytelen.",
"wrong_share_password": "Hibás jelszó.",
"youtube_editing_off": "Előbb kapcsold be a YouTube-szerkesztést a Beállításokban.",
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés)."
} }
} }
@@ -1,109 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// One contract, one implementation — `notifyActionError` (see actionErrors.ts).
//
// `req` deliberately leaves 403/404/429 to the caller, and the routes behind those statuses
// hand-write English. Round 5 taught the shared helper to discard a plain-string `detail` in favour
// of the caller's already-translated fallback. Round 6 then found `ChatThread` carrying a SECOND,
// hand-written copy of the same contract (`e.detail || t("messages.sendFailed")`) — so the central
// fix could not reach it and the English sentence stayed in a Hungarian UI. A fix that lands in one
// place while a copy of the rule lives in another is this repo's recurring smell; this is the check
// that names the copies.
//
// The assertion pins the expected SET, never a count: a guard that counts is green the moment its
// glob stops matching (E10 review round 4 learned that the hard way).
// Reads a raw `HttpError.detail` and uses it as the message a user sees. Matches `e.detail ||`,
// `err.detail ? err.detail :`, `(e instanceof HttpError ? e.detail : undefined) ??`, a cast
// receiver (`(e as HttpError).detail`), an optional chain (`e?.detail`) and a `.detail` passed
// straight as a call argument (`setErr(e.detail)`).
//
// The receiver alternation and the absence of a `)` lookahead are both load-bearing. The first
// version required a bare identifier before `.detail` and excluded `.detail)` — so `setErr(e.detail)`,
// `String(e.detail)` and `(e as HttpError).detail` were all invisible, and because the file SET is
// compared with `toEqual` a file the pattern cannot see is simply never listed: the guard stayed
// green over a fresh copy of the contract written in any of those shapes (round 7). The `)`
// exclusion bought nothing — the only `.detail)` in the frontend is inside `lib/api/core.ts`, which
// `IMPLEMENTATION` skips wholesale — and re-scanning with the wider pattern returns the same five
// files, so it removes no false positive.
const RAW_DETAIL = /(?:\)|[A-Za-z_$][\w$]*)\s*\??\.detail\b(?!\s*=[^=])/;
// KNOWN LIMIT: a `detail` pulled out by destructuring (`const { detail } = err`) is out of reach
// of a line-level scan — by then there is no receiver left to match, and a pattern loose enough
// to catch it fires on every unrelated `detail` field too (measured: `const { data: detail } =
// useQuery(...)` in VideoInfoCard and an options destructure in plexPlayerHooks, neither of which
// touches an HttpError). Naming the hole beats pinning two innocent files into the list below as
// though they were copies of the contract.
// Every place that still prefers the server's raw string over a translated fallback. These are
// INLINE surfaces — a form field's error line, a setup step's banner — not toasts, and each shows a
// reason the user is mid-flow on, so converting them is a sweep of its own (with the ~60 backend
// routes that hand-write those strings). Logged on ROADMAP.md under the bug-hunt deferrals. What
// must not happen is a NEW one, or a toast path growing one again.
const KNOWN_RAW_DETAIL_CONSUMERS = [
"components/Feed.tsx", // the live-YouTube search error line
"components/KeyGate.tsx", // the passphrase field's own refusal
"components/SettingsPanel.tsx", // the change-password field
"components/SetupWizard.tsx", // the first-run wizard's banner
"components/Welcome.tsx", // the sign-in card's refusal panel
];
// Where the rule itself lives, plus the request layer that produces and localizes the value.
const IMPLEMENTATION = ["lib/actionErrors.ts", "lib/api/core.ts"];
function consumers(): { hits: string[]; scanned: number } {
const files = import.meta.glob("../**/*.{ts,tsx}", {
query: "?raw",
import: "default",
eager: true,
});
const hits: string[] = [];
for (const [rel, source] of Object.entries(files)) {
// Vite resolves the glob against THIS file, so same-directory hits come back as "./x".
const path = rel.startsWith("./") ? `lib/${rel.slice(2)}` : rel.replace(/^\.\.\//, "");
if (/\.test\.tsx?$|\.d\.ts$/.test(path)) continue;
if (IMPLEMENTATION.includes(path)) continue;
const lines = String(source)
.split("\n")
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)); // a comment about the rule is not a copy of it
if (lines.some((l) => RAW_DETAIL.test(l))) hits.push(path);
}
return { hits: hits.sort(), scanned: Object.keys(files).length };
}
describe("the refused-write contract", () => {
it("has no copies beyond the ones on the deferral list", () => {
const { hits, scanned } = consumers();
// The GLOB must have matched something, or this whole file is a green no-op — and the old
// version of this guard did not check that: it read `actionErrors.ts` by its own URL, which
// resolves whether or not `import.meta.glob` enumerated a single file. That only mattered
// because the pinned set was non-empty; the moment the deferral sweep below lands and the list
// becomes `[]`, `expect([]).toEqual([])` would pass over a glob that matched nothing (round 7).
expect(scanned).toBeGreaterThan(100);
expect(readFileSync(new URL("./actionErrors.ts", import.meta.url), "utf8")).toContain(
"detailData",
);
expect(hits).toEqual(KNOWN_RAW_DETAIL_CONSUMERS);
});
it("sees a raw detail written in any of the shapes this codebase uses", () => {
// The pattern is the whole guard, so pin the pattern itself: each of these is a real form
// found in this frontend or in its sibling PRs, and every one of the first five was invisible
// to the original regex.
for (const line of [
" setErr(e.detail);",
" reportError(err.detail);",
" notify({ message: (e as HttpError).detail });",
" toast(String(e.detail));",
" notify({ message: (err as HttpError).detail ?? fallback });",
' setError(e.detail ?? t("x"));',
' const msg = err.detail || t("x");',
" setErr(state.err?.detail);",
])
expect(RAW_DETAIL.test(line), line).toBe(true);
// And it must not fire on the places that PRODUCE or localize the value, which is why
// `IMPLEMENTATION` exists rather than a cleverer pattern: an assignment is not a read.
for (const line of [" detail = await r.json();", " this.detail = detail;"])
expect(RAW_DETAIL.test(line), line).toBe(false);
});
});
+6 -4
View File
@@ -9,11 +9,13 @@ const GATEWAY = new Set([502, 503, 504]);
/** /**
* WHY a write failed, in the reader's language — the line under the toast's title. * WHY a write failed, in the reader's language — the line under the toast's title.
* *
* A STRUCTURED server detail (`{code, reason}`) wins: `req` has already run it through * The server's own reason wins: `req` has already run the structured detail through
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is * `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* gone). A plain-string `detail` never does — it is whatever English the route hand-wrote, and * gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
* `reportFailure` leaves exactly 403/404/429 to the caller, so those used to put "No user with that * detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
* email." into a Hungarian UI (deep-review round 5). Otherwise the status says it. * "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
* says it.
*/ */
export function failureReason(err: unknown): string { export function failureReason(err: unknown): string {
const t = i18n.t.bind(i18n); const t = i18n.t.bind(i18n);
+28
View File
@@ -123,6 +123,17 @@ describe("uploadReq", () => {
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge"); expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
}); });
it("offers no string detail as the reason on the upload path either", async () => {
const p = uploadReq("/u", form());
const x = FakeXHR.last;
x.status = 405;
x.responseText = '{"detail":"Method Not Allowed"}';
x.onload!();
const err = (await p.catch((e: unknown) => e)) as HttpError;
expect(err).toBeInstanceOf(HttpError);
expect(err.detail).toBeUndefined();
});
it("reports a connectivity failure on a network error", async () => { it("reports a connectivity failure on a network error", async () => {
const p = uploadReq("/u", form()); const p = uploadReq("/u", form());
FakeXHR.last.onerror!(); FakeXHR.last.onerror!();
@@ -168,6 +179,23 @@ describe("req — a structured refusal reads in the user's language", () => {
}); });
}); });
it("never offers a string detail as the reason — only a structured one, translated", async () => {
// Every refusal the app answers is structured; a string can only be the framework's own English
// ("Method Not Allowed"). Surfaces print `err.detail` — some destructured, out of any source
// scan's reach — so the guarantee has to live here, where the value is made.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: "Method Not Allowed" }), {
status: 405,
headers: { "content-type": "application/json" },
}),
);
const err = (await req("/api/x", { method: "POST" }, { quiet: true }).catch(
(e: unknown) => e,
)) as HttpError;
expect(err).toBeInstanceOf(HttpError);
expect(err.detail).toBeUndefined();
});
it.each(["quota", "reconnect", "unavailable"])( it.each(["quota", "reconnect", "unavailable"])(
"says what to do about an action YouTube refused (%s)", "says what to do about an action YouTube refused (%s)",
async (reason) => { async (reason) => {
+15 -5
View File
@@ -50,7 +50,12 @@ export function isAborted(err: unknown): boolean {
export class HttpError extends Error { export class HttpError extends Error {
status: number; status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present // The server's reason IN THE READER'S LANGUAGE, when it gave one — i.e. a structured detail run
// through `localizeDetail`, never the raw text of a string detail. Every refusal the app answers is
// structured (backend `app/refusal.py`, enforced by its test_http_details.py); a string can only be
// the framework's own ("Method Not Allowed"), so it is left out rather than shown. That is what
// makes reading `.detail` safe anywhere — including destructured, which no source scan could see.
detail?: string;
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: unknown) { constructor(status: number, detail?: string, detailData?: unknown) {
super(detail || `HTTP ${status}`); super(detail || `HTTP ${status}`);
@@ -133,6 +138,11 @@ function localizeDetail(d: unknown): string {
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one. // wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube") if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable")); return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …). // A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic")); if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that // An admin action on an account or an invite that the server refused: activating an address that
@@ -326,8 +336,8 @@ export async function req<T = unknown>(
let detailData: unknown; let detailData: unknown;
try { try {
const body = await r.json(); const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail; // A string detail is not the app's (see `HttpError.detail`) and is not shown.
else if (body && body.detail && typeof body.detail === "object") { if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail; detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message detail = localizeDetail(body.detail); // structured error → localized message
} }
@@ -393,8 +403,8 @@ export function uploadReq<T = unknown>(
let detailData: unknown; let detailData: unknown;
try { try {
const body = JSON.parse(xhr.responseText); const body = JSON.parse(xhr.responseText);
if (body && typeof body.detail === "string") detail = body.detail; // A string detail is not the app's (see `HttpError.detail`) and is not shown.
else if (body && body.detail && typeof body.detail === "object") { if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail; detailData = body.detail;
detail = localizeDetail(body.detail); detail = localizeDetail(body.detail);
} }
@@ -0,0 +1,30 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// `local/jsx-comment-text` (eslint.config.js): a `//` inside JSX children is rendered as text. One
// reached the Playlists page past tsc, eslint and every test — only a visual check caught it. A rule
// that matches nothing looks exactly like a clean codebase, so this asserts it fires.
const RULE = "local/jsx-comment-text";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a comment inside JSX children", () => {
it("is refused — it would render as text", async () => {
expect(
await hits(`export const A = () => (\n <>\n // the list\n <ul />\n </>\n);\n`),
).toBe(1);
expect(await hits(`export const B = () => <div>/* note */</div>;\n`)).toBe(1);
});
it("is fine as a real comment, and text that merely contains a slash pair is text", async () => {
expect(await hits(`export const C = () => <div>{/* the list */}</div>;\n`)).toBe(0);
expect(await hits(`export const D = () => <p>See https://example.com</p>;\n`)).toBe(0);
});
});