Initial commit.
This commit is contained in:
parent
489a16c0b5
commit
3cb6f97610
17 changed files with 1881 additions and 0 deletions
48
server/src/endpoints/auth/twitch/callback.ts
Normal file
48
server/src/endpoints/auth/twitch/callback.ts
Normal 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;
|
||||
};
|
||||
},
|
||||
};
|
||||
19
server/src/endpoints/auth/twitch/redirect.ts
Normal file
19
server/src/endpoints/auth/twitch/redirect.ts
Normal 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() );
|
||||
},
|
||||
};
|
||||
36
server/src/endpoints/polls/create.ts
Normal file
36
server/src/endpoints/polls/create.ts
Normal 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);
|
||||
};
|
||||
},
|
||||
};
|
||||
23
server/src/endpoints/site/channel_polls.ts
Normal file
23
server/src/endpoints/site/channel_polls.ts
Normal 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
73
server/src/main.ts
Normal 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
24
server/src/types/Config.d.ts
vendored
Normal 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
13
server/src/types/Database.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
160
server/src/utils/TwitchAuth.ts
Normal file
160
server/src/utils/TwitchAuth.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
24
server/src/utils/clean_exit.ts
Normal file
24
server/src/utils/clean_exit.ts
Normal 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
63
server/src/webserver.ts
Normal 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}`);
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue