Initial commit.

This commit is contained in:
Oliver Akins 2022-02-17 00:04:40 -06:00
parent 489a16c0b5
commit 3cb6f97610
No known key found for this signature in database
GPG key ID: 3C2014AF9457AF99
17 changed files with 1881 additions and 0 deletions

View file

@ -0,0 +1,48 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { TwitchAuth } from "~/utils/TwitchAuth";
import { config, log, twitchAuths } from "~/main";
import axios from "axios";
export default {
method: `GET`, path: `/twitch/login/callback`,
options: { auth: false },
async handler(request: Request, _: ResponseToolkit): Promise<any> {
log.silly(`An authentication request is being processed`);
try {
let code: string = request.query.code;
let qs = new URLSearchParams();
qs.set("client_id", config.twitch.client_id);
qs.set("client_secret", config.twitch.client_secret);
qs.set("redirect_uri", config.twitch.auth.redirect_uri);
qs.set("grant_type", "authorization_code");
qs.set("code", code);
let r = await axios.post(
config.twitch.auth.base_url + "/token?" + qs.toString()
);
if (r.status !== 200) {
return _.response({
message: "Something went wrong while request access from Twitch",
}).code(403);
};
let auth = new TwitchAuth(r.data);
let userdata = await auth.request<any>(`GET`, `/users`);
let user = userdata.data[0];
auth.channel = user.login;
auth.bid = user.id;
twitchAuths[auth.channel as string] = auth;
return _.response({
message: `Setup auth correctly for ${auth.channel}, you can leave this page now.`,
}).code(200);
} catch (err) {
log.error(err);
throw err;
};
},
};

View file

@ -0,0 +1,19 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { config, log } from "~/main";
export default {
method: `GET`, path: `/twitch/login`,
options: { auth: false },
async handler(_: Request, h: ResponseToolkit): Promise<any> {
log.silly(`A new authentication process has begun`);
let qs = new URLSearchParams();
qs.set("client_id", config.twitch.client_id);
qs.set("redirect_uri", config.twitch.auth.redirect_uri);
qs.set("response_type", "code");
qs.set("scope", config.twitch.auth.scopes.join(" "));
return h.redirect( config.twitch.auth.base_url + "/authorize?" + qs.toString() );
},
};

View file

@ -0,0 +1,36 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { log, twitchAuths } from "~/main";
import boom from "@hapi/boom";
export default {
method: `POST`, path: `/twitch/{user}/poll`,
options: { auth: false },
async handler(request: Request, h: ResponseToolkit): Promise<any> {
log.debug(`Making a Twitch poll!`);
let user = request.params.user;
// Assert user existance
if (twitchAuths[user] == null) {
throw boom.notFound("Invalid user");
};
let auth = twitchAuths[user];
let pollData = request.payload as any;
pollData.broadcaster_id = auth.bid;
try {
let r = await auth.request<any>("POST", "/polls", {
data: pollData,
});
let poll = r.data[0];
return h.response({
poll_id: poll.id,
})
} catch (err: any) {
throw boom.internal(err.response.message);
};
},
};

View file

@ -0,0 +1,23 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { twitchAuths, log } from "~/main";
import boom from "@hapi/boom";
import path from "path";
export default {
method: `GET`, path: `/dashboard/{user}`,
options: { auth: 'simple' },
async handler(request: Request, h: ResponseToolkit): Promise<any> {
let user = request.params.user;
if (twitchAuths[user] == null) {
throw boom.notFound("Invalid user");
};
let uri = path.join(process.cwd(), `../site/${user}.html`);
log.silly(`Filepath: ${uri}`)
return h.file(uri, {
confine: false
});
},
};

73
server/src/main.ts Normal file
View file

