Browse Source

Discord account linking. Requests can be performed through Discord. Minor clean up in other files

master
David Ludwig 4 years ago
parent
commit
2346251e71
21 changed files with 731 additions and 62 deletions
  1. +1
    -1
      src/app/App.vue
  2. +19
    -2
      src/app/routes/index.ts
  3. +99
    -0
      src/app/views/LinkDiscord.vue
  4. +4
    -4
      src/app/views/Login.vue
  5. +15
    -0
      src/lib/tmdb/index.ts
  6. +8
    -0
      src/lib/tmdb/schema.ts
  7. +22
    -0
      src/server/database/entities/DiscordAccount.ts
  8. +17
    -0
      src/server/database/entities/DiscordChannel.ts
  9. +31
    -0
      src/server/database/entities/DiscordLinkRequest.ts
  10. +26
    -0
      src/server/database/entities/DiscordRequest.ts
  11. +2
    -2
      src/server/database/entities/MovieTicket.ts
  12. +5
    -12
      src/server/database/entities/RegisterToken.ts
  13. +7
    -13
      src/server/database/entities/User.ts
  14. +4
    -0
      src/server/database/entities/index.ts
  15. +366
    -3
      src/server/services/DiscordBot.ts
  16. +9
    -1
      src/server/services/MovieSearch.ts
  17. +12
    -0
      src/server/services/WebServer/requests/LinkDiscordRequest.ts
  18. +2
    -2
      src/server/services/WebServer/routes/api.ts
  19. +61
    -21
      src/server/services/WebServer/routes/auth.ts
  20. +1
    -1
      src/server/services/index.ts
  21. +20
    -0
      src/server/util.ts

+ 1
- 1
src/app/App.vue View File

@ -1,6 +1,6 @@
<template>
<div class="h-full flex flex-col lg:flex-row">
<side-nav v-if="$store.getters.isAuthenticated"/>
<side-nav v-if="!$route.meta['disableNavBar']"/>
<div class="h-full p-4 flex flex-col flex-grow overflow-y-auto">
<router-view/>
</div>


+ 19
- 2
src/app/routes/index.ts View File

