Browse Source

Auth middleware supplies user entity. Add movie ticket requests for both IMDb and TMDb

master
David Ludwig 4 years ago
parent
commit
2dc8f52ed1
11 changed files with 243 additions and 76 deletions
  1. +1
    -2
      src/app/components/modals/MovieModal.vue
  2. +7
    -4
      src/app/store/actions.ts
  3. +16
    -0
      src/common/api_schema.ts
  4. +26
    -0
      src/server/database/entities/MovieInfo.ts
  5. +47
    -3
      src/server/database/entities/MovieTicket.ts
  6. +1
    -0
      src/server/database/entities/index.ts
  7. +25
    -20
      src/server/services/MovieSearch.ts
  8. +17
    -13
      src/server/services/WebServer/middleware/auth.ts
  9. +1
    -1
      src/server/services/WebServer/requests/RequestImdbMovieRequest.ts
  10. +24
    -0
      src/server/services/WebServer/requests/RequestTmdbMovieRequest.ts
  11. +78
    -33
      src/server/services/WebServer/routes/api.ts

+ 1
- 2
src/app/components/modals/MovieModal.vue View File

@ -155,8 +155,7 @@ export default defineComponent({
return;
}
this.isRequesting = true;
// await new Promise((resolve) => setTimeout(resolve, 3000));
let result = await this.$store.dispatch(Action.RequestMovie, this.movie.imdb_id);
let ticketId = await this.$store.dispatch(Action.RequestMovie, this.movieId);
this.isRequested = true;
this.isRequesting = false;
}


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

@ -35,7 +35,7 @@ export type ActionsTypes = {
// RESTful
[Action.AuthFetch] : (payload: IAuthFetchPayload) => Promise<Response>,
[Action.RequestMovie]: (imdbId: string) => Promise<boolean>
[Action.RequestMovie]: (tmdbId: number | string) => Promise<number>
}
/**
@ -125,8 +125,11 @@ export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes
/**
* Request a movie
*/
async [Action.RequestMovie](_, imdbId) {
let response = await authFetch(`/api/movie/request/${imdbId}`);
return response.status == 200;
async [Action.RequestMovie](_, tmdbId): Promise<number> {
let response = await authFetch(`/api/movie/request/create/tmdb/${tmdbId}`);
if (response.status != 200) {
throw new Error("Movie request failed");
}
return (await response.json()).data.ticket_id;
}
};

+ 16
- 0
src/common/api_schema.ts View File

