mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
@@ -12,8 +12,13 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const jobName = body?.jobName;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const jobName = (body as { jobName?: unknown })?.jobName;
|
||||
|
||||
if (!jobName || typeof jobName !== "string") {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+49
-22
@@ -24,8 +24,17 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const query = req.nextUrl.searchParams.get("query");
|
||||
const type = req.nextUrl.searchParams.get("type");
|
||||
const query = req.nextUrl.searchParams.get("query")?.trim();
|
||||
const rawType = req.nextUrl.searchParams.get("type");
|
||||
const type: "movie" | "tv" | null =
|
||||
rawType === "movie" || rawType === "tv" ? rawType : null;
|
||||
|
||||
if (rawType && !type) {
|
||||
return NextResponse.json(
|
||||
{ error: "type must be movie or tv" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!query)
|
||||
return NextResponse.json(
|
||||
@@ -34,29 +43,47 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
|
||||
let results: TmdbSearchResponse;
|
||||
if (type === "movie") {
|
||||
results = await searchMovies(query);
|
||||
} else if (type === "tv") {
|
||||
results = await searchTv(query);
|
||||
} else {
|
||||
results = await searchMulti(query);
|
||||
try {
|
||||
if (type === "movie") {
|
||||
results = await searchMovies(query);
|
||||
} else if (type === "tv") {
|
||||
results = await searchTv(query);
|
||||
} else {
|
||||
results = await searchMulti(query);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch search results" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out person results from multi search
|
||||
const filtered = results.results.filter(
|
||||
(r) => r.media_type !== "person" || type,
|
||||
);
|
||||
// Filter out person results for multi search
|
||||
const filtered =
|
||||
type === "movie" || type === "tv"
|
||||
? results.results
|
||||
: results.results.filter((r) => r.media_type !== "person");
|
||||
|
||||
return NextResponse.json({
|
||||
results: filtered.map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: r.media_type ?? type,
|
||||
title: r.title ?? r.name,
|
||||
overview: r.overview,
|
||||
releaseDate: r.release_date ?? r.first_air_date,
|
||||
posterPath: tmdbImageUrl(r.poster_path, "w500"),
|
||||
popularity: r.popularity,
|
||||
voteAverage: r.vote_average,
|
||||
})),
|
||||
results: filtered
|
||||
.map((r) => {
|
||||
const mediaType =
|
||||
r.media_type === "movie" || r.media_type === "tv"
|
||||
? r.media_type
|
||||
: type;
|
||||
if (!mediaType) return null;
|
||||
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: mediaType,
|
||||
title: r.title ?? r.name,
|
||||
overview: r.overview,
|
||||
releaseDate: r.release_date ?? r.first_air_date,
|
||||
posterPath: tmdbImageUrl(r.poster_path, "w500"),
|
||||
popularity: r.popularity,
|
||||
voteAverage: r.vote_average,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,16 +12,38 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { tmdbId, type } = body;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!tmdbId || !type || !["movie", "tv"].includes(type)) {
|
||||
const parsed = body as { tmdbId?: unknown; type?: unknown };
|
||||
const type = parsed.type;
|
||||
const tmdbId =
|
||||
typeof parsed.tmdbId === "number"
|
||||
? parsed.tmdbId
|
||||
: Number.parseInt(String(parsed.tmdbId), 10);
|
||||
|
||||
if (
|
||||
!Number.isInteger(tmdbId) ||
|
||||
tmdbId < 1 ||
|
||||
(type !== "movie" && type !== "tv")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "tmdbId and type (movie|tv) are required" },
|
||||
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const title = await importTitle(tmdbId, type);
|
||||
return NextResponse.json(title);
|
||||
try {
|
||||
const title = await importTitle(tmdbId, type);
|
||||
return NextResponse.json(title);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to import title" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,38 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { tmdbId, type } = body;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!tmdbId || !type || !["movie", "tv"].includes(type)) {
|
||||
const parsed = body as { tmdbId?: unknown; type?: unknown };
|
||||
const type = parsed.type;
|
||||
const tmdbId =
|
||||
typeof parsed.tmdbId === "number"
|
||||
? parsed.tmdbId
|
||||
: Number.parseInt(String(parsed.tmdbId), 10);
|
||||
|
||||
if (
|
||||
!Number.isInteger(tmdbId) ||
|
||||
tmdbId < 1 ||
|
||||
(type !== "movie" && type !== "tv")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "tmdbId and type (movie|tv) are required" },
|
||||
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const title = await importTitle(tmdbId, type, { awaitEnrichment: true });
|
||||
return NextResponse.json({ id: title?.id });
|
||||
try {
|
||||
const title = await importTitle(tmdbId, type, { awaitEnrichment: true });
|
||||
return NextResponse.json({ id: title?.id });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to resolve title" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user