diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 7952d503..37ffe171 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -256,7 +256,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) async ({ params: { id }, query: { limit, after, query, sort, filter }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub }, status, @@ -294,7 +294,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) userId: sub, })) as Entry[]; - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get entries of a serie" }, @@ -338,6 +338,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) params: { id }, query: { limit, after, query, sort, filter }, request: { url }, + headers, jwt: { sub }, status, }) => { @@ -373,7 +374,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) userId: sub, })) as Extra[]; - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get extras of a serie" }, @@ -410,6 +411,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) async ({ query: { limit, after, query, filter }, request: { url }, + headers, jwt: { sub }, }) => { const sort = newsSort; @@ -427,7 +429,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) userId: sub, })) as Entry[]; - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get new movies/episodes added recently." }, diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 41d997f1..9736ee23 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -67,7 +67,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) "/profiles/me/history", async ({ query: { sort, filter, query, limit, after }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub }, }) => { @@ -87,7 +87,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) progressQ: historyProgressQ, })) as Entry[]; - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { @@ -109,7 +109,11 @@ export const historyH = new Elysia({ tags: ["profiles"] }) async ({ params: { id }, query: { sort, filter, query, limit, after }, - headers: { "accept-language": languages, authorization }, + headers: { + "accept-language": languages, + authorization, + ...headers + }, request: { url }, status, }) => { @@ -132,7 +136,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) progressQ: historyProgressQ, })) as Entry[]; - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index afc2fff0..df2b6f67 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -69,7 +69,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) "/profiles/me/nextup", async ({ query: { sort, filter, query, limit, after }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub }, }) => { @@ -124,7 +124,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) .limit(limit) .execute({ userId: sub }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { diff --git a/api/src/controllers/profiles/watchlist.ts b/api/src/controllers/profiles/watchlist.ts index 1471213e..51da5686 100644 --- a/api/src/controllers/profiles/watchlist.ts +++ b/api/src/controllers/profiles/watchlist.ts @@ -142,7 +142,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) "/profiles/me/watchlist", async ({ query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub, settings }, }) => { @@ -162,7 +162,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) relations: ["nextEntry"], userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all movies/series in your watchlist" }, @@ -195,7 +195,11 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, jwt: { settings }, - headers: { "accept-language": languages, authorization }, + headers: { + "accept-language": languages, + authorization, + ...headers + }, request: { url }, status, }) => { @@ -218,7 +222,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) relations: ["nextEntry"], userId: uInfo.id, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { diff --git a/api/src/controllers/seasons.ts b/api/src/controllers/seasons.ts index 38010822..58a2edba 100644 --- a/api/src/controllers/seasons.ts +++ b/api/src/controllers/seasons.ts @@ -53,7 +53,7 @@ export const seasonsH = new Elysia({ tags: ["series"] }) async ({ params: { id }, query: { limit, after, query, sort, filter }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, status, }) => { @@ -110,7 +110,7 @@ export const seasonsH = new Elysia({ tags: ["series"] }) ) .limit(limit); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get seasons of a serie" }, diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts index b53cbe4e..952d8956 100644 --- a/api/src/controllers/shows/collections.ts +++ b/api/src/controllers/shows/collections.ts @@ -143,7 +143,7 @@ export const collections = new Elysia({ "", async ({ query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, jwt: { sub, settings }, request: { url }, }) => { @@ -158,7 +158,7 @@ export const collections = new Elysia({ preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all collections" }, @@ -227,7 +227,7 @@ export const collections = new Elysia({ async ({ params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, jwt: { sub, settings }, request: { url }, status, @@ -265,7 +265,7 @@ export const collections = new Elysia({ preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all movies in a collection" }, @@ -284,7 +284,7 @@ export const collections = new Elysia({ async ({ params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, jwt: { sub, settings }, request: { url }, status, @@ -322,7 +322,7 @@ export const collections = new Elysia({ preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all series in a collection" }, @@ -341,7 +341,7 @@ export const collections = new Elysia({ async ({ params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, jwt: { sub, settings }, request: { url }, status, @@ -375,7 +375,7 @@ export const collections = new Elysia({ preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all series & movies in a collection" }, diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index e47c7bd5..f839eb81 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -133,7 +133,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) "", async ({ query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub, settings }, }) => { @@ -148,7 +148,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all movies" }, diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 853d13b3..12d9d669 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -136,7 +136,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) "", async ({ query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub, settings }, }) => { @@ -151,7 +151,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all series" }, diff --git a/api/src/controllers/shows/shows.ts b/api/src/controllers/shows/shows.ts index b8320bd2..f6ccdf0b 100644 --- a/api/src/controllers/shows/shows.ts +++ b/api/src/controllers/shows/shows.ts @@ -63,7 +63,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] }) preferOriginal, ignoreInCollection, }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub, settings }, }) => { @@ -81,7 +81,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] }) preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all movies/series/collections" }, diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index 7d4cdf55..81726f02 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -189,7 +189,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) async ({ params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, jwt: { sub, settings }, status, @@ -269,7 +269,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) roles.showPk, ) .limit(limit); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { @@ -316,7 +316,11 @@ export const staffH = new Elysia({ tags: ["staff"] }) ) .get( "/staff", - async ({ query: { limit, after, sort, query }, request: { url } }) => { + async ({ + query: { limit, after, sort, query }, + request: { url }, + headers, + }) => { const items = await db .select() .from(staff) @@ -333,7 +337,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) staff.pk, ) .limit(limit); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { @@ -362,6 +366,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) params: { id }, query: { limit, after, query, sort, filter }, request: { url }, + headers, status, }) => { const [movie] = await db @@ -389,7 +394,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) sort, filter: and(eq(roles.showPk, movie.pk), filter), }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { @@ -429,6 +434,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) params: { id }, query: { limit, after, query, sort, filter }, request: { url }, + headers, status, }) => { const [serie] = await db @@ -456,7 +462,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) sort, filter: and(eq(roles.showPk, serie.pk), filter), }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index 81097886..ad576cce 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -228,7 +228,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) "", async ({ query: { limit, after, query, sort }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, request: { url }, }) => { const langs = processLanguages(languages); @@ -239,7 +239,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) sort, languages: langs, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all studios" }, @@ -302,7 +302,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) async ({ params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, jwt: { sub, settings }, request: { url }, status, @@ -344,7 +344,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) preferOriginal: preferOriginal ?? settings.preferOriginal, 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." }, @@ -363,7 +363,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) async ({ params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, jwt: { sub, settings }, request: { url }, status, @@ -406,7 +406,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all movies made by a studio." }, @@ -425,7 +425,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) async ({ params: { id }, query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, + headers: { "accept-language": languages, ...headers }, jwt: { sub, settings }, request: { url }, status, @@ -468,7 +468,7 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); - return createPage(items, { url, sort, limit }); + return createPage(items, { url, sort, limit, headers }); }, { detail: { description: "Get all series made by a studio." }, diff --git a/api/src/models/utils/page.ts b/api/src/models/utils/page.ts index 86a4b222..5a95c5cb 100644 --- a/api/src/models/utils/page.ts +++ b/api/src/models/utils/page.ts @@ -1,5 +1,6 @@ import type { ObjectOptions } from "@sinclair/typebox"; import { type TSchema, t } from "elysia"; +import { buildUrl } from "~/utils"; import { generateAfter } from "./keyset-paginate"; import type { Sort } from "./sort"; @@ -18,11 +19,31 @@ export const Page = (schema: T, options?: ObjectOptions) => export const createPage = ( items: T[], - { url, sort, limit }: { url: string; sort: Sort; limit: number }, + { + url, + sort, + limit, + headers, + }: { + url: string; + sort: Sort; + limit: number; + headers?: Record; + }, ) => { - let next: string | null = null; 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) { uri.searchParams.set("sort", `random:${sort.random.seed}`); url = uri.toString(); @@ -35,5 +56,5 @@ export const createPage = ( uri.searchParams.set("after", generateAfter(items[items.length - 1], sort)); next = uri.toString(); } - return { items, this: url, next }; + return { items, this: current, next }; }; diff --git a/api/tests/misc/x-forwarded-headers.test.ts b/api/tests/misc/x-forwarded-headers.test.ts new file mode 100644 index 00000000..cc4e2185 --- /dev/null +++ b/api/tests/misc/x-forwarded-headers.test.ts @@ -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"); + }); +});