@ -1,5 +1,8 @@
import { IMovieDetails } from "@lib/tmdb/schema";
/**
* The JWT auth token structure
*/
export interface ITokenSchema {
id : number,
name : string,
@ -8,17 +11,30 @@ export interface ITokenSchema {
exp : number
}
/**
* The general API response structure
*/
export interface IApiResponse {
status: string
}
/**
* A generic data response from the API
*/
export interface IApiDataResponse<T> extends IApiResponse {
data: T
}
/**
* Movie detail data
*/
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>;

+ 26
- 0
src/server/database/entities/MovieInfo.ts View File

@ -0,0 +1,26 @@
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";
@Entity()
export class MovieInfo extends BaseEntity
{
@PrimaryGeneratedColumn()
id!: number;
@Column({ unique: true })
tmdbId!: number;
@Column({ type: "text", nullable: true })
overview!: string | null;
@Column({ type: "int", nullable: true })
runtime!: number | null;
@Column({ type: "char", length: 10, nullable: true })
releaseDate!: string | null;
@Column({ type: "varchar", length: 32, nullable: true })
backdropPath!: string | null;
@Column({ type: "varchar", length: 32, nullable: true })
posterPath!: string | null;
}

+ 47
- 3
src/server/database/entities/MovieTicket.ts View File

@ -1,4 +1,6 @@
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne, OneToMany } from "typeorm";
import { IApiMovieDetails } from "@common/api_schema";
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne, OneToMany, OneToOne, JoinColumn } from "typeorm";
import { MovieInfo } from "./MovieInfo";
import { MovieTorrent } from "./MovieTorrent";
import { User } from "./User";
@ -8,12 +10,54 @@ export class MovieTicket extends BaseEntity
@PrimaryGeneratedColumn()
id!: number;
@Column({ length: 27 })
imdbId!: string;
@Column({ type: "varchar", length: 27, unique: true, nullable: true })
imdbId!: string | null;
@Column({ type: "varchar", nullable: true })
title!: string | null;
@Column({ type: "year", nullable: true })
year!: number | null;
@ManyToOne(() => User, user => user.movieTickets)
user!: User;
@OneToMany(() => MovieTorrent, torrent => torrent.movieTicket)
torrents!: MovieTorrent[];
@OneToOne(() => MovieInfo, { nullable: true })
@JoinColumn()
info!: MovieInfo;
/**
* Insert a request via IMDb movie details
*/
public static async requestImdb(user: User, imdbId: string, title: string | null = null) {
let ticket = new MovieTicket();
ticket.imdbId = imdbId;
ticket.title = title;
ticket.user = user;
return await ticket.save();
}
/**
* Insert a rquest via TMDb movie details
*/
public static async requestTmdb(user: User, tmdbId: number, movie: IApiMovieDetails) {
let info = new MovieInfo();
info.overview = movie.overview;
info.posterPath = movie.poster_path;
info.backdropPath = movie.backdrop_path;
info.releaseDate = movie.release_date;
info.runtime = movie.runtime;
info.tmdbId = tmdbId;
await info.save();
let ticket = new MovieTicket();
ticket.imdbId = movie.imdb_id;
ticket.title = movie.title;
ticket.user = user;
ticket.info = info;
return await ticket.save();
}
}

+ 1
- 0
src/server/database/entities/index.ts View File

@ -1,3 +1,4 @@
export * from "./MovieInfo";
export * from "./MovieTicket";
export * from "./MovieTorrent";
export * from "./RegisterToken";


+ 25
- 20
src/server/services/MovieSearch.ts View File