@ -112,17 +112,26 @@ const routes: RouteRecordRaw[] = [
}
]
},
// Auth Routes ---------------------------------------------------------------------------------
{
path: "/login",
name: "Login",
component: () => import("../views/Login.vue"),
beforeEnter: requiresGuest
beforeEnter: requiresGuest,
meta: {
disableNavBar: true
}
},
{
path: "/register",
name: "Register",
component: () => import("../views/Register.vue"),
beforeEnter: requiresGuest
beforeEnter: requiresGuest,
meta: {
disableNavBar: true
}
},
{
path: "/logout",
@ -134,6 +143,14 @@ const routes: RouteRecordRaw[] = [
}
}
},
{
path: "/link/discord/:token",
name: "LinkDiscord",
component: () => import("../views/LinkDiscord.vue"),
meta: {
disableNavBar: true
}
},
{
path: "/:pathMatch(.*)*",
component: () => import("../views/Error404.vue")


+ 99
- 0
src/app/views/LinkDiscord.vue View File

@ -0,0 +1,99 @@
<template>
<div v-if="success" class="w-full sm:max-w-sm sm:mx-auto sm:my-auto p-6 space-y-4 bg-white rounded-lg shadow-md text-center">
<div class="text-center font-thin text-4xl pt-4">AUTOPLEX</div>
<div>
<i class="fas fa-check-circle align-middle text-green-500 text-4xl"></i>
</div>
<p>Discord account linked successfully! You may close this tab now</p>
</div>
<div v-else class="w-full sm:max-w-sm sm:mx-auto sm:my-auto p-6 space-y-4 bg-white rounded-lg shadow-md">
<div class="text-center font-thin text-4xl pt-4">AUTOPLEX</div>
<div>
<p class="opacity-70 text-sm text-center">Enter your credientials to complete linking your Discord account</p>
</div>
<form @submit.prevent="authenticate">
<div class="space-y-4">
<div>
<text-box label="Email" type="email" ref="email" placeholder="john@example.com" :disabled="isSubmitting"
v-model="fields.email"/>
</div>
<div>
<text-box label="Password" type="password" ref="password" placeholder="············" :disabled="isSubmitting"
v-model="fields.password"/>
</div>
<div>
<button :disabled="isSubmitting" class="block w-full rounded-full bg-indigo-500 text-white p-2 focus:outline-none">Link Discord Account</button>
</div>
</div>
</form>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import TextBox from "../components/TextBox.vue";
export default defineComponent({
components: {
TextBox
},
data() {
return {
isSubmitting: false,
success: false,
fields: {
email: "",
password: ""
},
}
},
methods: {
async authenticate() {
if (this.isSubmitting) {
return;
}
this.isSubmitting = true;
try {
// Send the request and wait far the response
let response = await fetch(`/auth/link/discord/${this.$route.params["token"]}`, {
method: "post",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.fields)
});
this.isSubmitting = false;
// Check the response for errors
let body = await response.json();
if (response.status !== 200) {
if (response.status === 401) {
body.errors = { "email": [ "Email or password is incorrect" ] };
}
for (let fieldName of ["email", "password"]) {
let field = <any>this.$refs[fieldName];
let message = <string>((<any>body.errors)[fieldName] ?? [""])[0];
field.setErrorMessage(message);
}
return;
}
} catch(e) {
console.error("Something went wrong logging in:", e);
this.isSubmitting = false;
}
this.success = true;
}
},
async beforeRouteEnter(to, from, next) {
let token = to.params["token"];
if (typeof token !== "string") {
next({ name: "Home" });
return;
}
// Verify that the token is valid. Redirect otherwise
let response = await fetch(`/auth/link/verify/discord/${token}`, { method: "post" });
if (response.status !== 200) {
next({ name: "Home" });
return;
}
next();
}
});
</script>

+ 4
- 4
src/app/views/Login.vue View File

@ -70,8 +70,8 @@ export default defineComponent({
});
</script>
<style>
button:focus-visible {
@apply ring-2 ring-indigo-500 ring-offset-2;
}
<style lang="postcss">
button:focus-visible {
@apply ring-2 ring-indigo-500 ring-offset-2;
}
</style>

+ 15
- 0
src/lib/tmdb/index.ts View File

@ -1,6 +1,17 @@
import ApiRequestManager from "./request"
import * as Schema from "./schema";
export enum ExternalSource {
Facebook = "facebook_id",
Freebase = "freebase_id",
FreebaseM = "freebase_mid",
Imdb = "imdb_id",
Instagram = "instagram_id",
Tvdb = "tvdb_id",
TvRage = "tvrage_id",
Twitter = "twitter_id"
}
export default class TheMovieDb
{
protected requestManager!: ApiRequestManager;
@ -20,4 +31,8 @@ export default class TheMovieDb
public async movie(id: number) {
return await this.requestManager.get<Schema.IMovieDetails>(`/movie/${id}`);
}
public async findMovie(id: string, externalSource: ExternalSource) {
return await this.requestManager.get<Schema.IFindResult>(`/find/${id}`, { external_source: externalSource });
}
}

+ 8
- 0
src/lib/tmdb/schema.ts View File

