diff --git a/src/index.ts b/src/index.ts index 1c3b93b..b327b4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Context, Env, Hono } from 'hono'; +import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import { ScrapeMedia, @@ -6,7 +6,9 @@ import { makeStandardFetcher, targets, } from '@movie-web/providers'; -import { ZodError, z } from 'zod'; +import { ZodError } from 'zod'; +import { mediaSchema } from '@/schema'; +import { validateTurnstile } from '@/turnstile'; const app = new Hono(); let eventId = 0; @@ -18,84 +20,18 @@ const providers = makeProviders({ target: targets.NATIVE, }); -async function outputEvent( +async function writeSSEEvent( stream: Parameters['1']>['0'], event: string, - data: any, + data: any | undefined, ) { return await stream.writeSSE({ event, - data: JSON.stringify(data), + data: data ? JSON.stringify(data) : '', id: String(eventId++), }); } -const tmdbIdSchema = z.string().regex(/^\d+$/); - -const mediaSchema = z - .discriminatedUnion('type', [ - z.object({ - type: z.literal('movie'), - title: z.string().min(1), - releaseYear: z.coerce.number().int().gt(0), - tmdbId: tmdbIdSchema, - }), - z.object({ - type: z.literal('show'), - title: z.string().min(1), - releaseYear: z.coerce.number().int().gt(0), - tmdbId: tmdbIdSchema, - episodeNumber: z.coerce.number().int(), - episodeTmdbId: tmdbIdSchema, - seasonNumber: z.coerce.number().int(), - seasonTmdbId: tmdbIdSchema, - }), - ]) - .transform((query) => { - if (query.type == 'movie') return query; - - return { - type: query.type, - title: query.title, - releaseYear: query.releaseYear, - tmdbId: query.tmdbId, - episode: { - number: query.episodeNumber, - tmdbId: query.episodeTmdbId, - }, - season: { - number: query.seasonNumber, - tmdbId: query.seasonTmdbId, - }, - }; - }); - -async function validateTurnstile(context: Context) { - const turnstileSecret = context.env?.TURNSTILE_SECRET as string | undefined; - - const token = context.req.header('cf-turnstile-token') || ''; - - // TODO: Make this cross platform - const ip = context.req.header('CF-Connecting-IP') || ''; - - const formData = new FormData(); - formData.append('secret', turnstileSecret || ''); - formData.append('response', token); - formData.append('remoteip', ip); - - const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; - const result = await fetch(url, { - body: formData, - method: 'POST', - }); - - const outcome = await result.json(); - return { - success: outcome.success as boolean, - errorCodes: outcome['error-codes'] as string[], - }; -} - app.get('/scrape', async (context) => { const queryParams = context.req.query(); @@ -131,25 +67,25 @@ app.get('/scrape', async (context) => { media, events: { discoverEmbeds(evt) { - outputEvent(stream, 'discoverEmbeds', evt); + writeSSEEvent(stream, 'discoverEmbeds', evt); }, init(evt) { - outputEvent(stream, 'init', evt); + writeSSEEvent(stream, 'init', evt); }, start(evt) { - outputEvent(stream, 'start', evt); + writeSSEEvent(stream, 'start', evt); }, update(evt) { - outputEvent(stream, 'update', evt); + writeSSEEvent(stream, 'update', evt); }, }, }); if (output) { - return await outputEvent(stream, 'completed', output); + return await writeSSEEvent(stream, 'completed', output); } - stream.writeSSE({ event: 'noOutput', data: '', id: String(eventId++) }); + return await writeSSEEvent(stream, 'noOutput', ''); }); }); diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..ce7ae84 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +export const tmdbIdSchema = z.string().regex(/^\d+$/); + +export const mediaSchema = z + .discriminatedUnion('type', [ + z.object({ + type: z.literal('movie'), + title: z.string().min(1), + releaseYear: z.coerce.number().int().gt(0), + tmdbId: tmdbIdSchema, + }), + z.object({ + type: z.literal('show'), + title: z.string().min(1), + releaseYear: z.coerce.number().int().gt(0), + tmdbId: tmdbIdSchema, + episodeNumber: z.coerce.number().int(), + episodeTmdbId: tmdbIdSchema, + seasonNumber: z.coerce.number().int(), + seasonTmdbId: tmdbIdSchema, + }), + ]) + .transform((query) => { + if (query.type == 'movie') return query; + + return { + type: query.type, + title: query.title, + releaseYear: query.releaseYear, + tmdbId: query.tmdbId, + episode: { + number: query.episodeNumber, + tmdbId: query.episodeTmdbId, + }, + season: { + number: query.seasonNumber, + tmdbId: query.seasonTmdbId, + }, + }; + }); diff --git a/src/turnstile.ts b/src/turnstile.ts new file mode 100644 index 0000000..f240408 --- /dev/null +++ b/src/turnstile.ts @@ -0,0 +1,27 @@ +import { Context, Env } from 'hono'; + +export async function validateTurnstile(context: Context) { + const turnstileSecret = context.env?.TURNSTILE_SECRET as string | undefined; + + const token = context.req.header('cf-turnstile-token') || ''; + + // TODO: Make this cross platform + const ip = context.req.header('CF-Connecting-IP') || ''; + + const formData = new FormData(); + formData.append('secret', turnstileSecret || ''); + formData.append('response', token); + formData.append('remoteip', ip); + + const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + const result = await fetch(url, { + body: formData, + method: 'POST', + }); + + const outcome = await result.json(); + return { + success: outcome.success as boolean, + errorCodes: outcome['error-codes'] as string[], + }; +} diff --git a/src/utils/body.ts b/src/utils/body.ts deleted file mode 100644 index 9f10d08..0000000 --- a/src/utils/body.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { H3Event } from 'h3'; - -export function hasBody(event: H3Event) { - const method = event.method.toUpperCase(); - return ['PUT', 'POST', 'PATCH', 'DELETE'].includes(method); -} - -export async function getBodyBuffer( - event: H3Event, -): Promise { - if (!hasBody(event)) return; - return await readRawBody(event, false); -} diff --git a/src/utils/headers.ts b/src/utils/headers.ts deleted file mode 100644 index f87c2f6..0000000 --- a/src/utils/headers.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { H3Event } from 'h3'; - -const blacklistedHeaders = [ - 'cf-connecting-ip', - 'cf-worker', - 'cf-ray', - 'cf-visitor', - 'cf-ew-via', - 'x-forwarded-for', - 'x-forwarded-host', - 'x-forwarded-proto', - 'forwarded', - 'x-real-ip', -]; - -function copyHeader( - headers: Headers, - outputHeaders: Headers, - inputKey: string, - outputKey: string, -) { - if (headers.has(inputKey)) - outputHeaders.set(outputKey, headers.get(inputKey) ?? ''); -} - -export function getProxyHeaders(headers: Headers): Headers { - const output = new Headers(); - - const headerMap: Record = { - 'X-Cookie': 'Cookie', - 'X-Referer': 'Referer', - 'X-Origin': 'Origin', - }; - Object.entries(headerMap).forEach((entry) => { - copyHeader(headers, output, entry[0], entry[1]); - }); - - output.set( - 'User-Agent', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0', - ); - - return output; -} - -export function getAfterResponseHeaders( - headers: Headers, - finalUrl: string, -): Record { - const output: Record = {}; - - if (headers.has('Set-Cookie')) - output['X-Set-Cookie'] = headers.get('Set-Cookie') ?? ''; - - return { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Expose-Headers': '*', - Vary: 'Origin', - 'X-Final-Destination': finalUrl, - }; -} - -export function removeHeadersFromEvent(event: H3Event, key: string) { - const normalizedKey = key.toLowerCase(); - if (event.node.req.headers[normalizedKey]) - delete event.node.req.headers[normalizedKey]; -} - -export function cleanupHeadersBeforeProxy(event: H3Event) { - blacklistedHeaders.forEach((key) => { - removeHeadersFromEvent(event, key); - }); -} diff --git a/src/utils/sending.ts b/src/utils/sending.ts deleted file mode 100644 index 145015b..0000000 --- a/src/utils/sending.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { H3Event, EventHandlerRequest } from 'h3'; - -export async function sendJson(ops: { - event: H3Event; - data: Record; - status?: number; -}) { - setResponseStatus(ops.event, ops.status ?? 200); - await send(ops.event, JSON.stringify(ops.data, null, 2), 'application/json'); -}