From aaadfc7f9ecca1300e77b1a67b70707fd8944455 Mon Sep 17 00:00:00 2001 From: David Ludwig Date: Thu, 15 Apr 2021 15:20:49 -0500 Subject: [PATCH] Movie searching and request modal designs --- .env.example | 4 + src/app/auth.ts | 36 ++++- src/app/components/MovieModal.vue | 149 ++++++++++++++++++ src/app/util.ts | 46 ++++++ src/app/views/Home.vue | 95 ++++++++++- src/app/views/Login.vue | 2 +- src/common/api_schema.ts | 7 + src/common/validation.ts | 21 +++ src/server/index.ts | 4 +- src/server/services/MovieSearch.ts | 49 +++++- src/server/services/TvDb.ts | 37 ++++- src/server/services/WebServer/index.ts | 2 +- .../services/WebServer/middleware/auth.ts | 2 + .../services/WebServer/middleware/index.ts | 18 ++- .../WebServer/requests/MovieSearchRequest.ts | 14 ++ .../WebServer/routes/RouteRegisterFactory.ts | 14 ++ src/server/services/WebServer/routes/api.ts | 22 ++- src/server/services/WebServer/routes/auth.ts | 24 +-- src/server/services/WebServer/validators.ts | 3 - src/server/util.ts | 32 ++-- tailwind.config.js | 3 +- tsconfig.json | 5 +- yarn.lock | 5 + 23 files changed, 543 insertions(+), 51 deletions(-) create mode 100644 src/app/components/MovieModal.vue create mode 100644 src/common/api_schema.ts create mode 100644 src/server/services/WebServer/requests/MovieSearchRequest.ts diff --git a/.env.example b/.env.example index fdc7ff7..4a4e658 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,13 @@ APP_KEY_FILE = /run/secrets/app_key # Discord bot token DISCORD_BOT_KEY_FILE = /run/secrets/discord_bot_key +# The Movie DB key +TMDB_KEY_FILE = /run/secrets/tmdb_key + # TVDB API key TVDB_KEY_FILE = /run/secrets/tvdb_key TVDB_PIN = +TVDB_REFRESH_PERIOD = 480 # (8 hours) How frequently to refresh the token (in minutes) # Database ----------------------------------------------------------------------------------------- diff --git a/src/app/auth.ts b/src/app/auth.ts index 34973c1..67bf47c 100644 --- a/src/app/auth.ts +++ b/src/app/auth.ts @@ -1,6 +1,7 @@ import jwtDecode from "jwt-decode"; +import router from "./routes"; -interface IUser { +export interface IUser { id: number, name: string, isAdmin: boolean @@ -9,7 +10,7 @@ interface IUser { /** * The active JWT */ - let token: string | null; +let token: string | null; /** * The decoded user object @@ -50,6 +51,7 @@ export function loadToken() { user = null; return false; } + console.log("Token loaded", token); return true; } @@ -77,3 +79,33 @@ export function storeToken(jwtToken: string) { } return true; } + +/** + * Fetch request providing authentication and logout upon unauthorized requests + */ + export async function authFetch(path: string, options: RequestInit = {}): Promise { + console.log("Performing auth fetch"); + options.credentials = "include"; + options.headers = Object.assign(options.headers ?? {}, { + "Authorization": `Bearer ${token}` + }); + let response = await fetch(path, options); + if (response.status === 401) { + // forgetToken(); + console.log("Forgetting token..."); + router.push({ name: "Login" }); + throw Error("Unauthorized"); + } + return response; +} + +/** + * @TODO Remove later + */ +(window).forgetToken = forgetToken; +(window).authFetch = authFetch; + +/** + * Ensure the token is loaded upon startup + */ +loadToken(); diff --git a/src/app/components/MovieModal.vue b/src/app/components/MovieModal.vue new file mode 100644 index 0000000..6f0285f --- /dev/null +++ b/src/app/components/MovieModal.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/app/util.ts b/src/app/util.ts index 6b8b50e..5860e9f 100644 --- a/src/app/util.ts +++ b/src/app/util.ts @@ -3,3 +3,49 @@ import { single as validate } from "validate.js"; export function validateValue(value: string, constraints: any) { return (validate(value || null, constraints) ?? [""])[0]; } + +export function getAverageRgb(imgEl: HTMLImageElement) { + + var blockSize = 5, // only visit every 5 pixels + defaultRGB = {r:0,g:0,b:0}, // for non-supporting envs + canvas = document.createElement('canvas'), + context = canvas.getContext && canvas.getContext('2d'), + data, width, height, + i = -4, + length, + rgb = {r:0,g:0,b:0}, + count = 0; + + if (!context) { + return defaultRGB; + } + + height = canvas.height = imgEl.naturalHeight || imgEl.offsetHeight || imgEl.height; + width = canvas.width = imgEl.naturalWidth || imgEl.offsetWidth || imgEl.width; + + context.drawImage(imgEl, 0, 0); + + try { + data = context.getImageData(0, 0, width, height); + } catch(e) { + /* security error, img on diff domain */alert('x'); + return defaultRGB; + } + + length = data.data.length; + + while ( (i += blockSize * 4) < length ) { + ++count; + rgb.r += data.data[i]; + rgb.g += data.data[i+1]; + rgb.b += data.data[i+2]; + } + + // ~~ used to floor values + rgb.r = ~~(rgb.r/count); + rgb.g = ~~(rgb.g/count); + rgb.b = ~~(rgb.b/count); + + return rgb; + +} diff --git a/src/app/views/Home.vue b/src/app/views/Home.vue index 4799125..3e98b44 100644 --- a/src/app/views/Home.vue +++ b/src/app/views/Home.vue @@ -1,20 +1,101 @@ + + diff --git a/src/app/views/Login.vue b/src/app/views/Login.vue index 011736e..c548dcb 100644 --- a/src/app/views/Login.vue +++ b/src/app/views/Login.vue @@ -49,7 +49,7 @@ export default defineComponent({ return; } this.isSubmitting = true; - fetch("/auth/login", { + fetch(`/auth/login?use_cookies=${navigator.cookieEnabled}`, { method: "post", headers: { "Content-Type": "application/json" diff --git a/src/common/api_schema.ts b/src/common/api_schema.ts new file mode 100644 index 0000000..9e5ae7f --- /dev/null +++ b/src/common/api_schema.ts @@ -0,0 +1,7 @@ +export interface IApiResponse { + status: string +} + +export interface IApiDataResponse extends IApiResponse { + data: T +} diff --git a/src/common/validation.ts b/src/common/validation.ts index 869549a..19ea394 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -1,4 +1,25 @@ export const constraints = { + api: { + movie: { + search: { + query: { + presence: { + allowEmpty: false, + message: "The query cannot be blank" + } + }, + year: { + numericality: { + onlyInteger: true, + greaterThan: 0, + notGreaterThan: "Invalid year", + notValid: "Invalid year", + notInteger: "Invalid year" + } + } + } + } + }, login: { email: { presence: { diff --git a/src/server/index.ts b/src/server/index.ts index 15fb96e..9c794f7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,10 +1,10 @@ -import { readFileSync } from "fs"; import Application from "./Application"; +import { env, secretSync } from "./util"; /** * Load the application key */ -let appKey = readFileSync(process.env["APP_KEY_FILE"]).toString(); +let appKey = secretSync(env("APP_KEY_FILE")); /** * Create a new application instance diff --git a/src/server/services/MovieSearch.ts b/src/server/services/MovieSearch.ts index 07a6aaa..eb6dd0f 100644 --- a/src/server/services/MovieSearch.ts +++ b/src/server/services/MovieSearch.ts @@ -1,18 +1,65 @@ import Application from "@server/Application"; +import TheMovieDb from "@lib/tmdb"; +import { env, secret } from "@server/util"; +import { readFile } from "fs/promises"; import TVDB from "tvdb-v4"; import Service from "./Service"; +import TvDb from "./TvDb"; export default class MovieSearch extends Service { + protected tmdb!: TheMovieDb; + + /** + * The instance of TVDB + */ + protected tvdb!: TvDb; + public constructor(app: Application) { super("Movie Search", app); } - public async boot() { + /** + * Start the service + */ + public start() { + this.tvdb = this.app.service("TVDB"); + } + /** + * Boot the service + */ + public async boot() { + let apiKey = await secret(env("TMDB_KEY_FILE")); + this.tmdb = new TheMovieDb(apiKey); } + /** + * Shutdown the service + */ public async shutdown() { + // no-op + } + + // Interface ----------------------------------------------------------------------------------- + + /** + * Get the details of a movie + */ + public async details(id: number) { + return await this.tmdb.movie(id); + } + /** + * Search for a movie + */ + public async search(query: string, year?: number) { + return await this.tmdb.searchMovie(query, year); + // let results = await this.tvdb.searchMovie(query, year); + // return results.map(movie => { + // image : movie.image_url ? `/api/tvdb/artwork${new URL(movie.image_url).pathname}`: null, + // name : movie.name, + // year : movie.year + // }); } } diff --git a/src/server/services/TvDb.ts b/src/server/services/TvDb.ts index 1ac8b99..b05ee6e 100644 --- a/src/server/services/TvDb.ts +++ b/src/server/services/TvDb.ts @@ -3,6 +3,12 @@ import Application from "@server/Application"; import TVDB from "tvdb-v4"; import Service from "./Service"; +/** + * The token refresh period in milliseconds + */ +// const TOKEN_REFRESH_PERIOD = 1000*60*parseInt(process.env["TVDB_REFRESH_PERIOD"]); +const TOKEN_REFRESH_PERIOD = 1000*60; + export default class TvDb extends Service { /** @@ -10,25 +16,52 @@ export default class TvDb extends Service */ protected tvdb!: TVDB; + /** + * Store the next timestamp when a token refresh is needed + */ + protected nextTokenRefreshTimestamp: number = 0; + /** * Create a new TvDb service instance */ public constructor(app: Application) { super("TVDB", app); - this.tvdb; } /** * Boot the service */ public async boot() { - let apiKey = await readFile(process.env["TVDB_KEY_FILE"]); + let apiKey = (await readFile(process.env["TVDB_KEY_FILE"])).toString().trim(); + this.tvdb = new TVDB(apiKey); + await this.refreshLogin(); } /** * Shutdown the service */ public async shutdown() { + // no-op + } + /** + * Refresh the login token if necessary + */ + protected async refreshLogin() { + if (Date.now() < this.nextTokenRefreshTimestamp) { + return; + } + this.log("Refreshing login token..."); + let timestamp = Date.now() + TOKEN_REFRESH_PERIOD; // Save the time before the request + await this.tvdb.login(process.env["TVDB_PIN"]); + this.nextTokenRefreshTimestamp = timestamp // if succeeds, update the timestamp + } + + /** + * Search for a movie + */ + public async searchMovie(query: string, year?: number) { + await this.refreshLogin(); + return await this.tvdb.search(query, "movie", year); } } diff --git a/src/server/services/WebServer/index.ts b/src/server/services/WebServer/index.ts index fa4df1a..b70ffc6 100644 --- a/src/server/services/WebServer/index.ts +++ b/src/server/services/WebServer/index.ts @@ -108,7 +108,7 @@ export default class WebServer extends Service return reply.sendFile("index.html"); }); } else { - console.log("Using Vite proxy"); + this.log("Using Vite proxy"); this.fastify.register(fastifyHttpProxy, { upstream: "http://localhost:3001" }); diff --git a/src/server/services/WebServer/middleware/auth.ts b/src/server/services/WebServer/middleware/auth.ts index 9a01a12..c03f4af 100644 --- a/src/server/services/WebServer/middleware/auth.ts +++ b/src/server/services/WebServer/middleware/auth.ts @@ -7,6 +7,8 @@ import { IteratorNext } from "."; * Attempt to authenticate a client's JWT token */ function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): T | undefined { + console.log("JWT Signature:", request.cookies["jwt_signature"]); + console.log(request.headers["authorization"]); // Verify headers if (!request.headers["authorization"]) { reply.status(401); diff --git a/src/server/services/WebServer/middleware/index.ts b/src/server/services/WebServer/middleware/index.ts index 1ef35e1..268bcdb 100644 --- a/src/server/services/WebServer/middleware/index.ts +++ b/src/server/services/WebServer/middleware/index.ts @@ -1,23 +1,27 @@ import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify"; -export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply) => void; +export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply, next?: any) => void; export type MiddlewareMethod = (request: FastifyRequest, reply: FastifyReply, next: () => void) => void; export type IteratorNext = () => void; /** * A route handler that supports middleware methods */ -export function handleMiddleware(middleware: MiddlewareMethod[], handler: RouteHandlerMethod) { - return (async (request: FastifyRequest, reply: FastifyReply) => { +export function handleMiddleware(middleware: MiddlewareMethod[], handler?: RouteHandlerMethod) { + return (async (request, reply, next) => { var iterator = middleware[Symbol.iterator](); - var next = async () => { + var nextMiddleware = async () => { let result = iterator.next(); if (result.done) { - (handler)(request, reply); + if (handler) { + (handler)(request, reply); + } else if (next !== undefined) { + next(); + } return; } - result.value(request, reply, next); + result.value(request, reply, nextMiddleware); }; - next(); + nextMiddleware(); }); } diff --git a/src/server/services/WebServer/requests/MovieSearchRequest.ts b/src/server/services/WebServer/requests/MovieSearchRequest.ts new file mode 100644 index 0000000..910d28d --- /dev/null +++ b/src/server/services/WebServer/requests/MovieSearchRequest.ts @@ -0,0 +1,14 @@ +import { constraints } from "@common/validation"; +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.query, { + query: constraints.api.movie.search.query, + year: constraints.api.movie.search.year + },{ fullMessages: false }); + } +} diff --git a/src/server/services/WebServer/routes/RouteRegisterFactory.ts b/src/server/services/WebServer/routes/RouteRegisterFactory.ts index 5f242f8..34e8a28 100644 --- a/src/server/services/WebServer/routes/RouteRegisterFactory.ts +++ b/src/server/services/WebServer/routes/RouteRegisterFactory.ts @@ -1,6 +1,8 @@ import { FastifyInstance, RouteHandlerMethod } from "fastify"; import Application from "@server/Application"; import { handleMiddleware, MiddlewareMethod } from "../middleware"; +import fastifyHttpProxy from "fastify-http-proxy"; +import { auth } from "../middleware/auth"; export type RouteFactory = ((factory: RouteRegisterFactory) => void) | ((factory: RouteRegisterFactory, app: Application) => void); @@ -63,6 +65,7 @@ export default class RouteRegisterFactory public get(path: string, middleware: RouteHandlerMethod|MiddlewareMethod[], handler?: RouteHandlerMethod) { 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)); } @@ -76,4 +79,15 @@ export default class RouteRegisterFactory middleware = (middleware instanceof Array) ? this.middleware.concat(middleware) : this.middleware; this.fastify.post(`${this.pathPrefix}${path}`, handleMiddleware(middleware, handler)); } + + /** + * Register a proxy route + */ + public proxy(path: string, upstream: string, middleware?: MiddlewareMethod[]) { + this.fastify.register(fastifyHttpProxy, { + prefix: path, + beforeHandler: middleware ? handleMiddleware(middleware) : undefined, + upstream + }); + } } diff --git a/src/server/services/WebServer/routes/api.ts b/src/server/services/WebServer/routes/api.ts index 62e5382..2da60a1 100644 --- a/src/server/services/WebServer/routes/api.ts +++ b/src/server/services/WebServer/routes/api.ts @@ -1,11 +1,27 @@ import Application from "@server/Application"; -import { FastifyInstance } from "fastify"; +import MovieSearch from "@server/services/MovieSearch"; +import { auth } from "../middleware/auth"; +import RouteRegisterFactory from "./RouteRegisterFactory"; /** * Register API routes */ -export default function register(server: FastifyInstance, app: Application) { +export default function register(factory: RouteRegisterFactory, app: Application) { - server.get("/api/movie/search", async (request, reply) => { }); + factory.proxy("/api/tvdb/artwork", "https://artworks.thetvdb.com/"); + factory.proxy("/api/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 }); + }); + + 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}); + }); } diff --git a/src/server/services/WebServer/routes/auth.ts b/src/server/services/WebServer/routes/auth.ts index 621f371..a272099 100644 --- a/src/server/services/WebServer/routes/auth.ts +++ b/src/server/services/WebServer/routes/auth.ts @@ -4,9 +4,7 @@ import { RegisterToken, User } from "@server/database/entities"; import LoginRequest, { ILoginFormBody } from "../requests/LoginRequest"; import RegisterRequest, { IRegisterFormBody } from "../requests/RegisterRequest"; import handle from "../requests"; -import jwt from "jsonwebtoken"; import { auth } from "../middleware/auth"; -import { handleMiddleware } from "../middleware"; import RouteRegisterFactory from "./RouteRegisterFactory"; /** @@ -14,10 +12,10 @@ import RouteRegisterFactory from "./RouteRegisterFactory"; */ export default function register(factory: RouteRegisterFactory, app: Application) { - factory.get("/auth/verify", handleMiddleware([auth], (request, reply) => { + factory.get("/auth/verify", [auth], (request, reply) => { console.log("Authentication has been verified"); reply.send({ status: "success" }); - })); + }); // Login --------------------------------------------------------------------------------------- @@ -30,13 +28,17 @@ export default function register(factory: RouteRegisterFactory, app: Application return } // Store the header/payload in the client, store the signature in a secure httpOnly cookie - let [header, payload, signature] = token.split('.'); - reply.setCookie("jwt_signature", signature, { - httpOnly: true, - sameSite: true, - secure: true - }); - reply.send({ status: "success", token: `${header}.${payload}` }); + // if ((request.query)["use_cookies"] || (request.query)["use_cookies"] === undefined) { + // let [header, payload, signature] = token.split('.'); + // token = `${header}.${payload}`; + // reply.setCookie("jwt_signature", signature, { + // path: '/', + // httpOnly: true, + // sameSite: true, + // secure: true + // }); + // } + reply.send({ status: "success", token }); })); // Registration -------------------------------------------------------------------------------- diff --git a/src/server/services/WebServer/validators.ts b/src/server/services/WebServer/validators.ts index 17dd428..0ac8e5e 100644 --- a/src/server/services/WebServer/validators.ts +++ b/src/server/services/WebServer/validators.ts @@ -15,10 +15,7 @@ validate.validators.token = async function(value: any, options: IValidTokenOptio Object.assign({ message: "is not a valid token" }, options); - if (!(await RegisterToken.isValid(value))) { return options.message; } } - -console.log("Custom validators installed"); diff --git a/src/server/util.ts b/src/server/util.ts index 50368d0..3b95728 100644 --- a/src/server/util.ts +++ b/src/server/util.ts @@ -1,11 +1,25 @@ -export function hasAllProperties(obj: any, properties: string[]) { - if (Object.keys(obj).length !== properties.length) { - return false; +import assert from "assert"; +import { readFile } from "fs/promises"; +import { readFileSync } from "fs"; + +/** + * Fetch an environment variable + */ +export function env(variable: string, throwIfNotFound = true) { + let value = process.env[variable]; + if (throwIfNotFound) { + assert(value !== undefined); } - for (let key of properties) { - if (!(key in obj)) { - return false; - } - } - return true; + return value; +} + +/** + * Fetch a secret from a file + */ +export async function secret(path: string) { + return (await readFile(path)).toString().trim(); +} + +export function secretSync(path: string) { + return readFileSync(path).toString().trim(); } diff --git a/tailwind.config.js b/tailwind.config.js index 6898e87..1ac9aaa 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -17,7 +17,8 @@ module.exports = { backgroundColor: ["disabled"], opacity: ["disabled"], borderWidth: ["disabled"], - borderColor: ["disabled"] + borderColor: ["disabled"], + transform: ["motion-safe"] }, }, plugins: [], diff --git a/tsconfig.json b/tsconfig.json index 6f54e7e..ebcadbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,10 +48,13 @@ "paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ "@app/*": ["app/*"], "@common/*": ["common/*"], + "@lib/*": ["lib/*"], "@server/*": ["server/*"], }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ + "typeRoots": [ /* List of folders to include type definitions from. */ + "src/typings" + ], // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ diff --git a/yarn.lock b/yarn.lock index 703f32e..8831e61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -142,6 +142,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e" integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw== +"@types/node@^14.14.39": + version "14.14.39" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.39.tgz#9ef394d4eb52953d2890e4839393c309aa25d2d1" + integrity sha512-Qipn7rfTxGEDqZiezH+wxqWYR8vcXq5LRpZrETD19Gs4o8LbklbmqotSUsMU+s5G3PJwMRDfNEYoxrcBwIxOuw== + "@types/zen-observable@^0.8.2": version "0.8.2" resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71"