@ -3,11 +3,11 @@ 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 { request } from "https";
import Service from "./Service";
import TvDb from "./TvDb";
import { IApiMovieDetails } from "@common/api_schema";
import { MovieTicket } from "@server/database/entities";
import { MovieInfo, MovieTicket } from "@server/database/entities";
const CACHE_CLEAR_INTERVAL = 1000*10; // 60 seconds
@ -21,9 +21,9 @@ export default class MovieSearch extends Service
protected tvdb!: TvDb;
/**
* Hold a cache of recently fetch movie IMDB ids to speed up request times
* Hold a cache of recently fetched movies to speed up request times
*/
protected imdbCache: { [imdbId: string]: number };
protected movieCache: { [tmdbId: number]: { timestamp: number, movie: IApiMovieDetails } };
/**
* Hold the clear cache interval reference
@ -35,7 +35,7 @@ export default class MovieSearch extends Service
*/
public constructor(app: Application) {
super("Movie Search", app);
this.imdbCache = {};
this.movieCache = {};
this.__clearCacheInterval = null;
}
@ -64,22 +64,23 @@ export default class MovieSearch extends Service
/**
* Store an IMDb ID in cache
*/
protected cacheImdbId(id: string) {
this.imdbCache[id] = Date.now() + CACHE_CLEAR_INTERVAL;
protected cacheMovie(tmdbId: number, movie: IApiMovieDetails) {
this.movieCache[tmdbId] = { movie, timestamp: Date.now() + CACHE_CLEAR_INTERVAL };
if (this.__clearCacheInterval === null) {
this.__clearCacheInterval = setInterval(() => this.cleanImdbCache(), CACHE_CLEAR_INTERVAL);
this.__clearCacheInterval = setInterval(() => this.cleanMovieCache(), CACHE_CLEAR_INTERVAL);
}
return this.movieCache[tmdbId];
}
/**
* Clean the IMDb cache
*/
protected cleanImdbCache() {
protected cleanMovieCache() {
let now = Date.now();
let remaining = 0;
for (let key in this.imdbCache) {
if (now > this.imdbCache[key]) {
delete this.imdbCache[key];
for (let key in this.movieCache) {
if (now > this.movieCache[key].timestamp) {
delete this.movieCache[key];
} else {
remaining++;
}
@ -98,9 +99,9 @@ export default class MovieSearch extends Service
public verifyImdbId(id: string) {
return new Promise<boolean>((resolve, reject) => {
// If the ID is cached, no need to fetch it
if (id in this.imdbCache) {
resolve(true);
}
// 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();
@ -120,12 +121,15 @@ export default class MovieSearch extends Service
*/
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}));
if (id in this.movieCache) {
if (!this.movieCache[id].movie.is_requested) {
isRequested = await MovieInfo.count({tmdbId: id}) > 0;
this.movieCache[id].movie.is_requested = isRequested;
}
return this.movieCache[id].movie;
}
return <IApiMovieDetails>{
let movie = await this.tmdb.movie(id);
let result: IApiMovieDetails = {
backdrop_path: movie.backdrop_path,
imdb_id : movie.imdb_id,
overview : movie.overview,
@ -135,6 +139,7 @@ export default class MovieSearch extends Service
title : movie.title,
is_requested : isRequested
};
return this.cacheMovie(id, result).movie;
}
/**


+ 17
- 13
src/server/services/WebServer/middleware/auth.ts View File

@ -2,22 +2,22 @@ import { FastifyReply, FastifyRequest } from "fastify";
import Application from "@server/Application";
import jwt from "jsonwebtoken";
import { IteratorNext, MiddlewareRequest } from ".";
import { IUser } from "@app/store/schema";
import { ITokenSchema } from "@common/api_schema";
import { User } from "@server/database/entities";
/**
* Attempt to authenticate a client's JWT token
*/
function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): ITokenSchema | undefined {
async function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): Promise<User|undefined> {
// Verify headers
if (!request.headers["authorization"]) {
reply.status(401);
reply.send();
reply.send({ status: "Unauthorized" });
return;
}
if (!request.headers["authorization"].startsWith("Bearer ")) {
reply.status(400);
reply.send();
reply.send({ status: "Bad request" });
return;
}
// Construct the token string
@ -26,34 +26,38 @@ function authenticateJwtToken(request: FastifyRequest, reply: FastifyReply): ITo
token += '.' + (request.cookies.jwt_signature ?? "").trim();
}
// Decode the token
let decoded: ITokenSchema;
let user: User;
try {
decoded = <ITokenSchema>jwt.verify(token, Application.instance().APP_KEY);
let decoded = <ITokenSchema>jwt.verify(token, Application.instance().APP_KEY);
user = await User.findOneOrFail(decoded.id);
} catch(e) {
reply.status(401);
reply.send();
reply.send({ status: "Unauthorized" });
return;
}
return decoded;
return user;
}
/**
* The parameter types for the auth middleware
*/
export interface IAuthMiddlewareParams {
auth: ITokenSchema
auth: {
user: User
}
}
/**
* Ensure that a valid authentication token is provided
*/
export function auth<T extends IAuthMiddlewareParams>(request: MiddlewareRequest<T>, reply: FastifyReply, next: IteratorNext) {
let decoded = authenticateJwtToken(request, reply);
if (decoded === undefined) {
export async function auth<T extends IAuthMiddlewareParams>(request: MiddlewareRequest<T>, reply: FastifyReply, next: IteratorNext) {
let user = await authenticateJwtToken(request, reply);
if (user === undefined) {
// The authenticateJwtToken function sends out the response
return;
}
request.middlewareParams = <any><IAuthMiddlewareParams>{
auth: decoded
auth: { user }
};
next();
}

src/server/services/WebServer/requests/RequestMovieRequest.ts → src/server/services/WebServer/requests/RequestImdbMovieRequest.ts View File


+ 24
- 0
src/server/services/WebServer/requests/RequestTmdbMovieRequest.ts View File

@ -0,0 +1,24 @@
import { FastifyRequest } from "fastify";
import validate from "validate.js";
import Request from "./Request";
export default class RequestTmdbMovieRequest extends Request
{
public validate(request: FastifyRequest) {
return validate.async(request.params, {
tmdb_id: {
presence: {
allowEmpty: false,
message: "TMDb ID is required"
},
numericality: {
onlyInteger: true,
greaterThanOrEqualTo: 0,
notInteger: "Invalid TMDb ID",
notGreaterThanOrEqualTo: "Invalid TMDb ID"
}
}
},<any>{ fullMessages: false });
}
}

+ 78
- 33
src/server/services/WebServer/routes/api.ts View File

@ -1,10 +1,11 @@
import Application from "@server/Application";
import MovieSearch from "@server/services/MovieSearch";
import RequestMovieRequest from "../requests/RequestMovieRequest";
import RequestImdbMovieRequest from "../requests/RequestImdbMovieRequest";
import { auth } from "../middleware/auth";
import RouteRegisterFactory from "./RouteRegisterFactory";
import handle from "../requests";
import { MovieTicket, User } from "@server/database/entities";
import { MovieInfo, MovieTicket } from "@server/database/entities";
import RequestTmdbMovieRequest from "../requests/RequestTmdbMovieRequest";
/**
* Register API routes
@ -37,36 +38,80 @@ export default function register(factory: RouteRegisterFactory, app: Application
reply.send({ status: "success", data: results});
});
/**
* Request a movie to download
*/
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" });
}));
// Movie Request ---------------------------------------------------------------------------
factory.prefix("/movie/request", [], (factory, app) => {
/**
* Request a movie to download
*/
factory.get("/create/tmdb/:tmdb_id", handle([RequestTmdbMovieRequest], async (request, reply) => {
// Verify that the ID has not yet been requested
let tmdbId = (<any>request.params)["tmdb_id"];
if (0 != await MovieInfo.count({tmdbId})) {
reply.status(409);
reply.send({ status: "Conflict" });
return;
}
// Verify that the IMDb ID exists
let movieDetails = await app.service<MovieSearch>("Movie Search").details(tmdbId);
if (!movieDetails) { // @TODO This isn't correct I don't think
reply.status(404);
reply.send({ satus: "Not found" });
return;
}
// Create the movie request ticket
let user = request.middlewareParams.auth.user;
let ticket = await MovieTicket.requestTmdb(user, tmdbId, movieDetails);
return reply.send({ status: "success", data: { ticket_id: ticket.id }});
}));
/**
* Request a movie to download
*/
factory.get("/create/imdb/:imdb_id", handle([RequestImdbMovieRequest], async (request, reply) => {
// Verify that the ID has not yet been requested
let imdbId = (<any>request.params)["imdb_id"];
let title = (<any>request.query)["title"] || null;
if (0 != await MovieTicket.count({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;
}
// Create the movie request ticket
let user = request.middlewareParams.auth.user;
let ticket = await MovieTicket.requestImdb(user, imdbId, title);
return reply.send({ status: "success", data: { ticket_id: ticket.id }});
}));
/**
* Remove/cancel a request
*/
// factory.get("/cancel/:ticket_id", async (request, reply) => {
// // Verify that the ticket exists
// let ticketId = parseInt((<any>request.params)["ticket_id"]);
// if (ticketId === NaN) {
// reply.status(400);
// reply.send({ status: "Bad request" });
// return;
// }
// let user = request.middlewareParams.auth.user;
// let ticket = await MovieTicket.findOne({ id: ticketId, user});
// if (ticket === ) {
// reply.status(404);
// reply.send({ status: "Not found" });
// return;
// }
// // Create the movie request ticket
// let user =
// let ticket = await MovieTicket.requestTmdb(user, tmdbId, movieDetails);
// return reply.send({ status: "success", data: { ticket_id: ticket.id }});
// });
});
});
}

Loading…
Cancel
Save