@ -0,0 +1,73 @@
// Filepath alias resolution
import "module-alias/register";
import { clean_exit } from "~/utils/clean_exit";
import { init_webserver } from "~/webserver";
import { Logger } from "tslog";
import toml from "toml";
import fs from "fs";
import { TwitchAuth } from "./utils/TwitchAuth";
// Load the config from disk
if (!fs.existsSync(`config.toml`)) {
console.log(`Please create the config and edit it then run the server again.`);
process.exit(1);
};
export const config: Config = toml.parse(fs.readFileSync(`config.toml`, `utf-8`));
// Setup the logger with the appropriate settings
export const log = new Logger({
displayFilePath: `hidden`,
displayFunctionName: false,
displayDateTime: true,
displayLogLevel: true,
minLevel: config.log.level,
name: config.log.name,
});
// Load the database
if (!fs.existsSync(`data/db.json`)) {
log.info(`Can't find database file, creating default`);
try {
fs.writeFileSync(`data/db.json`, `{"authed_channels": {}}`);
} catch (err) {
log.error(`Unable to create the default database, make sure the data directory exists.`);
process.exit(1);
};
};
export const db: Database = JSON.parse(
fs.readFileSync(`data/db.json`, `utf-8`)
);
export const twitchAuths: {[index: string]: TwitchAuth} = {};
// Signal listeners to save persistent storage
process.on(`SIGINT`, clean_exit);
process.on(`SIGTERM`, clean_exit);
process.on(`uncaughtException`, clean_exit);
// Setup the utilities that are needed throughout the system
async function init() {
// Restore all the auth classes for users.
for (var token_data of db.authed_channels) {
if (token_data.channel) {
twitchAuths[token_data.channel] = new TwitchAuth(token_data);
} else {
let auth = new TwitchAuth(token_data);
let userdata = await auth.request<any>(`GET`, `/users`);
auth.channel = userdata.data[0].login;
twitchAuths[auth.channel as string] = auth;
};
};
await init_webserver();
};
init();

24
server/src/types/Config.d.ts vendored Normal file
View file

@ -0,0 +1,24 @@
interface Config {
server: {
host: string;
port: number;
auth: {
enabled: boolean;
username?: string;
password?: string;
};
};
twitch: {
client_id: string;
client_secret: string;
auth: {
redirect_uri: string;
base_url: string;
scopes: string[];
};
};
log: {
name: string;
level: "silly" | "trace" | "debug" | "info" | "warn" | "error" | "fatal";
};
};

13
server/src/types/Database.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
interface TokenData {
access_token: string;
expires_in: number;
refresh_token: string;
channel?: string;
scope: string;
token_type: string;
bid?: string;
}
interface Database {
authed_channels: any[];
}

View file