@ -17,6 +17,14 @@ export interface ILanguage {
name: string
}
export interface IFindResult {
movie_results : IMovieSearchResult[],
person_results : unknown,
tv_results : unknown,
tv_episode_results: unknown,
tv_season_results : unknown
}
export interface IMovieSearchResult {
adult : boolean,
backdrop_path : string | null,


+ 22
- 0
src/server/database/entities/DiscordAccount.ts View File

@ -0,0 +1,22 @@
import { BaseEntity, Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { DiscordRequest } from "./DiscordRequest";
import { User } from "./User";
@Entity()
export class DiscordAccount extends BaseEntity
{
@PrimaryGeneratedColumn()
id!: number;
@Column({ length: 20 })
discordId!: string;
@ManyToOne(() => User, user => user.discordAccounts)
user!: User;
@OneToMany(() => DiscordRequest, request => request.account)
requests!: DiscordRequest[];
@CreateDateColumn()
createdAt!: Date;
}

+ 17
- 0
src/server/database/entities/DiscordChannel.ts View File

@ -0,0 +1,17 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class DiscordChannel extends BaseEntity
{
@PrimaryGeneratedColumn()
id!: number;
@Column({ length: 20 })
channelId!: string;
@Column({ length: 20 })
messageId!: string;
@CreateDateColumn()
createdAt!: Date;
}

+ 31
- 0
src/server/database/entities/DiscordLinkRequest.ts View File

@ -0,0 +1,31 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
import { DiscordAccount } from "./DiscordAccount";
import { User } from "./User";
@Entity()
export class DiscordLinkRequest extends BaseEntity
{
@PrimaryGeneratedColumn()
id!: number;
@Column()
discordId!: string;
@Column()
token!: string;
@CreateDateColumn()
createdAt!: Date;
/**
* Fulfill a link request for the given user
*/
public async linkUser(user: User) {
let account = new DiscordAccount();
account.discordId = this.discordId;
account.user = user;
let awaitSave = account.save();
await DiscordLinkRequest.delete({ id: this.id });
return await awaitSave;
}
}

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

@ -0,0 +1,26 @@
import { BaseEntity, Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn } from "typeorm";
import { DiscordAccount } from "./DiscordAccount";
import { MovieTicket } from "./MovieTicket";
@Entity()
export class DiscordRequest extends BaseEntity
{
@PrimaryGeneratedColumn()
id!: number;
@Column({ length: 20 })
channelId!: string;
@Column({ length: 20 })
messageId!: string;
@CreateDateColumn()
createdAt!: Date;
@ManyToOne(() => DiscordAccount, account => account.requests)
account!: DiscordAccount;
@OneToOne(() => MovieTicket)
@JoinColumn()
ticket!: MovieTicket;
}

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

@ -72,7 +72,7 @@ export class MovieTicket extends BaseEntity
/**
* Insert a rquest via TMDb movie details
*/
public static async requestTmdb(user: User, tmdbId: number, movie: IApiMovieDetails) {
public static async requestTmdb(user: User, movie: IApiMovieDetails) {
let info = new MovieInfo();
info.overview = movie.overview;
info.posterPath = movie.posterPath;
@ -82,7 +82,7 @@ export class MovieTicket extends BaseEntity
await info.save();
let ticket = new MovieTicket();
ticket.tmdbId = tmdbId;
ticket.tmdbId = movie.tmdbId;
ticket.imdbId = movie.imdbId;
ticket.title = movie.title;
ticket.year = movie.releaseDate ? parseInt(movie.releaseDate.slice(0, 4)) : null;


+ 5
- 12
src/server/database/entities/RegisterToken.ts View File

@ -1,3 +1,4 @@
import { generateToken } from "@server/util";
import { randomBytes } from "crypto";
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";
@ -23,17 +24,9 @@ export class RegisterToken extends BaseEntity
/**
* Create a new registration token and insert it into the database
*/
public static generate() {
return new Promise<RegisterToken>((resolve, reject) => {
randomBytes(48, async (err, result) => {
if (err) {
reject(err);
} else {
let token = new RegisterToken();
token.token = result.toString("hex");
resolve(await token.save());
}
});
});
public static async generate() {
let token = new RegisterToken();
token.token = await generateToken();
return await token.save();
}
}

+ 7
- 13
src/server/database/entities/User.ts View File

@ -1,10 +1,9 @@
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, OneToMany, OneToOne, JoinColumn, CreateDateColumn, MoreThanOrEqual } from "typeorm";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { MovieTicket } from "./MovieTicket";
import Application from "@server/Application";
import { MovieQuota } from "./MovieQuota";
import { IApiMovie } from "@common/api_schema";
import { DiscordAccount } from "./DiscordAccount";
@Entity()
export class User extends BaseEntity
@ -34,23 +33,18 @@ export class User extends BaseEntity
@OneToMany(() => User, user => user.movieTickets)
movieTickets!: MovieTicket[];
@OneToMany(() => DiscordAccount, account => account.user)
discordAccounts!: DiscordAccount[];
/**
* Authenticate a user and return an auth token upon success
*/
public static async authenticate(email: string, password: string) {
let user = <User>await User.findOne({ email });
if (user === undefined || !(await bcrypt.compare(password, user.password))) {
return undefined;
return null;
}
return user.createJwtToken(Application.instance().APP_KEY);
}
/**
* Create an auth token for the user
*/
public createJwtToken(key: string, expiresIn: number = 60*60*24) {
let body = { id: this.id, name: this.name, isAdmin: this.isAdmin };
return jwt.sign(body, key, { expiresIn });
return user;
}
/**
@ -100,7 +94,7 @@ export class User extends BaseEntity
}
/**
* Fetch
* Fetch active movie tickets for this user
*/
public async activeMovieTickets() {
let tickets = await MovieTicket.find({


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

@ -1,3 +1,7 @@
export * from "./DiscordAccount";
export * from "./DiscordChannel";
export * from "./DiscordLinkRequest";
export * from "./DiscordRequest";
export * from "./MovieInfo";
export * from "./MovieQuota";
export * from "./MovieTicket";


+ 366
- 3
src/server/services/DiscordBot.ts View File

@ -1,6 +1,39 @@
import Application from "../Application";
import Service from "./Service";
import { Client } from "discord.js";
import { Client, Collection, Message, TextChannel, User as DiscordUser } from "discord.js";
import { env, formatImdbId, generateToken, secret } from "@server/util";
import { DiscordChannel } from "@server/database/entities/DiscordChannel";
import { DiscordAccount, MovieTicket } from "@server/database/entities";
import MovieSearch from "./MovieSearch";
import { DiscordLinkRequest } from "@server/database/entities/DiscordLinkRequest";
/**
* The required role to perfrom administrative commands on the bot
*/
const ROLE_ADMIN = "Autoplex Admin";
/**
* The message prefix for commands
*/
const COMMAND_PREFIX = "!plex";
/**
* How frequently to update Discord status messages
*/
const UPDATE_STATUS_INTERVAL = 1500;
interface IChannelMap {
[channelId: string]: Message
}
interface ICommandMap {
[key: string]: (message: Message) => void|Promise<void>
}
interface IAuthCommandmap {
[key: string]: (account: DiscordAccount, message: Message) => void|Promise<void>
}
export default class DiscordBot extends Service
{
/**
@ -8,20 +41,73 @@ export default class DiscordBot extends Service
*/
protected bot: Client;
/**
* Maintain a set of request channels
*/
protected channels: IChannelMap = {};
/**
* Admin commands available
*/
protected adminCommands: ICommandMap;
/**
* Guest commands available
*/
protected guestCommands: ICommandMap;
/**
* Authorized commands available
*/
protected authCommands: IAuthCommandmap;
/**
* The status update interval
*/
protected updateStatusTimeout: NodeJS.Timeout | null = null;
/**
* Timestamp of the most recent status update
*/
protected lastStatusUpdate: number = 0;
/**
* The current status of the bot
*/
protected status: string = "";
/**
* Create a new Discord bot instance
*/
public constructor(app: Application) {
super("Discord Bot", app);
this.bot = new Client();
this.adminCommands = {
"install": this.cmdInstallChannel.bind(this)
};
this.guestCommands = {
"link": this.cmdLinkAccount.bind(this)
}
this.authCommands = {};
}
/**
* Boot the discord bot
*/
public async boot() {
await this.bot.login(process.env["DISCORD_BOT_TOKEN"]);
this.log("Discord bot ready");
let token = await secret(env("DISCORD_BOT_KEY_FILE"));
await this.bot.login(token);
}
/**
* Invoked when all other services are booted and ready
*/
public async start() {
await this.loadChannels();
this.bot.on("message", message => this.onMessage(message));
setInterval(() => {
this.updateStatus(Date.now().toString());
}, 500);
}
/**
@ -29,5 +115,282 @@ export default class DiscordBot extends Service
*/
public async shutdown() {
this.bot.destroy();
}
// Message Handling ----------------------------------------------------------------------------
/**
* Invoked when a Discord message is received
*/
protected onMessage(message: Message) {
if (message.content.startsWith(`${COMMAND_PREFIX} `)) {
this.handleMessage(message);
}
}
/**
* Handle an incoming Discord message
*/
protected async handleMessage(message: Message) {
let command = message.content.split(/\s+/)[1];
if (message.member?.roles.cache.some(role => role.name == ROLE_ADMIN)) {
if (command in this.adminCommands) {
this.adminCommands[command](message);
return;
}
}
// Check if the channel is an authorized channel
if (!(message.channel.id in this.channels)) {
this.informUserOfAuthorizedChannels(message);
return;
}
if (command in this.guestCommands) {
this.guestCommands[command](message);
return;
}
// Only allow plex members
let account = await DiscordAccount.findOne({ where: { discordId: message.author.id }, relations: ["user", "user.quota"] });
if (account === undefined) {
console.log("Unauthorized");
return;
}
if (command in this.authCommands) {
this.authCommands[command](account, message);
}
this.cmdRequestMovie(account, message);
if (message.deletable) {
message.delete();
}
}
/**
* Inform a message sender of authorized channels within the same server if available
*/
protected async informUserOfAuthorizedChannels(message: Message) {
if (!(message.channel instanceof TextChannel)) {
return;
}
let serverId = message.channel.guild.id;
let authorizedChannels = <Collection<string, TextChannel>>message.channel.client.channels.cache.filter(channel => (
channel instanceof TextChannel &&
channel.guild.id == serverId &&
channel.id in this.channels));
let response = "Autoplex commands must be issued through authorized channels. ";
if (authorizedChannels.size == 0) {
response += `Unfortunately, **${message.channel.guild.name}** does not have any available channels.`;
} else {
response += `The following channels in **${message.channel.guild.name}** are:`;
for (let channel of authorizedChannels) {
response += `\n - \`#${channel[1].name}\``;
};
}
this.sendDm(message.author, response);
}
// Channel Handling ----------------------------------------------------------------------------
/**
* Load the channels from the database
*/
protected async loadChannels() {
let channels = await DiscordChannel.find();
for (let channel of channels) {
this.loadChannel(channel);
}
}
/**
* Load the channel's status message. Reinstall if the message can't be found
*/
protected async loadChannel(discordChannel: DiscordChannel) {
// Load the channel
let channel: TextChannel
try {
channel = <TextChannel> await this.bot.channels.fetch(discordChannel.channelId);
} catch(e) {
this.uninstall(discordChannel.channelId);
return;
}
// Load the message, attempt to install if failed
let message: Message;
try {
message = await channel.messages.fetch(discordChannel.messageId);
} catch(e) {
await this.install(channel, "Autoplex reinstalled");
return;
}
this.channels[channel.id] = message;
}
/**
* Install the Discord bot to a channel
*/
protected async install(channel: TextChannel, status?: string): Promise<boolean>;
protected async install(channelId: string, status?: string): Promise<boolean>;
protected async install(channel: string | TextChannel, status?: string) {
// Fetch the channel if necessary
if (typeof channel === "string") {
channel = <TextChannel> await this.bot.channels.fetch(channel);
}
// Ensure the channel is a text channel
if (!(channel instanceof TextChannel)) {
return false;
}
try {
// Create the initial status message
let message = await channel.send(status ?? "Autoplex installed.");
// Fetch the channel model or create a new one
let discordChannel = (await DiscordChannel.findOne({ where: { channelId: channel.id } }))
?? new DiscordChannel();
discordChannel.channelId = channel.id;
discordChannel.messageId = message.id;
await discordChannel.save();
this.channels[channel.id] = message;
} catch(e) {
console.error("Failed to install to channel.", e);
this.uninstall(channel)
return false;
}
return true;
}
/**
* Uninstall the Discord bot from a channel
*/
protected async uninstall(channel: TextChannel): Promise<void>;
protected async uninstall(channelId: string): Promise<void>;
protected async uninstall(channel: string | TextChannel) {
// Fetch the channel if necessary
if (typeof channel === "string") {
channel = <TextChannel> await this.bot.channels.fetch(channel);
}
if (channel.id in this.channels) {
delete this.channels[channel.id];
}
await DiscordChannel.delete({ channelId: channel.id });
console.error("Channel uninstalled");
}
// Message Handling ----------------------------------------------------------------------------
/**
* Update the status of the server
*/
public updateStatus(status: string) {
this.status = status;
if (this.updateStatusTimeout !== null) {
return;
}
this.updateStatusTimeout = setTimeout(() => {
this.lastStatusUpdate = Date.now();
this.updateStatusTimeout = null;
for (let channelId in this.channels) {
this.updateStatusMessage(channelId, status);
}
}, this.lastStatusUpdate + UPDATE_STATUS_INTERVAL - Date.now());
}
/**
* Update the status message in a single channel
*/
protected async updateStatusMessage(channelId: string, status: string) {
try {
await this.channels[channelId].edit(status);
} catch(e) {
await this.install(channelId, status);
}
}
/**
* Send a DM to a user
*/
protected async sendDm(user: DiscordUser, message: string) {
return await (await user.createDM()).send(message);
}
// Discord Commands ----------------------------------------------------------------------------
/**
* Install the Autoplex bot onto a channel
*/
protected async cmdInstallChannel(message: Message) {
let isInstalled = await this.install(message.channel.id);
if (!isInstalled) {
message.reply("Installation failed. Check server logs");
return;
}
}
/**
* Link a Discord account to an Autoplex account
*/
protected async cmdLinkAccount(message: Message) {
// Ensure the account hasn't already been linked
let numAccounts = await DiscordAccount.count({ where: { discordId: message.author.id } });
if (numAccounts > 0) {
this.sendDm(message.author, "This Discord account has already been linked to an Autoplex account");
return;
}
// Create the link request
let linkRequest = await DiscordLinkRequest.findOne({
where: { discordId: message.author.id }
}) ?? new DiscordLinkRequest();
linkRequest.discordId = message.author.id;
linkRequest.token = await generateToken();
await linkRequest.save();
// Send the link to the user
this.sendDm(message.author,
"Link your Autoplex account by clicking the link and logging in.\n" +
`${env("BASE_URL")}/link/discord/${linkRequest.token}`
);
}
/**
* Request a movie
*/
protected async cmdRequestMovie(account: DiscordAccount, message: Message) {
// Ensure an IMDb ID was entered
let imdbIds = message.content.match(/\btt[0-9]{7,25}\b/g);
if (imdbIds === null) {
this.sendDm(message.author, "Usage: `!plex https://www.imdb.com/title/ttxxxxxx/`");
return;
}
let imdbId = formatImdbId(imdbIds[0]);
// Ensure this movie hasn't already been requested
let ticket = await MovieTicket.findOne({ where: { imdbId, isCanceled: false }, relations: ["user"] });
if (ticket !== undefined) {
if (ticket.user.id == account.user.id) {
this.sendDm(message.author, `You have already requested the movie: *${ticket.title}*.`);
} else {
this.sendDm(message.author, `The movie *${ticket.title}* `);
}
return;
}
// Ensure the user hasn't gone over their quota
let quota = await account.user.availableQuota();
if (quota !== null && quota < 1) {
this.sendDm(
message.author,
`You have reach your quota limit of ${account.user.quota?.moviesPerWeek}` +
` movie${account.user.quota?.moviesPerWeek != 0 ? 's' : ''} for this week.`);
return;
}
// Fetch the movie details and create the ticket
let movie = await this.app.service<MovieSearch>("Movie Search").findImdb(imdbId);
if (movie === null) {
this.sendDm(
message.author,
`Unfortunately, the movie you requested could net be located: https://imdb.com/title/${imdbId}/`);
return;
}
let info = await this.app.service<MovieSearch>("Movie Search").details(movie.id);
await MovieTicket.requestTmdb(account.user, info);
this.sendDm(message.author, `*${info.title}* has been requested successfully!`);
}
}

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

@ -1,11 +1,12 @@
import Application from "@server/Application";
import TheMovieDb from "@lib/tmdb";
import TheMovieDb, { ExternalSource } from "@lib/tmdb";
import { env, secret } from "@server/util";
import { request } from "https";
import Service from "./Service";
import TvDb from "./TvDb";
import { IApiMovie, IApiMovieDetails, IApiPaginatedResponse } from "@common/api_schema";
import { MovieTicket } from "@server/database/entities";
import { IMovieSearchResult } from "@lib/tmdb/schema";
const CACHE_CLEAR_INTERVAL = 1000*60; // 60 seconds
@ -153,6 +154,13 @@ export default class MovieSearch extends Service
return this.cacheMovie(id, result).movie;
}
/**
* Find a movie by its IMDb ID
*/
public async findImdb(imdbId: string): Promise<IMovieSearchResult|null> {
return (await this.tmdb.findMovie(imdbId, ExternalSource.Imdb)).movie_results[0] ?? null;
}
/**
* Search for a movie
*/


+ 12
- 0
src/server/services/WebServer/requests/LinkDiscordRequest.ts View File

@ -0,0 +1,12 @@
import { FastifyRequest } from "fastify";
import LoginRequest from "./LoginRequest";
export default class LinkDiscordRequest extends LoginRequest
{
public checkFormat(request: FastifyRequest) {
if ((<any>request.params)["token"] === undefined) {
return false;
}
return true;
}
}

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

@ -4,7 +4,7 @@ import MovieSearch from "@server/services/MovieSearch";
import { auth } from "../middleware/auth";
import RouteRegisterFactory from "./RouteRegisterFactory";
import handle from "../requests";
import { MovieInfo, MovieTicket } from "@server/database/entities";
import { MovieTicket } from "@server/database/entities";
import RequestTmdbMovieRequest from "../requests/RequestTmdbMovieRequest";
/**
@ -76,7 +76,7 @@ export default function register(factory: RouteRegisterFactory, app: Application
}
// Create the movie request ticket
let user = request.middlewareParams.auth.user;
let ticket = await MovieTicket.requestTmdb(user, tmdbId, movieDetails);
let ticket = await MovieTicket.requestTmdb(user, movieDetails);
app.service<SeekerIpc>("Seeker IPC").notifyMovieRequested(ticket.id);
return reply.send({ status: "Success", data: { ticket_id: ticket.id }});
}));


+ 61
- 21
src/server/services/WebServer/routes/auth.ts View File

@ -1,11 +1,12 @@
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"
import Application from "@server/Application";
import { RegisterToken, User } from "@server/database/entities";
import { DiscordLinkRequest, RegisterToken, User } from "@server/database/entities";
import LoginRequest, { ILoginFormBody } from "../requests/LoginRequest";
import RegisterRequest, { IRegisterFormBody } from "../requests/RegisterRequest";
import handle from "../requests";
import { auth } from "../middleware/auth";
import RouteRegisterFactory from "./RouteRegisterFactory";
import LinkDiscordRequest from "../requests/LinkDiscordRequest";
/**
* Register authentication routes
@ -14,31 +15,33 @@ export default function register(factory: RouteRegisterFactory, app: Application
factory.get("/auth/verify", [auth], (request, reply) => {
console.log("Authentication has been verified");
reply.send({ status: "success" });
reply.send({ status: "Success" });
});
// Login ---------------------------------------------------------------------------------------
factory.post("/auth/login", handle([LoginRequest], async (request, reply) => {
let body = <ILoginFormBody>request.body;
let token = await User.authenticate(body.email, body.password);
if (token === undefined) {
let form = <ILoginFormBody>request.body;
let user = await User.authenticate(form.email, form.password);
if (user === null) {
reply.status(401);
reply.send({ "status": "unauthorized" });
reply.send({ "status": "Unauthorized" });
return
}
let body = { id: user.id, name: user.name, isAdmin: user.isAdmin };
let token = jwt.sign(body, app.APP_KEY, { expiresIn: 60*60*24 });
// Store the header/payload in the client, store the signature in a secure httpOnly cookie
// if ((<any>request.query)["use_cookies"] || (<any>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 });
if ((<any>request.query)["use_cookies"] || (<any>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 --------------------------------------------------------------------------------
@ -54,7 +57,7 @@ export default function register(factory: RouteRegisterFactory, app: Application
body.password.trim()
);
await RegisterToken.delete({token: body.token });
reply.send({ status: "success" });
reply.send({ status: "Success" });
}));
/**
@ -67,7 +70,7 @@ export default function register(factory: RouteRegisterFactory, app: Application
reply.send({ "status": "unprocessable entity" });
return;
}
reply.send({ status: "success" });
reply.send({ status: "Success" });
});
/**
@ -80,6 +83,43 @@ export default function register(factory: RouteRegisterFactory, app: Application
reply.send({ "status": "unprocessable entity" });
return;
}
reply.send({ status: "success" });
reply.send({ status: "Success" });
});
// Account Linking -----------------------------------------------------------------------------
factory.post("/auth/link/verify/discord/:token", async (request, reply) => {
let token = <string>(<any>request.params)["token"];
if (await DiscordLinkRequest.count({ where: { token } }) == 0) {
reply.status(404);
reply.send({ status: "Not found" });
return;
}
reply.send({ status: "Success" });
});
/**
* Link a Discord account
*/
factory.post("/auth/link/discord/:token", handle([LinkDiscordRequest], async (request, reply) => {
// Fetch the link request from the token
let token = <string|undefined>(<any>request.params)["token"];
let linkRequest = await DiscordLinkRequest.findOne({ where: { token } });
if (linkRequest === undefined) {
reply.status(404);
reply.send({ status: "Not found" });
return;
}
// // Validate the credentials
let form = <ILoginFormBody>request.body;
let user = await User.authenticate(form.email, form.password);
if (user === null) {
reply.status(401);
reply.send({ "status": "Unauthorized" });
return
}
// Link the accounts
await linkRequest.linkUser(user);
reply.send({ "status": "Success" });
}));
}

+ 1
- 1
src/server/services/index.ts View File

@ -8,7 +8,7 @@ import WebServer from "./WebServer";
export default {
Database,
// DiscordBot,
DiscordBot,
MovieSearch,
// TorrentIpcClient,
SeekerIpcClient,


+ 20
- 0
src/server/util.ts View File

@ -1,6 +1,7 @@
import assert from "assert";
import { readFile } from "fs/promises";
import { readFileSync } from "fs";
import { randomBytes } from "crypto";
/**
* Fetch an environment variable
@ -28,3 +29,22 @@ export type Logger = ReturnType<typeof createLogger>;
export function createLogger(name: string) {
return (...args: any[]) => console.log(`[${name}]:`, ...args);
}
export function formatImdbId(imdbId: string) {
return `tt${imdbId.replace(/^tt0*/, "").padStart(7, '0')}`;
}
/**
* Generate a random token. Length is 2*size
*/
export function generateToken(size: number = 48) {
return new Promise<string>((resolve, reject) => {
randomBytes(size, async (err, result) => {
if (err) {
reject(err);
return;
}
resolve(result.toString("hex"));
});
});
}

Loading…
Cancel
Save