Fix pagination URLs when behind SSL-terminating reverse proxy

This commit is contained in:
Zoe Roux 2025-11-13 14:02:37 +01:00
parent f4b1ab5fa0
commit 5e63b57440
13 changed files with 190 additions and 47 deletions

View File

@ -256,7 +256,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub }, jwt: { sub },
status, status,
@ -294,7 +294,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
userId: sub, userId: sub,
})) as Entry[]; })) as Entry[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get entries of a serie" }, detail: { description: "Get entries of a serie" },
@ -338,6 +338,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
request: { url }, request: { url },
headers,
jwt: { sub }, jwt: { sub },
status, status,
}) => { }) => {
@ -373,7 +374,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
userId: sub, userId: sub,
})) as Extra[]; })) as Extra[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get extras of a serie" }, detail: { description: "Get extras of a serie" },
@ -410,6 +411,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
async ({ async ({
query: { limit, after, query, filter }, query: { limit, after, query, filter },
request: { url }, request: { url },
headers,
jwt: { sub }, jwt: { sub },
}) => { }) => {
const sort = newsSort; const sort = newsSort;
@ -427,7 +429,7 @@ export const entriesH = new Elysia({ tags: ["series"] })
userId: sub, userId: sub,
})) as Entry[]; })) as Entry[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get new movies/episodes added recently." }, detail: { description: "Get new movies/episodes added recently." },

View File

@ -67,7 +67,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
"/profiles/me/history", "/profiles/me/history",
async ({ async ({
query: { sort, filter, query, limit, after }, query: { sort, filter, query, limit, after },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub }, jwt: { sub },
}) => { }) => {
@ -87,7 +87,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
progressQ: historyProgressQ, progressQ: historyProgressQ,
})) as Entry[]; })) as Entry[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {
@ -109,7 +109,11 @@ export const historyH = new Elysia({ tags: ["profiles"] })
async ({ async ({
params: { id }, params: { id },
query: { sort, filter, query, limit, after }, query: { sort, filter, query, limit, after },
headers: { "accept-language": languages, authorization }, headers: {
"accept-language": languages,
authorization,
...headers
},
request: { url }, request: { url },
status, status,
}) => { }) => {
@ -132,7 +136,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
progressQ: historyProgressQ, progressQ: historyProgressQ,
})) as Entry[]; })) as Entry[];
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {

View File

@ -69,7 +69,7 @@ export const nextup = new Elysia({ tags: ["profiles"] })
"/profiles/me/nextup", "/profiles/me/nextup",
async ({ async ({
query: { sort, filter, query, limit, after }, query: { sort, filter, query, limit, after },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub }, jwt: { sub },
}) => { }) => {
@ -124,7 +124,7 @@ export const nextup = new Elysia({ tags: ["profiles"] })
.limit(limit) .limit(limit)
.execute({ userId: sub }); .execute({ userId: sub });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {

View File

@ -142,7 +142,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
"/profiles/me/watchlist", "/profiles/me/watchlist",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@ -162,7 +162,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
relations: ["nextEntry"], relations: ["nextEntry"],
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies/series in your watchlist" }, detail: { description: "Get all movies/series in your watchlist" },
@ -195,7 +195,11 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
jwt: { settings }, jwt: { settings },
headers: { "accept-language": languages, authorization }, headers: {
"accept-language": languages,
authorization,
...headers
},
request: { url }, request: { url },
status, status,
}) => { }) => {
@ -218,7 +222,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] })
relations: ["nextEntry"], relations: ["nextEntry"],
userId: uInfo.id, userId: uInfo.id,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {

View File

@ -53,7 +53,7 @@ export const seasonsH = new Elysia({ tags: ["series"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
status, status,
}) => { }) => {
@ -110,7 +110,7 @@ export const seasonsH = new Elysia({ tags: ["series"] })
) )
.limit(limit); .limit(limit);
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get seasons of a serie" }, detail: { description: "Get seasons of a serie" },

View File