@ -0,0 +1,160 @@
import { config, log, twitchAuths } from "~/main";
import axios, { Method } from "axios";
export class TwitchAuth {
private _channel: string | undefined;
private _token: string;
private _refresh_token: string;
private token_type: string;
private scope: string;
private expires_in: number;
private invalidated: boolean = false;
private _bid: string | undefined;
constructor(token_data: TokenData) {
this._token = token_data.access_token;
this._refresh_token = token_data.refresh_token;
this.token_type = token_data.token_type;
this.scope = token_data.scope;
this.expires_in = token_data.expires_in;
if (token_data?.channel == null) {
log.silly("Unknown channel authenticated, not adding to the twitchAuths.");
} else {
log.silly("Known channel authenticated.");
this._channel = token_data.channel;
twitchAuths[this._channel] = this;
};
if (token_data?.bid == null) {
log.silly("Channel auth doesn't contain broadcaster ID");
} else {
this._bid = token_data.bid;
};
};
/** Get the authenticated user's channel name. */
private async getChannel() {
try {
log.debug(`Requesting user info`)
let r = await this.request<any>("GET", "/users");
this._channel = r.data.data[0].login;
if (this._channel) {
twitchAuths[this._channel] = this;
};
} catch (err) {
log.error(`User info errored`)
setTimeout(() => {
this.getChannel();
}, 2000);
};
};
/** The channel login name that this authentication is for */
get channel() { return this._channel };
set channel(value: string | undefined) {
if (!this._channel) {
this._channel = value;
};
};
/** The authenticated user's broadcaster ID */
get bid() { return this._bid };
set bid(value: string | undefined) {
if (!this._bid) {
this._bid = value
};
};
/** The token used to make requests for this channel */
get token() { return `Bearer ${this._token}` };
private async refresh() {
let qs = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: this._refresh_token,
client_id: config.twitch.client_id,
client_secret: config.twitch.client_secret,
});
try {
let r = await this.request<TokenData>(
"POST",
config.twitch.auth.base_url + "/token?" + encodeURIComponent(qs.toString()),
);
this._refresh_token = r.refresh_token;
this._token = r.access_token,
this.expires_in = r.expires_in;
this.scope = r.scope;
this.token_type = r.token_type;
} catch (err) {
log.error(`Could not refresh the token for ${this._channel}`)
}
};
public saveData(): TokenData {
return {
access_token: this._token,
refresh_token: this._refresh_token,
token_type: this.token_type,
expires_in: this.expires_in,
scope: this.scope,
channel: this.channel,
};
};
/**
* Makes a request to Twitch on behalf of the authenticated user.
*
* @param method The HTTP method to make the request with
* @param url The URL to make the request to Twitch with. Relative URLs are supported.
* @param conf The Axios RequestConfig, without method, url, or baseURL
* @returns The data returned from the Twitch API
*/
public async request<T>(method: Method, url: string, conf:any={}): Promise<T> {
if (this.invalidated) {
throw new Error("Can't make a request with an invalidated token");
};
try {
let headers = {
"Client-Id": config.twitch.client_id,
"Authorization": this.token, // Bearer <token>
};
if (conf?.headers != null) {
headers = {
...headers,
...conf.headers,
};
delete conf.headers;
};
let r = await axios({
method,
url,
headers,
baseURL: "https://api.twitch.tv/helix",
...(conf ?? {})
});
return r.data as T;
} catch (err: any) {
if (err.response) {
// global error handling,
switch (err.response.status) {
case 403:
this.refresh();
break;
default:
log.error(err);
};
};
log.error(err);
throw err;
};
};
}

View file

@ -0,0 +1,24 @@
import { db, log, twitchAuths } from "~/main";
import fs from "fs";
export function clean_exit() {
log.info(`Exiting the program cleanly`);
db.authed_channels = [];
for (var channel in twitchAuths) {
log.debug(`Saving ${channel}'s auth information into the database file.`);
db.authed_channels.push(twitchAuths[channel].saveData());
};
// Attempt to write the persistent storage to the database
try {
fs.writeFileSync(
`./data/db.json`,
JSON.stringify(db)
);
} catch (err) {
log.error(`Couldn't save program storage properly, writing to log.`, db);
};
process.exit(1);
};

63
server/src/webserver.ts Normal file
View file

@ -0,0 +1,63 @@
import { ResponseToolkit, Server, ServerRoute } from "@hapi/hapi";
import { config, log } from "~/main";
import basic from "@hapi/basic";
import inert from "@hapi/inert";
import glob from "glob";
import path from "path";
export async function init_webserver() {
const server = new Server({
port: config.server.port,
});
await server.register(inert);
await server.register(basic);
server.auth.strategy(`simple`, `basic`, {
// @ts-expect-error
async validate(r: Request, user: string, password: string, h: ResponseToolkit) {
if (!config.server.auth.enabled) {
return {
isValid: true,
credentials: {
user: "",
password: "",
},
};
};
// Assert usernames match if it was set in the config
if (config.server.auth.username != null) {
if (user !== config.server.auth.username) {
return {
isValid: false,
};
};
};
return {
isValid: config.server.auth.password === password,
credentials: { user, password }
};
},
allowEmptyUsername: true,
});
server.auth.default(`simple`);
// Register all the endpoints that we need for the server functionality
let files = glob.sync(
`endpoints/**/!(*.map)`,
{ cwd: __dirname, nodir: true }
);
for (var file of files) {
let route: ServerRoute = (await import(path.join(__dirname, file))).default;
log.debug(`Registering route: ${route.method} ${route.path}`);
server.route(route);
};
server.start().then(() => {
log.info(`Server running on: ${config.server.host}:${config.server.port}`);
});
};