From 4791648cc02d05cf036a887ee85326bb3e75047c Mon Sep 17 00:00:00 2001 From: David Ludwig Date: Fri, 16 Apr 2021 22:15:34 -0500 Subject: [PATCH] Verify IMDb movie IDs --- .../src/server/services/MovieSearch.ts | 76 ++++++++++++++++++- .../src/server/services/WebServer/index.ts | 3 +- .../WebServer/requests/RequestMovieRequest.ts | 22 ++++++ .../WebServer/routes/RouteRegisterFactory.ts | 21 ++++- .../server/services/WebServer/routes/api.ts | 50 +++++++++--- services/request/src/server/util.ts | 5 ++ 6 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 services/request/src/server/services/WebServer/requests/RequestMovieRequest.ts diff --git a/services/request/src/server/services/MovieSearch.ts b/services/request/src/server/services/MovieSearch.ts index eb6dd0f..fcbb6e9 100644 --- a/services/request/src/server/services/MovieSearch.ts +++ b/services/request/src/server/services/MovieSearch.ts @@ -3,9 +3,12 @@ import TheMovieDb from "@lib/tmdb"; import { env, secret } from "@server/util"; import { readFile } from "fs/promises"; import TVDB from "tvdb-v4"; +import { request, Agent } from "https"; import Service from "./Service"; import TvDb from "./TvDb"; +const CACHE_CLEAR_INTERVAL = 1000*10; // 60 seconds + export default class MovieSearch extends Service { protected tmdb!: TheMovieDb; @@ -15,8 +18,23 @@ export default class MovieSearch extends Service */ protected tvdb!: TvDb; + /** + * Hold a cache of recently fetch movie IMDB ids to speed up request times + */ + protected imdbCache: { [imdbId: string]: number }; + + /** + * Hold the clear cache interval reference + */ + private __clearCacheInterval: NodeJS.Timeout | null; + + /** + * Create a new Movie Search service instance + */ public constructor(app: Application) { super("Movie Search", app); + this.imdbCache = {}; + this.__clearCacheInterval = null; } /** @@ -41,13 +59,69 @@ export default class MovieSearch extends Service // no-op } + /** + * Store an IMDb ID in cache + */ + protected cacheImdbId(id: string) { + this.imdbCache[id] = Date.now() + CACHE_CLEAR_INTERVAL; + if (this.__clearCacheInterval === null) { + this.__clearCacheInterval = setInterval(() => this.cleanImdbCache(), CACHE_CLEAR_INTERVAL); + } + } + + /** + * Clean the IMDb cache + */ + protected cleanImdbCache() { + let now = Date.now(); + let remaining = 0; + for (let key in this.imdbCache) { + if (now > this.imdbCache[key]) { + delete this.imdbCache[key]; + } else { + remaining++; + } + } + if (remaining == 0) { + clearInterval(this.__clearCacheInterval); + this.__clearCacheInterval = null; + } + } + // Interface ----------------------------------------------------------------------------------- + /** + * Verify the IMDb ID exists + */ + public verifyImdbId(id: string) { + return new Promise((resolve, reject) => { + // If the ID is cached, no need to fetch it + if (id in this.imdbCache) { + resolve(true); + } + // Verify the movie exists on IMDb by checking for a 404 + let req = request({ method: "HEAD", host: "www.imdb.com", path: `/title/${id}/` }, (response) => { + response.resume(); + if (response.statusCode == undefined) { + reject(); + return; + } + resolve(response.statusCode === 200); + response.destroy(); + }); + req.end(); + }); + } + /** * Get the details of a movie */ public async details(id: number) { - return await this.tmdb.movie(id); + let movie = await this.tmdb.movie(id); + if (movie.imdb_id != null) { + this.cacheImdbId(movie.imdb_id); + } + return movie; } /** diff --git a/services/request/src/server/services/WebServer/index.ts b/services/request/src/server/services/WebServer/index.ts index b70ffc6..1960ef4 100644 --- a/services/request/src/server/services/WebServer/index.ts +++ b/services/request/src/server/services/WebServer/index.ts @@ -85,7 +85,7 @@ export default class WebServer extends Service */ protected registerRoutes() { this.registerSpaRoutes(); - let factory = new RouteRegisterFactory(this.fastify, this.app); + let factory = new RouteRegisterFactory(this, this.fastify, this.app); for (let group in routes) { (routes)[group](factory, this.app); } @@ -98,7 +98,6 @@ export default class WebServer extends Service /** * If the app is in production mode, serve static assets. * If the app is in development mode, forward 404's to Vite. - * NOTE: Static assets may be moved to nginx later... not sure yet */ if (process.env["NODE_ENV"] == "production") { this.fastify.register(fastifyStatic, { diff --git a/services/request/src/server/services/WebServer/requests/RequestMovieRequest.ts b/services/request/src/server/services/WebServer/requests/RequestMovieRequest.ts new file mode 100644 index 0000000..11eba29 --- /dev/null +++ b/services/request/src/server/services/WebServer/requests/RequestMovieRequest.ts @@ -0,0 +1,22 @@ +import { FastifyRequest } from "fastify"; +import validate from "validate.js"; +import Request from "./Request"; + + +export default class MovieSearchRequest extends Request +{ + public validate(request: FastifyRequest) { + return validate.async(request.params, { + imdb_id: { + presence: { + allowEmpty: false, + message: "IMDb ID is required" + }, + format: { + pattern: "tt[0-9]+", + message: "Invalid IMDb ID" + } + } + },{ fullMessages: false }); + } +} diff --git a/services/request/src/server/services/WebServer/routes/RouteRegisterFactory.ts b/services/request/src/server/services/WebServer/routes/RouteRegisterFactory.ts index 34e8a28..bdf34c8 100644 --- a/services/request/src/server/services/WebServer/routes/RouteRegisterFactory.ts +++ b/services/request/src/server/services/WebServer/routes/RouteRegisterFactory.ts @@ -3,6 +3,7 @@ import Application from "@server/Application"; import { handleMiddleware, MiddlewareMethod } from "../middleware"; import fastifyHttpProxy from "fastify-http-proxy"; import { auth } from "../middleware/auth"; +import WebServer from ".."; export type RouteFactory = ((factory: RouteRegisterFactory) => void) | ((factory: RouteRegisterFactory, app: Application) => void); @@ -19,6 +20,11 @@ export default class RouteRegisterFactory */ protected readonly fastify: FastifyInstance; + /** + * The webserver instance + */ + protected readonly webserver: WebServer; + /** * The list of middleware */ @@ -32,9 +38,10 @@ export default class RouteRegisterFactory /** * Create a new route factory */ - public constructor(fastify: FastifyInstance, app: Application) { + public constructor(webserver: WebServer, fastify: FastifyInstance, app: Application) { this.app = app; this.fastify = fastify; + this.webserver = webserver; } /** @@ -63,9 +70,9 @@ export default class RouteRegisterFactory public get(path: string, handler: RouteHandlerMethod): void; public get(path: string, middleware: MiddlewareMethod[], handler: RouteHandlerMethod): void; public get(path: string, middleware: RouteHandlerMethod|MiddlewareMethod[], handler?: RouteHandlerMethod) { + this.log(`Registering GET: ${this.pathPrefix}${path}`); handler = (handler ?? middleware); middleware = (middleware instanceof Array) ? this.middleware.concat(middleware) : this.middleware; - console.log("Registering route:", `${this.pathPrefix}${path}`); this.fastify.get(`${this.pathPrefix}${path}`, handleMiddleware(middleware, handler)); } @@ -84,10 +91,18 @@ export default class RouteRegisterFactory * Register a proxy route */ public proxy(path: string, upstream: string, middleware?: MiddlewareMethod[]) { + this.log(`Registering proxy: ${this.pathPrefix}${path} -> ${upstream}`); this.fastify.register(fastifyHttpProxy, { - prefix: path, + prefix: `${this.pathPrefix}${path}`, beforeHandler: middleware ? handleMiddleware(middleware) : undefined, upstream }); } + + /** + * Log under the WebServer service + */ + public log(...args: any[]) { + this.webserver.log(...args); + } } diff --git a/services/request/src/server/services/WebServer/routes/api.ts b/services/request/src/server/services/WebServer/routes/api.ts index 2da60a1..a932800 100644 --- a/services/request/src/server/services/WebServer/routes/api.ts +++ b/services/request/src/server/services/WebServer/routes/api.ts @@ -1,27 +1,53 @@ import Application from "@server/Application"; import MovieSearch from "@server/services/MovieSearch"; +import RequestMovieRequest from "../requests/RequestMovieRequest"; import { auth } from "../middleware/auth"; import RouteRegisterFactory from "./RouteRegisterFactory"; +import handle from "../requests"; /** * Register API routes */ export default function register(factory: RouteRegisterFactory, app: Application) { - factory.proxy("/api/tvdb/artwork", "https://artworks.thetvdb.com/"); - factory.proxy("/api/tmdb/image", "https://image.tmdb.org/t/p/"); + factory.prefix("/api", [auth], (factory, app) => { + /** + * Service proxies + */ + factory.proxy("/tvdb/artwork", "https://artworks.thetvdb.com/"); + factory.proxy("/tmdb/image", "https://image.tmdb.org/t/p/"); - factory.get("/api/movie/search", [auth], async (request, reply) => { - let query = (request.query)["query"]; - let year = parseInt((request.query)["year"]) || undefined; - let results = await app.service("Movie Search").search(query, year); - reply.send({ status: "success", data: results }); - }); + /** + * Search for a movie + */ + factory.get("/movie/search", async (request, reply) => { + let query = (request.query)["query"]; + let year = parseInt((request.query)["year"]) || undefined; + let results = await app.service("Movie Search").search(query, year); + reply.send({ status: "success", data: results }); + }); + + /** + * Lookup a movie's details + */ + factory.get("/movie/details/:id", async (request, reply) => { + let id = parseInt((request.params)["id"]); + let results = await app.service("Movie Search").details(id); + reply.send({ status: "success", data: results}); + }); - factory.get("/api/movie/details/:id", [auth], async (request, reply) => { - let id = parseInt((request.params)["id"]); - let results = await app.service("Movie Search").details(id); - reply.send({ status: "success", data: results}); + /** + * Request a movie to download + */ + factory.get("/api/movie/request/:imdb_id", handle([RequestMovieRequest], async (request, reply) => { + let imdbId = (request.params)["imdb_id"]; + if (!await app.service("Movie Search").verifyImdbId(imdbId)) { + reply.status(404); + reply.send({ satus: "Not found" }); + return; + } + return reply.send({ status: "success" }); + })); }); } diff --git a/services/request/src/server/util.ts b/services/request/src/server/util.ts index 3b95728..d8b687b 100644 --- a/services/request/src/server/util.ts +++ b/services/request/src/server/util.ts @@ -23,3 +23,8 @@ export async function secret(path: string) { export function secretSync(path: string) { return readFileSync(path).toString().trim(); } + +export type Logger = ReturnType; +export function createLogger(name: string) { + return (...args: any[]) => console.log(`[${name}]:`, ...args); +}