fix(feed): an out-of-range seed or cursor is refused, not a 500
A seed past a float overflowed the shuffle's start point, and a cursor holding Infinity or a 400-digit key overflowed its int/float read: both answered 500. The routes bound the seed (422), the start point folds it to 32 bits, and the cursor read counts an overflow as a bad cursor (400). A plan test holds that the walk reads ix_videos_shuffle_key with no sort over a realistic scope.
This commit is contained in:
@@ -719,8 +719,11 @@ _GOLDEN = 0.6180339887498949
|
||||
|
||||
|
||||
def shuffle_start(seed: int) -> float:
|
||||
"""The point in [0, 1) where a seed's shuffle starts on the ring of `Video.shuffle_key`."""
|
||||
return (seed * _GOLDEN) % 1.0
|
||||
"""The point in [0, 1) where a seed's shuffle starts on the ring of `Video.shuffle_key`.
|
||||
|
||||
Folded to 32 bits first: an int past a float's range overflowed the product, and one past 2**53
|
||||
left the product no fraction to land on. A seed the client draws (under a billion) is unchanged."""
|
||||
return ((seed % 2**32) * _GOLDEN) % 1.0
|
||||
|
||||
|
||||
def shuffle_walk(
|
||||
@@ -787,7 +790,7 @@ def feed_page(
|
||||
if vals is not None and cursor_id is not None:
|
||||
try:
|
||||
after = (int(vals[0]), float(vals[1]), str(cursor_id))
|
||||
except (TypeError, ValueError) as e:
|
||||
except (TypeError, ValueError, OverflowError) as e: # Infinity, a 400-digit key
|
||||
raise BadCursor("Invalid feed cursor") from e
|
||||
rows = shuffle_walk(db, query, seed, limit + 1, after)
|
||||
else:
|
||||
|
||||
@@ -6,9 +6,9 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
|
||||
one existence-leak policy.
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol, TypeVar
|
||||
from typing import Annotated, Any, Protocol, TypeVar
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi import HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -21,6 +21,10 @@ from app.youtube.client import YouTubeError, refusal_reason
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
|
||||
# A shuffle / surprise seed off the query string. The client draws one under a billion; bounded so a
|
||||
# hand-typed 400-digit one is a 422, not an int no float or bigint can hold further down.
|
||||
Seed = Annotated[int, Query(ge=-(2**53), le=2**53)]
|
||||
|
||||
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
|
||||
# library's): positions below this are "didn't really start" and within this of the end are
|
||||
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.discover import pins, rails
|
||||
from app.discover.budget import Budget, DEFAULT_BUDGET
|
||||
from app.models import User
|
||||
from app.plex.cards import norm_lang
|
||||
from app.routes._common import not_found
|
||||
from app.routes._common import Seed, not_found
|
||||
|
||||
router = APIRouter(prefix="/api/discover", tags=["discover"])
|
||||
|
||||
@@ -44,7 +44,7 @@ class ReorderIn(BaseModel):
|
||||
@router.get("")
|
||||
def get_discover(
|
||||
budget: Budget = DEFAULT_BUDGET,
|
||||
seed: int = 0,
|
||||
seed: Seed = 0,
|
||||
lang: str = "en",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -79,7 +79,7 @@ MAX_EXCLUDE = 32
|
||||
@router.get("/pick")
|
||||
def get_pick(
|
||||
budget: Budget = DEFAULT_BUDGET,
|
||||
seed: int = 0,
|
||||
seed: Seed = 0,
|
||||
exclude: str = "",
|
||||
start: int = 0,
|
||||
lang: str = "en",
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models import User
|
||||
from app.reader import queries, service, takeout
|
||||
from app.routes._common import (
|
||||
ProgressIn,
|
||||
Seed,
|
||||
capped_body,
|
||||
drain_body,
|
||||
kept_position,
|
||||
@@ -89,7 +90,7 @@ def _feed_params(
|
||||
def get_feed(
|
||||
params: dict = Depends(_feed_params),
|
||||
sort: str = "newest",
|
||||
seed: int = 0,
|
||||
seed: Seed = 0,
|
||||
limit: int = Query(default=60, le=200),
|
||||
cursor: str | None = None,
|
||||
user: User = Depends(current_user),
|
||||
|
||||
Reference in New Issue
Block a user