Browse Source

Movie requests are functional. Added new middleware parameters type system

master
David Ludwig 4 years ago
parent
commit
f3602c5a84
11 changed files with 151 additions and 72 deletions
  1. +26
    -11
      src/app/components/modals/MovieModal.vue
  2. +14
    -4
      src/app/store/actions.ts
  3. +0
    -3
      src/app/store/generics.ts
  4. +18
    -1
      src/common/api_schema.ts
  5. +1
    -7
      src/server/database/entities/MovieTicket.ts
  6. +14
    -1
      src/server/services/MovieSearch.ts
  7. +19
    -7
      src/server/services/WebServer/middleware/auth.ts
  8. +10
    -5
      src/server/services/WebServer/middleware/index.ts
  9. +2
    -5
      src/server/services/WebServer/requests/index.ts
  10. +26
    -26
      src/server/services/WebServer/routes/RouteRegisterFactory.ts
  11. +21
    -2
      src/server/services/WebServer/routes/api.ts

+ 26
- 11
src/app/components/modals/MovieModal.vue View File

@ -25,7 +25,10 @@
<p class="">{{movie?.overview}}</p>
</div>
<div class="mt-4 hidden md:block">
<button class="py-2 px-8 rounded-full shadow-md hover:shadow-lg focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }">Request</button>
<button class="py-2 px-4 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }"
v-if="movie.is_requested">View Request Status</button>
<button v-else class="py-2 w-36 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }"
:disabled="isRequesting || isRequested" @click="request">{{isRequesting ? "Requesting..." : isRequested ? "Request Sent" : "Request"}}</button>
</div>
</div>
</div>
@ -37,7 +40,10 @@
<p>{{ movie?.overview }}</p>
</div>
<div class="text-center">
<button class="py-2 px-8 rounded-full bg-red-500 text-white">Request</button>
<button v-if="movie.is_requested" class="py-2 px-4 rounded-full bg-red-500 text-white disabled:opacity-50"
:disabled="isRequesting" @click="request">View Request Status</button>
<button v-else class="py-2 px-8 rounded-full bg-red-500 text-white disabled:opacity-50"
:disabled="isRequesting" @click="request">{{isRequesting ? "Requesting..." : "Request"}}</button>
</div>
</div>
</div>
@ -48,13 +54,10 @@
<script lang="ts">
import { defineComponent } from "vue";
import { IApiDataResponse } from "@common/api_schema";
import { IMovieDetails } from "@lib/tmdb/schema";
import { IApiMovieDetailsResponse, IApiMovieDetails } from "@common/api_schema";
import { authFetch } from "../../routes";
import { getAverageRgb } from "../../util";
import { useStore, Mutation } from "../../store";
type movie = IApiDataResponse<IMovieDetails>;
import { useStore, Mutation, Action } from "../../store";
export default defineComponent({
computed: {
@ -99,8 +102,10 @@ export default defineComponent({
data() {
return {
rgb: {r: 0, g: 0, b: 0},
movie: <IMovieDetails|undefined>undefined,
isPosterLoaded: false
movie: <IApiMovieDetails|undefined>undefined,
isPosterLoaded: false,
isRequesting: false,
isRequested: false
}
},
methods: {
@ -142,8 +147,18 @@ export default defineComponent({
this.rgb = <any>rgb;
},
async fetchMovieDetails() {
let response = <movie> await (authFetch(`/api/movie/details/${this.movieId}`).then(response => response.json()));
let response = <IApiMovieDetailsResponse> await (authFetch(`/api/movie/details/${this.movieId}`).then(response => response.json()));
this.movie = response.data;
},
async request() {
if (this.isRequesting || this.movie == null || this.movie.imdb_id == null || this.isRequested) {
return;
}
this.isRequesting = true;
// await new Promise((resolve) => setTimeout(resolve, 3000));
let result = await this.$store.dispatch(Action.RequestMovie, this.movie.imdb_id);
this.isRequested = true;
this.isRequesting = false;
}
},
mounted() {
@ -173,7 +188,7 @@ export default defineComponent({
});
</script>
<style>
<style lang="postcss">
.modal {
@apply fixed inset-0 flex flex-col h-screen;
background: rgba(0, 0, 0, 0.5);


+ 14
- 4
src/app/store/actions.ts View File

@ -2,7 +2,7 @@ import { ActionTree } from "vuex";
import { Actions } from "./generics";
import { IState } from "./state";
import { Mutation, MutationsTypes } from "./mutations";
import router from "../routes";
import router, { authFetch } from "../routes";
import { GettersTypes } from "./getters";
// Payload types
@ -20,7 +20,8 @@ export enum Action {
AuthLoad = "AUTH_LOAD",
// RESTful
AuthFetch = "AUTH_FETCH"
AuthFetch = "AUTH_FETCH",
RequestMovie = "REQUEST_MOVIE"
}
/**
@ -30,10 +31,11 @@ export type ActionsTypes = {
// Authentication
[Action.AuthAuthenticate]: (payload: IAuthenticatePayload) => Promise<IAuthenticateResult>,
[Action.AuthForget] : () => void,
[Action.AuthLoad] : () => boolean
[Action.AuthLoad] : () => boolean,
// RESTful
[Action.AuthFetch]: (payload: IAuthFetchPayload) => Promise<Response>
[Action.AuthFetch] : (payload: IAuthFetchPayload) => Promise<Response>,
[Action.RequestMovie]: (imdbId: string) => Promise<boolean>
}
/**
@ -118,5 +120,13 @@ export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes
throw Error("Unauthorized");
}
return response;
},
/**
* Request a movie
*/
async [Action.RequestMovie](_, imdbId) {
let response = await authFetch(`/api/movie/request/${imdbId}`);
return response.status == 200;
}
};

+ 0
- 3
src/app/store/generics.ts View File

@ -17,9 +17,6 @@ type ActionsType = {
export type Actions<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = {
[K in keyof A]: (context: AugmentedActionContext<S, G, M, A>, payload: Parameters<A[K]>[0]) => ReturnType<A[K]>
}
// type InferActionTypes<A> = A extends { [key: string]: (context: any, payload: any) => any } ? {
// [K in keyof A]: (payload: Parameters<A[K]>[1]) => ReturnType<A[K]>
// } : never;
export type GenericStore<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = Omit<VuexStore<S>, "commit" | "getters" | "dispatch"> & {
commit<K extends keyof M, P extends Parameters<M[K]>[1]>(


+ 18
- 1
src/common/api_schema.ts View File

@ -1,7 +1,24 @@
import { IMovieDetails } from "@lib/tmdb/schema";
export interface ITokenSchema {
id : number,
name : string,
isAdmin: boolean,
iat : number,
exp : number
}
export interface IApiResponse {
status: string
}
export interface IApiDataResponse<T> extends IApiResponse {
data: T
}
export interface IApiMovieDetails extends Pick<IMovieDetails, "title" | "overview" | "runtime" |
"release_date" | "imdb_id" | "backdrop_path" | "poster_path">
{
is_requested: boolean
}
export type IApiMovieDetailsResponse = IApiDataResponse<IApiMovieDetails>;

+ 1
- 7
src/server/database/entities/MovieTicket.ts View File

@ -8,15 +8,9 @@ export class MovieTicket extends BaseEntity
@PrimaryGeneratedColumn()
id!: number;
@Column()
@Column({ length: 27 })
imdbId!: string;
@Column()
name!: string;
@Column()
number!: number;
@ManyToOne(() => User, user => user.movieTickets)
user!: User;


+ 14
- 1
src/server/services/MovieSearch.ts View File

@ -6,6 +6,8 @@ import TVDB from "tvdb-v4";
import { request, Agent } from "https";
import Service from "./Service";
import TvDb from "./TvDb";
import { IApiMovieDetails } from "@common/api_schema";
import { MovieTicket } from "@server/database/entities";
const CACHE_CLEAR_INTERVAL = 1000*10; // 60 seconds
@ -117,11 +119,22 @@ export default class MovieSearch extends Service
* Get the details of a movie
*/
public async details(id: number) {
let isRequested = false;
let movie = await this.tmdb.movie(id);
if (movie.imdb_id != null) {
this.cacheImdbId(movie.imdb_id);
isRequested = Boolean(await MovieTicket.findOne({imdbId: movie.imdb_id}));
}
return movie;
return <IApiMovieDetails>{
backdrop_path: movie.backdrop_path,
imdb_id : movie.imdb_id,
overview : movie.overview,
poster_path : movie.poster_path,
release_date : movie.release_date,
runtime : movie.runtime,
title : movie.title,
is_requested : isRequested
};
}
/**


+ 19
- 7
src/server/services/WebServer/middleware/auth.ts View File

@ -1,12 +1,14 @@
import { FastifyReply, FastifyRequest } from "fastify";
import Application from "@server/Application";
import jwt from "jsonwebtoken";
import { IteratorNext } from ".";
import { IteratorNext, MiddlewareRequest } from ".";
import { IUser } from "@app/store/schema";
import { ITokenSchema } from "@common/api_schema";
/**
* Attempt to authenticate a client's JWT token
*/
function authenticateJwtToken<T = any>(request: FastifyRequest, reply: FastifyReply): T | undefined {
function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): ITokenSchema | undefined {
// Verify headers
if (!request.headers["authorization"]) {
reply.status(401);
@ -24,9 +26,9 @@ function authenticateJwtToken<T = any>(request: FastifyRequest, reply: FastifyRe
token += '.' + (request.cookies.jwt_signature ?? "").trim();
}
// Decode the token
let decoded: T;
let decoded: ITokenSchema;
try {
decoded = <any>jwt.verify(token, Application.instance().APP_KEY);
decoded = <ITokenSchema>jwt.verify(token, Application.instance().APP_KEY);
} catch(e) {
reply.status(401);
reply.send();
@ -35,13 +37,23 @@ function authenticateJwtToken<T = any>(request: FastifyRequest, reply: FastifyRe
return decoded;
}
/**
* The parameter types for the auth middleware
*/
export interface IAuthMiddlewareParams {
auth: ITokenSchema
}
/**
* Ensure that a valid authentication token is provided
*/
export function auth(request: FastifyRequest, reply: FastifyReply, next: IteratorNext) {
let token = authenticateJwtToken(request, reply);
if (token === undefined) {
export function auth<T extends IAuthMiddlewareParams>(request: MiddlewareRequest<T>, reply: FastifyReply, next: IteratorNext) {
let decoded = authenticateJwtToken(request, reply);
if (decoded === undefined) {
return;
}
request.middlewareParams = <any><IAuthMiddlewareParams>{
auth: decoded
};
next();
}

+ 10
- 5
src/server/services/WebServer/middleware/index.ts View File

@ -1,14 +1,18 @@
import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify";
export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply, next?: any) => void;
export type MiddlewareMethod = (request: FastifyRequest, reply: FastifyReply, next: () => void) => void;
export type IteratorNext = () => void;
export type MiddlewareRequest<P> = FastifyRequest & { middlewareParams: P };
export type HandlerMethod<T = undefined> = (request: MiddlewareRequest<T>, reply: FastifyReply, next?: any) => void;
export type MiddlewareMethod<T> = (request: MiddlewareRequest<T>, reply: FastifyReply, next: () => void) => void;
export type HandlerMethodWithMiddleware<T = undefined> = (request: MiddlewareRequest<MiddlewareParams<T>>, reply: FastifyReply) => void;
export type IteratorNext = () => void;
export type MiddlewareParams<T> = T extends MiddlewareMethod<infer X>[] ? X : never;
/**
* A route handler that supports middleware methods
*/
export function handleMiddleware(middleware: MiddlewareMethod[], handler?: RouteHandlerMethod) {
return <HandlerMethod> (async (request, reply, next) => {
export function handleMiddleware<T extends MiddlewareMethod<any>[]>(middleware: T, handler?: HandlerMethodWithMiddleware<T>) {
let result = <HandlerMethod<T>> (async (request, reply, next) => {
var iterator = middleware[Symbol.iterator]();
var nextMiddleware = async () => {
let result = iterator.next();
@ -24,4 +28,5 @@ export function handleMiddleware(middleware: MiddlewareMethod[], handler?: Route
};
nextMiddleware();
});
return <RouteHandlerMethod>result;
}

+ 2
- 5
src/server/services/WebServer/requests/index.ts View File

@ -1,12 +1,9 @@
import Request from "./Request";
import { RouteHandlerMethod } from "fastify";
// import { IncomingMessage, Server, ServerResponse } from "http";
// type Return = RouteHandlerMethod<Server, IncomingMessage, ServerResponse>;
import { HandlerMethodWithMiddleware } from "../middleware";
type RequestConstructor = new () => Request;
export default function handle(requests: RequestConstructor[], handle: RouteHandlerMethod): RouteHandlerMethod
export default function handle<T>(requests: RequestConstructor[], handle: HandlerMethodWithMiddleware<T>): HandlerMethodWithMiddleware<T>
{
return async (fastifyRequest, fastifyReply) => {
// Request parsing


+ 26
- 26
src/server/services/WebServer/routes/RouteRegisterFactory.ts View File

@ -1,14 +1,13 @@
import { FastifyInstance, RouteHandlerMethod } from "fastify";
import { FastifyInstance } from "fastify";
import Application from "@server/Application";
import { handleMiddleware, MiddlewareMethod } from "../middleware";
import { handleMiddleware, HandlerMethodWithMiddleware, 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);
export type RouteFactory<M extends MiddlewareMethod<any> = never> = ((factory: RouteRegisterFactory<M>) => void)
| ((factory: RouteRegisterFactory<M>, app: Application) => void);
export default class RouteRegisterFactory
export default class RouteRegisterFactory<M extends MiddlewareMethod<any> = never>
{
/**
* The application instance
@ -28,7 +27,7 @@ export default class RouteRegisterFactory
/**
* The list of middleware
*/
protected middleware: MiddlewareMethod[] = [];
protected middleware: M[] = [];
/**
* The current route prefix
@ -47,7 +46,7 @@ export default class RouteRegisterFactory
/**
* Register a group of routes under a common prefix and middleware
*/
public prefix(prefix: string, middleware: MiddlewareMethod[], factory: RouteFactory) {
public prefix<T extends MiddlewareMethod<any>>(prefix: string, middleware: T[], factory: RouteFactory<(M|T)>) {
let prefixBackup = this.pathPrefix;
this.pathPrefix = prefix;
this.group(middleware, factory);
@ -57,44 +56,45 @@ export default class RouteRegisterFactory
/**
* Register a group of routes under common middleware
*/
public group(middleware: MiddlewareMethod[], factory: RouteFactory) {
public group<T extends MiddlewareMethod<any>>(middleware: T[], factory: RouteFactory<(M|T)>) {
let middlewareBackup = this.middleware;
this.middleware = this.middleware.concat(middleware);
factory(this, this.app);
this.middleware = this.middleware.concat(<any>middleware);
factory(<RouteRegisterFactory<(M|T)>>this, this.app);
this.middleware = middlewareBackup;
}
/**
* Register a GET request
*/
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 ?? <RouteHandlerMethod>middleware);
middleware = (middleware instanceof Array) ? this.middleware.concat(middleware) : this.middleware;
this.fastify.get(`${this.pathPrefix}${path}`, handleMiddleware(middleware, handler));
public get<T extends MiddlewareMethod<any>>(path: string, handler: HandlerMethodWithMiddleware<M[]>): void;
public get<T extends MiddlewareMethod<any>>(path: string, middleware: T[], handler: HandlerMethodWithMiddleware<(T|M)[]>): void;
public get<T extends MiddlewareMethod<any>>(path: string, middleware: HandlerMethodWithMiddleware<(T|M)[]>|T[], handler?: HandlerMethodWithMiddleware<(T|M)[]>) {
type Handler = HandlerMethodWithMiddleware<(T|M)[]>;
handler = (handler ?? <Handler>middleware);
middleware = (middleware instanceof Array) ? <any>this.middleware.concat(<any>middleware) : this.middleware;
this.fastify.get(`${this.pathPrefix}${path}`, handleMiddleware(<(T|M)[]>middleware, <Handler>handler));
}
/**
* Register a POST request
*/
public post(path: string, handler: RouteHandlerMethod): void;
public post(path: string, middleware: MiddlewareMethod[], handler: RouteHandlerMethod): void;
public post(path: string, middleware: RouteHandlerMethod|MiddlewareMethod[], handler?: RouteHandlerMethod) {
handler = (handler ?? <RouteHandlerMethod>middleware);
middleware = (middleware instanceof Array) ? this.middleware.concat(middleware) : this.middleware;
this.fastify.post(`${this.pathPrefix}${path}`, handleMiddleware(middleware, handler));
public post<T extends MiddlewareMethod<any>>(path: string, handler: HandlerMethodWithMiddleware<M[]>): void;
public post<T extends MiddlewareMethod<any>>(path: string, middleware: T[], handler: HandlerMethodWithMiddleware<(T|M)[]>): void;
public post<T extends MiddlewareMethod<any>>(path: string, middleware: HandlerMethodWithMiddleware<(T|M)[]>|T[], handler?: HandlerMethodWithMiddleware<(T|M)[]>) {
type Handler = HandlerMethodWithMiddleware<(T|M)[]>;
handler = (handler ?? <Handler>middleware);
middleware = (middleware instanceof Array) ? <any>this.middleware.concat(<any>middleware) : this.middleware;
this.fastify.post(`${this.pathPrefix}${path}`, handleMiddleware(<(T|M)[]>middleware, <Handler>handler));
}
/**
* Register a proxy route
*/
public proxy(path: string, upstream: string, middleware?: MiddlewareMethod[]) {
public proxy<T extends MiddlewareMethod<any>>(path: string, upstream: string, middleware?: T[]) {
this.log(`Registering proxy: ${this.pathPrefix}${path} -> ${upstream}`);
this.fastify.register(fastifyHttpProxy, {
prefix: `${this.pathPrefix}${path}`,
beforeHandler: middleware ? handleMiddleware(middleware) : undefined,
beforeHandler: middleware ? handleMiddleware(this.middleware.concat(<any>middleware)) : undefined,
upstream
});
}


+ 21
- 2
src/server/services/WebServer/routes/api.ts View File

@ -4,6 +4,7 @@ import RequestMovieRequest from "../requests/RequestMovieRequest";
import { auth } from "../middleware/auth";
import RouteRegisterFactory from "./RouteRegisterFactory";
import handle from "../requests";
import { MovieTicket, User } from "@server/database/entities";
/**
* Register API routes
@ -39,15 +40,33 @@ export default function register(factory: RouteRegisterFactory, app: Application
/**
* Request a movie to download
*/
factory.get("/api/movie/request/:imdb_id", handle([RequestMovieRequest], async (request, reply) => {
factory.get("/movie/request/:imdb_id", handle([RequestMovieRequest], async (request, reply) => {
// Verify that the ID has not yet been requested
let imdbId = (<any>request.params)["imdb_id"];
if (undefined !== await MovieTicket.findOne({imdbId})) {
reply.status(409);
reply.send({ status: "Conflict" });
return;
}
// Verify that the IMDb ID exists
if (!await app.service<MovieSearch>("Movie Search").verifyImdbId(imdbId)) {
reply.status(404);
reply.send({ satus: "Not found" });
return;
}
// Grab the user
let user = await User.findOne({id: request.middlewareParams.auth.id});
if (user === undefined) {
reply.status(401);
reply.send({ status: "Unauthorized" });
return;
}
// Create the movie request ticket
let ticket = new MovieTicket();
ticket.imdbId = imdbId;
ticket.user = user;
await ticket.save();
return reply.send({ status: "success" });
}));
});
}

Loading…
Cancel
Save