Harden API input validation, error handling, and optimistic rollbacks

Add try/catch around `request.json()` in all POST routes to return a
400 instead of crashing on malformed bodies. Validate `type` as a
strict `"movie" | "tv"` enum in search, discover, import, and resolve
routes. Validate `tmdbId` as a positive integer. Add a
`SORT_BY_PATTERN` regex and page-range check (1–500) to the discover
route. Wrap all outbound TMDB calls in try/catch and return 502 on
failure so clients get a structured error rather than an unhandled
rejection.

Fix optimistic-update rollbacks in `use-title-actions`: capture
`prevStatus` and `prevWatches` before each mutation and restore both
atoms in the catch block for catchUp, handleMarkSeason,
handleUnmarkSeason, and single-episode toggle.

Fix a bug in `getContinueWatchingFeed` where the watchDateMap could
hold a stale date for episodes watched more than once; the map now
keeps the most-recent `watchedAt` per episode.
This commit is contained in:
2026-03-05 13:16:08 -05:00
parent ff3c8a737b
commit f3a425a5a9
10 changed files with 240 additions and 76 deletions
+40 -3
View File
@@ -5,6 +5,9 @@ import { isTmdbConfigured } from "@/lib/config";
import { discover } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
const SORT_BY_PATTERN = /^[a-z_]+\.(asc|desc)$/;
const MAX_PAGE = 500;
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({
headers: await headers(),
@@ -24,10 +27,36 @@ export async function GET(req: NextRequest) {
}
const { searchParams } = req.nextUrl;
const type = searchParams.get("type") === "tv" ? "tv" : "movie";
const rawType = searchParams.get("type");
if (rawType && rawType !== "movie" && rawType !== "tv") {
return NextResponse.json(
{ error: "type must be movie or tv" },
{ status: 400 },
);
}
const type = rawType === "tv" ? "tv" : "movie";
const genre = searchParams.get("genre");
const sortBy = searchParams.get("sort_by") || "popularity.desc";
const page = searchParams.get("page") || "1";
const pageRaw = searchParams.get("page") || "1";
const page = Number.parseInt(pageRaw, 10);
if (!Number.isInteger(page) || page < 1 || page > MAX_PAGE) {
return NextResponse.json(
{ error: `page must be an integer between 1 and ${MAX_PAGE}` },
{ status: 400 },
);
}
if (!SORT_BY_PATTERN.test(sortBy)) {
return NextResponse.json(
{ error: "Invalid sort_by value" },
{ status: 400 },
);
}
if (genre && !/^\d+(,\d+)*$/.test(genre)) {
return NextResponse.json({ error: "Invalid genre value" }, { status: 400 });
}
const params: Record<string, string> = {
sort_by: sortBy,
@@ -37,7 +66,15 @@ export async function GET(req: NextRequest) {
params.with_genres = genre;
}
const results = await discover(type, params, Number(page));
let results: Awaited<ReturnType<typeof discover>>;
try {
results = await discover(type, params, page);
} catch {
return NextResponse.json(
{ error: "Failed to fetch discover results" },
{ status: 502 },
);
}
const filtered = results.results.filter((r) => r.poster_path);