@ -143,7 +143,7 @@ export const collections = new Elysia({
"", "",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
}) => { }) => {
@ -158,7 +158,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all collections" }, detail: { description: "Get all collections" },
@ -227,7 +227,7 @@ export const collections = new Elysia({
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@ -265,7 +265,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies in a collection" }, detail: { description: "Get all movies in a collection" },
@ -284,7 +284,7 @@ export const collections = new Elysia({
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@ -322,7 +322,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series in a collection" }, detail: { description: "Get all series in a collection" },
@ -341,7 +341,7 @@ export const collections = new Elysia({
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@ -375,7 +375,7 @@ export const collections = new Elysia({
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series & movies in a collection" }, detail: { description: "Get all series & movies in a collection" },

View File

@ -133,7 +133,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
"", "",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@ -148,7 +148,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies" }, detail: { description: "Get all movies" },

View File

@ -136,7 +136,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
"", "",
async ({ async ({
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@ -151,7 +151,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series" }, detail: { description: "Get all series" },

View File

@ -63,7 +63,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
preferOriginal, preferOriginal,
ignoreInCollection, ignoreInCollection,
}, },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
}) => { }) => {
@ -81,7 +81,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies/series/collections" }, detail: { description: "Get all movies/series/collections" },

View File

@ -189,7 +189,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
jwt: { sub, settings }, jwt: { sub, settings },
status, status,
@ -269,7 +269,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
roles.showPk, roles.showPk,
) )
.limit(limit); .limit(limit);
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {
@ -316,7 +316,11 @@ export const staffH = new Elysia({ tags: ["staff"] })
) )
.get( .get(
"/staff", "/staff",
async ({ query: { limit, after, sort, query }, request: { url } }) => { async ({
query: { limit, after, sort, query },
request: { url },
headers,
}) => {
const items = await db const items = await db
.select() .select()
.from(staff) .from(staff)
@ -333,7 +337,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
staff.pk, staff.pk,
) )
.limit(limit); .limit(limit);
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {
@ -362,6 +366,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
request: { url }, request: { url },
headers,
status, status,
}) => { }) => {
const [movie] = await db const [movie] = await db
@ -389,7 +394,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
sort, sort,
filter: and(eq(roles.showPk, movie.pk), filter), filter: and(eq(roles.showPk, movie.pk), filter),
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {
@ -429,6 +434,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
params: { id }, params: { id },
query: { limit, after, query, sort, filter }, query: { limit, after, query, sort, filter },
request: { url }, request: { url },
headers,
status, status,
}) => { }) => {
const [serie] = await db const [serie] = await db
@ -456,7 +462,7 @@ export const staffH = new Elysia({ tags: ["staff"] })
sort, sort,
filter: and(eq(roles.showPk, serie.pk), filter), filter: and(eq(roles.showPk, serie.pk), filter),
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { detail: {

View File

@ -228,7 +228,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
"", "",
async ({ async ({
query: { limit, after, query, sort }, query: { limit, after, query, sort },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
request: { url }, request: { url },
}) => { }) => {
const langs = processLanguages(languages); const langs = processLanguages(languages);
@ -239,7 +239,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
sort, sort,
languages: langs, languages: langs,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all studios" }, detail: { description: "Get all studios" },
@ -302,7 +302,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@ -344,7 +344,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series & movies made by a studio." }, detail: { description: "Get all series & movies made by a studio." },
@ -363,7 +363,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@ -406,7 +406,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all movies made by a studio." }, detail: { description: "Get all movies made by a studio." },
@ -425,7 +425,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
async ({ async ({
params: { id }, params: { id },
query: { limit, after, query, sort, filter, preferOriginal }, query: { limit, after, query, sort, filter, preferOriginal },
headers: { "accept-language": languages }, headers: { "accept-language": languages, ...headers },
jwt: { sub, settings }, jwt: { sub, settings },
request: { url }, request: { url },
status, status,
@ -468,7 +468,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] })
preferOriginal: preferOriginal ?? settings.preferOriginal, preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub, userId: sub,
}); });
return createPage(items, { url, sort, limit }); return createPage(items, { url, sort, limit, headers });
}, },
{ {
detail: { description: "Get all series made by a studio." }, detail: { description: "Get all series made by a studio." },

View File

@ -1,5 +1,6 @@
import type { ObjectOptions } from "@sinclair/typebox"; import type { ObjectOptions } from "@sinclair/typebox";
import { type TSchema, t } from "elysia"; import { type TSchema, t } from "elysia";
import { buildUrl } from "~/utils";
import { generateAfter } from "./keyset-paginate"; import { generateAfter } from "./keyset-paginate";
import type { Sort } from "./sort"; import type { Sort } from "./sort";
@ -18,11 +19,31 @@ export const Page = <T extends TSchema>(schema: T, options?: ObjectOptions) =>
export const createPage = <T>( export const createPage = <T>(
items: T[], items: T[],
{ url, sort, limit }: { url: string; sort: Sort; limit: number }, {
url,
sort,
limit,
headers,
}: {
url: string;
sort: Sort;
limit: number;
headers?: Record<string, string | undefined>;
},
) => { ) => {
let next: string | null = null;
const uri = new URL(url); const uri = new URL(url);
const forwardedProto = headers?.["x-forwarded-proto"];
if (forwardedProto) {
uri.protocol = forwardedProto;
}
const forwardedHost = headers?.["x-forwarded-host"];
if (forwardedHost) {
uri.host = forwardedHost;
}
const current = uri.toString();
let next: string | null = null;
if (sort.random) { if (sort.random) {
uri.searchParams.set("sort", `random:${sort.random.seed}`); uri.searchParams.set("sort", `random:${sort.random.seed}`);
url = uri.toString(); url = uri.toString();
@ -35,5 +56,5 @@ export const createPage = <T>(
uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); uri.searchParams.set("after", generateAfter(items[items.length - 1], sort));
next = uri.toString(); next = uri.toString();
} }
return { items, this: url, next }; return { items, this: current, next };
}; };

View File

@ -0,0 +1,106 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { getJwtHeaders } from "tests/helpers/jwt";
import { expectStatus } from "tests/utils";
import { db } from "~/db";
import { shows } from "~/db/schema";
import { bubble } from "~/models/examples";
import { dune1984 } from "~/models/examples/dune-1984";
import { dune } from "~/models/examples/dune-2021";
import { createMovie, getMovies, handlers } from "../helpers";
beforeAll(async () => {
await db.delete(shows);
for (const movie of [bubble, dune1984, dune]) {
const [ret, _] = await createMovie(movie);
expect(ret.status).toBe(201);
}
});
describe("X-Forwarded-Proto header support", () => {
it("Pagination URLs use HTTPS when X-Forwarded-Proto is https", async () => {
const resp = await handlers.handle(
new Request("http://localhost/api/movies?limit=2", {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
},
}),
);
const body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body).toMatchObject({
items: expect.any(Array),
this: "https://localhost/api/movies?limit=2",
next: expect.stringContaining("https://localhost/api/movies?limit=2"),
});
});
it("Pagination URLs use HTTP when no X-Forwarded-Proto header", async () => {
const [resp, body] = await getMovies({
limit: 2,
langs: "en",
});
expectStatus(resp, body).toBe(200);
expect(body).toMatchObject({
items: expect.any(Array),
this: "http://localhost/api/movies?limit=2",
next: expect.stringContaining("http://localhost/api/movies?limit=2"),
});
});
it("X-Forwarded-Host header changes the host in pagination URLs", async () => {
const resp = await handlers.handle(
new Request("http://localhost/api/movies?limit=2", {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
"x-forwarded-host": "kyoo.example.com",
},
}),
);
const body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body).toMatchObject({
items: expect.any(Array),
this: "https://kyoo.example.com/api/movies?limit=2",
next: expect.stringContaining(
"https://kyoo.example.com/api/movies?limit=2",
),
});
});
it("Second page of pagination respects X-Forwarded headers", async () => {
let resp = await handlers.handle(
new Request("http://localhost/api/movies?limit=2", {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
"x-forwarded-host": "kyoo.example.com",
},
}),
);
let body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body.next).toBeTruthy();
expect(body.next).toContain("https://kyoo.example.com");
// Follow the next link with the same headers
resp = await handlers.handle(
new Request(body.next, {
headers: {
...(await getJwtHeaders()),
"x-forwarded-proto": "https",
"x-forwarded-host": "kyoo.example.com",
},
}),
);
body = await resp.json();
expectStatus(resp, body).toBe(200);
expect(body.this).toContain("https://kyoo.example.com");
});
});