Add game server and pull command.

main
Mari 3 years ago
parent 162aa1422b
commit 48b0502cd7
  1. 5
      .idea/codeStyles/codeStyleConfig.xml
  2. 907
      package-lock.json
  3. 9
      package.json
  4. 92
      src/BaseServer.ts
  5. 32
      src/CookieHelpers.ts
  6. 90
      src/DiscordWebhookHandler.ts
  7. 73
      src/FastifyHelpers.ts
  8. 165
      src/GameServer.ts
  9. 18
      src/PugRenderer.ts
  10. 39
      src/SavedWebhook.ts
  11. 192
      src/SetupServer.ts
  12. 50
      src/app.ts
  13. 28
      src/commands/SetupCommand.ts
  14. 56
      src/commands/game/PullCommand.ts
  15. 10
      static/pages/error.pug
  16. 11
      static/pages/game/running.pug
  17. 13
      static/pages/game/stop.pug
  18. 2
      static/pages/setup/admin.pug
  19. 14
      static/pages/setup/done.pug
  20. 13
      static/pages/setup/error.pug
  21. 4
      static/pages/setup/game.pug
  22. 13
      static/pages/setup/start.pug
  23. 12
      static/pages/setupDone.pug
  24. 6
      static/pages/shutdown.pug
  25. 52
      static/template/template.pug
  26. 2
      tsconfig.json

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

907
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -3,19 +3,28 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"axios": "^0.24.0", "axios": "^0.24.0",
"chance": "^1.1.8",
"crypto-random-string": "^4.0.0",
"detritus-client": "^0.16.3", "detritus-client": "^0.16.3",
"detritus-client-rest": "^0.10.5", "detritus-client-rest": "^0.10.5",
"discord-api-types": "^0.25.2", "discord-api-types": "^0.25.2",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"fastify": "^3.24.1", "fastify": "^3.24.1",
"fastify-cookie": "^5.4.0",
"pino": "^7.5.1",
"pino-discord": "^1.0.2",
"pug": "^3.0.2", "pug": "^3.0.2",
"relateurl": "^0.2.7",
"simple-oauth2": "^4.2.0", "simple-oauth2": "^4.2.0",
"slash-create": "^4.4.0" "slash-create": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/chance": "^1.1.3",
"@types/node": "^16.11.12", "@types/node": "^16.11.12",
"@types/pug": "^2.0.5", "@types/pug": "^2.0.5",
"@types/relateurl": "^0.2.29",
"@types/simple-oauth2": "^4.1.1", "@types/simple-oauth2": "^4.1.1",
"pino-pretty": "^7.3.0",
"typescript": "^4.5.3" "typescript": "^4.5.3"
}, },
"type": "module", "type": "module",

@ -0,0 +1,92 @@
import {SavedWebhook} from "./SavedWebhook.js";
import fastify, {FastifyInstance} from "fastify";
import {SlashCreator, SlashCreatorOptions} from "slash-create";
import fastifyCookie from "fastify-cookie";
import {FastifyServerButItWorksUnlikeTheRealOne} from "./FastifyHelpers.js";
export interface BaseServerDeps {
appId: string
botToken: string
clientSecret: string
publicKey: string
listenPort: number
listenAddress: string
cookieSecret: string
gameWebhook: SavedWebhook
adminWebhook: SavedWebhook
}
export class BaseServer {
readonly server: FastifyInstance
readonly gameWebhook: SavedWebhook
readonly adminWebhook: SavedWebhook
readonly appId: string
readonly clientSecret: string
readonly listenPort: number
readonly listenAddress: string
readonly slashcmd: SlashCreator
constructor({
cookieSecret,
appId,
clientSecret,
listenPort,
listenAddress,
gameWebhook,
adminWebhook,
publicKey,
botToken,
slashCreatorOptions = {}
}: BaseServerDeps & { slashCreatorOptions?: Partial<SlashCreatorOptions> }) {
this.slashcmd = new SlashCreator({
allowedMentions: {everyone: false, roles: false, users: false},
applicationID: appId,
defaultImageFormat: "webp",
handleCommandsManually: false,
maxSignatureTimestamp: 3000,
publicKey,
requestTimeout: 10000,
token: botToken,
unknownCommandResponse: true,
endpointPath: "/interactions",
...slashCreatorOptions,
})
this.server = fastify({logger: true})
this.slashcmd.withServer(new FastifyServerButItWorksUnlikeTheRealOne(this.server, {alreadyListening: true}))
this.server.register(fastifyCookie, {
secret: cookieSecret,
})
this.appId = appId
this.clientSecret = clientSecret
this.listenPort = listenPort
this.listenAddress = listenAddress
this.gameWebhook = gameWebhook
this.adminWebhook = adminWebhook
}
async _initInternal(): Promise<void> {
return
}
async initialize(): Promise<void> {
await this._initInternal()
await this.slashcmd.syncCommandsAsync({
syncGuilds: true,
syncPermissions: true,
skipGuildErrors: false,
deleteCommands: true,
})
await this.slashcmd.startServer()
await this.server.listen(this.listenPort, this.listenAddress)
}
async _shutdownInternal(): Promise<void> {
return
}
async shutdown(): Promise<void> {
await this._shutdownInternal()
return this.server.close()
}
}

@ -0,0 +1,32 @@
import {FastifyReply, FastifyRequest} from "fastify";
import {RouteGenericInterface} from "fastify/types/route";
import cryptoRandomString from "crypto-random-string";
export interface XSRFRoute extends RouteGenericInterface {
Querystring: { [key in typeof XSRFParameter]: string | string[] | undefined }
}
export const XSRFCookie = "__Host-XSRF-Cookie";
export const XSRFParameter = "state" as const;
export function generateXSRFCookie(res: FastifyReply): string {
const newState = cryptoRandomString({
length: 32,
type: 'url-safe'
})
res.setCookie(XSRFCookie, newState, {
path: "/",
sameSite: "strict",
httpOnly: true,
signed: true,
secure: true,
})
return newState
}
export function checkAndClearXSRFCookie(req: FastifyRequest<XSRFRoute>, res: FastifyReply): boolean {
const queryState = req.query[XSRFParameter] ?? null
const cookieState = req.cookies[XSRFCookie] ?? null
res.clearCookie(XSRFCookie)
return cookieState !== null && queryState !== null && cookieState === queryState
}

@ -4,11 +4,13 @@ import {AccessToken, AuthorizationCode, ModuleOptions} from "simple-oauth2";
import pug from "pug"; import pug from "pug";
import {renderError} from "./PugRenderer.js"; import {renderError} from "./PugRenderer.js";
import {APIWebhook} from "discord-api-types"; import {APIWebhook} from "discord-api-types";
import {getBaseUrl, getFirstValue, RouteWithQuerystring} from "./FastifyHelpers.js"; import {getBaseUrl, getFirstValue} from "./FastifyHelpers.js";
import { join } from "path"; import {join, relative} from "path";
import {RouteGenericInterface} from "fastify/types/route";
import {checkAndClearXSRFCookie, generateXSRFCookie} from "./CookieHelpers.js";
interface DiscordWebhookToken extends AccessToken { interface DiscordWebhookToken extends AccessToken {
token: {webhook?: APIWebhook} token: { webhook?: APIWebhook }
} }
const AuthConfig: ModuleOptions["auth"] = { const AuthConfig: ModuleOptions["auth"] = {
@ -18,12 +20,29 @@ const AuthConfig: ModuleOptions["auth"] = {
revokePath: "/api/oauth2/token/revoke", revokePath: "/api/oauth2/token/revoke",
} }
export interface OAuthRoute extends RouteGenericInterface {
Querystring: {
code: string | string[] | undefined,
state: string | string[] | undefined,
error: string | string[] | undefined,
error_description: string | string[] | undefined,
error_uri: string | string[] | undefined,
}
}
export class DiscordWebhookHandler { export class DiscordWebhookHandler {
readonly webhook: SavedWebhook readonly webhook: SavedWebhook
readonly templateFilename: string readonly templateFilename: string
readonly config: ModuleOptions readonly config: ModuleOptions
readonly destinationFunc: () => string readonly destinationFunc: () => string
constructor({webhook, templateFilename, appId, secret, destinationFunc}: {webhook: SavedWebhook, templateFilename: string, appId: string, secret: string, destinationFunc: () => string}) {
constructor({
webhook,
templateFilename,
appId,
secret,
destinationFunc
}: { webhook: SavedWebhook, templateFilename: string, appId: string, secret: string, destinationFunc: () => string }) {
this.templateFilename = templateFilename this.templateFilename = templateFilename
this.webhook = webhook this.webhook = webhook
this.config = { this.config = {
@ -35,20 +54,56 @@ export class DiscordWebhookHandler {
} }
this.destinationFunc = destinationFunc this.destinationFunc = destinationFunc
} }
async handleRequest(req: FastifyRequest<RouteWithQuerystring>, res: FastifyReply): Promise<void> {
async handleRequest(req: FastifyRequest<OAuthRoute>, res: FastifyReply): Promise<void> {
const baseUrl = getBaseUrl(req) const baseUrl = getBaseUrl(req)
const code = getFirstValue(req.query["code"]) ?? null const withoutParams = new URL(req.url)
withoutParams.search = ""
const code = getFirstValue(req.query.code) ?? null
const errorCode = getFirstValue(req.query.error) ?? null
const client = new AuthorizationCode(this.config) const client = new AuthorizationCode(this.config)
if (code === null) { if (code === null && errorCode === null) {
const state = generateXSRFCookie(res)
const authUrl = client.authorizeURL({ const authUrl = client.authorizeURL({
scope: ["applications.commands", "webhook.incoming"], scope: ["applications.commands", "webhook.incoming"],
redirect_uri: req.url, redirect_uri: withoutParams.toString(),
state,
}) })
res.code(200) res.code(200)
res.type("text/html") res.type("text/html")
res.send(pug.renderFile(join("static/pages", this.templateFilename), {authUrl, baseUrl, isReset: this.webhook.isPresent})) res.send(pug.renderFile(join("static/pages", this.templateFilename), {
authUrl,
baseUrl,
isReset: this.webhook.isPresent
}))
return return
} }
const validXSRF = checkAndClearXSRFCookie(req, res)
if (errorCode !== null || code === null) {
const errorDescription = getFirstValue(req.query.error_description) ?? null
const errorUrl = getFirstValue(req.query.error_uri)
return renderError({
baseUrl,
res,
code: 400,
error: errorDescription ?? errorCode ?? "There was no code or error present.",
context: "authorizing the application",
errorUrl,
buttonText: "Try Again",
buttonUrl: relative("/", new URL(req.url).pathname),
})
}
if (!validXSRF) {
return renderError({
baseUrl,
res,
code: 409,
error: "The state was incorrectly set - try restarting the authentication process.",
context: "processing the new access code",
buttonText: "Try Again",
buttonUrl: relative("/", new URL(req.url).pathname),
})
}
let token: DiscordWebhookToken let token: DiscordWebhookToken
try { try {
token = await client.getToken({ token = await client.getToken({
@ -61,15 +116,21 @@ export class DiscordWebhookHandler {
baseUrl, baseUrl,
res: res, res: res,
error: e, error: e,
context: "exchanging the code you gave me for a token" code: 400,
context: "exchanging the code you gave me for a token",
buttonText: "Try Again",
buttonUrl: relative("/", new URL(req.url).pathname),
}) })
} }
if (!token.token.webhook) { if (!token.token.webhook) {
return renderError({ return renderError({
baseUrl, baseUrl,
res: res, res: res,
error: "token did not contain webhook", code: 400,
context: "processing the token I received" error: "The token did not contain a webhook.",
context: "processing the token I received",
buttonText: "Try Again",
buttonUrl: relative("/", new URL(req.url).pathname),
}) })
} }
const wasSet = this.webhook.isPresent const wasSet = this.webhook.isPresent
@ -79,8 +140,11 @@ export class DiscordWebhookHandler {
return renderError({ return renderError({
baseUrl, baseUrl,
res: res, res: res,
code: 500,
error: e, error: e,
context: `saving the new webhook${wasSet ? " and deleting the old one" : ""}` context: `saving the new webhook${wasSet ? " and deleting the old one" : ""}`,
buttonText: "Try Again",
buttonUrl: relative("/", new URL(req.url).pathname),
}) })
} }
res.redirect(this.destinationFunc()) res.redirect(this.destinationFunc())

@ -1,10 +1,6 @@
/** Constructs the base URL from the headers from the given request. */ /** Constructs the base URL from the headers from the given request. */
import {FastifyRequest} from "fastify"; import fastify, {FastifyInstance, FastifyRequest} from "fastify";
import {RouteGenericInterface} from "fastify/types/route"; import {Server, ServerOptions, ServerRequestHandler} from "slash-create";
export interface RouteWithQuerystring extends RouteGenericInterface {
Querystring: {[key: string]: string|string[]|undefined}
}
export function getBaseUrl(request: FastifyRequest): string { export function getBaseUrl(request: FastifyRequest): string {
const hostHeader = request.hostname ?? "localhost" const hostHeader = request.hostname ?? "localhost"
@ -14,6 +10,69 @@ export function getBaseUrl(request: FastifyRequest): string {
} }
/** Translates a zero-to-many set of strings to one or zero strings. */ /** Translates a zero-to-many set of strings to one or zero strings. */
export function getFirstValue(value: string|string[]|undefined): string|undefined { export function getFirstValue(value: string | string[] | undefined): string | undefined {
return typeof value === "string" ? value : Array.isArray(value) ? value[0] : undefined return typeof value === "string" ? value : Array.isArray(value) ? value[0] : undefined
} }
/**
* A server for Fastify applications.
* @see https://fastify.io
*/
export class FastifyServerButItWorksUnlikeTheRealOne extends Server {
readonly app: FastifyInstance;
/**
* @param app The fastify application
* @param opts The server options
*/
constructor(app?: FastifyInstance, opts?: ServerOptions) {
super(opts);
this.app = app || fastify();
}
/**
* Adds middleware to the Fastify server.
* <warn>This requires you to have the 'middie' module registered to the server before using.</warn>
* @param middleware The middleware to add.
* @see https://www.fastify.io/docs/latest/Middleware/
*/
addMiddleware(middleware: Function) {
// @ts-ignore
if ('use' in this.app) this.app.use(middleware);
else
throw new Error(
"In order to use Express-like middleware, you must initialize the server and register the 'middie' module."
);
return this;
}
/** Alias for {@link FastifyServerButItWorksUnlikeTheRealOne#addMiddleware} */
use(middleware: Function) {
return this.addMiddleware(middleware);
}
/** @private */
createEndpoint(path: string, handler: ServerRequestHandler) {
this.app.post(path, (req: any, res: any) =>
handler(
{
headers: req.headers,
body: req.body,
request: req,
response: res
},
async (response) => {
res.status(response.status || 200);
if (response.headers) res.headers(response.headers);
res.send(response.body);
}
)
);
}
/** @private */
async listen(port = 8030, host = 'localhost') {
if (this.alreadyListening) return;
await this.app.listen(port, host);
}
}

@ -1,100 +1,109 @@
import {SavedWebhook} from "./SavedWebhook.js"; import {SetupServer} from "./SetupServer.js";
import fastify, {FastifyInstance} from "fastify"; import {checkAndClearXSRFCookie, generateXSRFCookie, XSRFRoute} from "./CookieHelpers.js";
import {DiscordWebhookHandler} from "./DiscordWebhookHandler.js";
import {getBaseUrl, RouteWithQuerystring} from "./FastifyHelpers.js";
import pug from "pug";
import {renderError} from "./PugRenderer.js"; import {renderError} from "./PugRenderer.js";
import {getBaseUrl} from "./FastifyHelpers.js";
import pug from "pug";
import {BaseServer, BaseServerDeps} from "./BaseServer.js";
import {PullCommand} from "./commands/game/PullCommand.js";
export class GameServer { export class GameServer extends BaseServer {
readonly server: FastifyInstance readonly setupFactory: () => SetupServer
readonly gameWebhook: SavedWebhook
readonly adminWebhook: SavedWebhook
readonly appId: string
readonly secret: string
readonly port: number
constructor({appId, secret, port}: {appId: string, secret: string, port: number}) {
this.server = fastify({
logger: true
})
this.appId = appId
this.secret = secret
this.port = port
this.gameWebhook = new SavedWebhook("broadcasthook.json", {logger: this.server.log})
this.adminWebhook = new SavedWebhook("logginghook.json", {logger: this.server.log})
}
async initialize(): Promise<void> { constructor(deps: BaseServerDeps & { setupFactory: () => SetupServer }) {
const gameHandler = new DiscordWebhookHandler({ super(deps)
webhook: this.gameWebhook, this.setupFactory = deps.setupFactory
templateFilename: "setupGame.pug",
appId: this.appId,
secret: this.secret,
destinationFunc: () => {
if (this.adminWebhook.isPresent) {
return "clear"
} else {
return "adminChannel"
} }
},
async _initInternal(): Promise<void> {
this.slashcmd.registerCommand(new PullCommand(this.slashcmd, this.gameWebhook))
this.server.get("/game/started", async (req, res) => {
const token = generateXSRFCookie(res)
res.code(200)
res.type("text/html")
res.send(pug.renderFile("static/pages/game/running.pug", {
baseUrl: getBaseUrl(req),
setupModeUrl: `game/stop?token=${token}`,
shutdownUrl: `shutdown?token=${token}`,
}))
}) })
const adminHandler = new DiscordWebhookHandler({ this.server.get("/game", async (req, res) => {
webhook: this.adminWebhook, res.redirect("game/started")
templateFilename: "setupAdmin.pug",
appId: this.appId,
secret: this.secret,
destinationFunc: () => {
if (this.gameWebhook.isPresent) {
return "clear"
} else {
return "gameChannel"
}
},
}) })
this.server.get<RouteWithQuerystring>("/setup", async (req, res) => { this.server.get("/game/start", async (req, res) => {
if (!this.gameWebhook.isPresent) { res.redirect("started")
res.redirect(`setup/gameChannel`) })
} else if (!this.adminWebhook.isPresent) { this.server.get("/setup", async (req, res) => {
res.redirect(`setup/adminChannel`) res.redirect("game/started")
} else { })
res.redirect(`setup/done`) this.server.get("/setup/gameChannel", async (req, res) => {
} res.redirect("../game/started")
}) })
this.server.get<RouteWithQuerystring>("/setup/gameChannel", async (req, res) => { this.server.get("/setup/adminChannel", async (req, res) => {
return await gameHandler.handleRequest(req, res) res.redirect("../game/started")
}) })
this.server.get<RouteWithQuerystring>("/setup/adminChannel", async (req, res) => { this.server.get("/setup/clear", async (req, res) => {
return await adminHandler.handleRequest(req, res) res.redirect("../game/started")
})
this.server.get("/setup/done", async (req, res) => {
res.redirect("../game/started")
})
this.server.get<XSRFRoute>("/game/stop", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) {
return renderError({
baseUrl: getBaseUrl(req),
res,
code: 400,
error: "Token was incorrect or not set.",
context: "stopping the game",
buttonText: "Return to Game",
buttonUrl: "game/started"
}) })
this.server.get<RouteWithQuerystring>("/setup/clear", async (req, res) => { }
res.code(200)
res.type("text/html")
res.send(pug.renderFile("static/pages/game/stop.pug", {
startedUrl: "setup"
}))
setImmediate(async () => {
this.server.log.info("Shutting down the game server and switching to the setup server.")
try { try {
await Promise.all([this.gameWebhook, this.adminWebhook] await this.server.close()
.filter((item) => item.isPresent)
.map((item) => item.clearHook()))
} catch (e) { } catch (e) {
this.server.log.error(e, "Failed to shut down the game server")
}
try {
await this.setupFactory().initialize()
} catch (e) {
this.server.log.error(e, "Failed to start up the setup server")
}
this.server.log.info("Successfully switched from the game server to the setup server.")
})
})
this.server.get<XSRFRoute>("/shutdown", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) {
return renderError({ return renderError({
baseUrl: getBaseUrl(req), baseUrl: getBaseUrl(req),
res, res,
error: e, code: 400,
context: "clearing the broadcast webhook" error: "Token was incorrect or not set.",
context: "shutting down the game",
buttonText: "Return to Game",
buttonUrl: "game/started"
}) })
} }
res.redirect(".")
})
this.server.get<RouteWithQuerystring>("/setup/done", async (req, res) => {
if (!this.gameWebhook.isPresent) {
res.redirect("gameChannel")
} else if (!this.adminWebhook.isPresent) {
res.redirect("adminChannel")
} else {
res.code(200) res.code(200)
res.type("text/html") res.type("text/html")
res.send(pug.renderFile("static/pages/setupDone.pug", { res.send(pug.renderFile("static/pages/shutdown.pug"))
baseUrl: getBaseUrl(req), setImmediate(async () => {
clearUrl: "setup/clear", this.server.log.info("Shutting down the game server.")
gameSetupUrl: "setup/gameChannel", try {
adminSetupUrl: "setup/adminChannel"})) await this.server.close()
} catch (e) {
this.server.log.error(e, "Failed to shut down the game server")
} }
this.server.log.info("Shut down. Good night...")
})
}) })
await Promise.all([this.gameWebhook.load(), this.adminWebhook.load(), this.server.listen(this.port, "127.0.0.1")])
} }
} }

@ -2,12 +2,24 @@ import {FastifyReply} from "fastify";
import {renderFile} from "pug"; import {renderFile} from "pug";
/** Renders the error page into the given reply. */ /** Renders the error page into the given reply. */
export function renderError({baseUrl, res, error, context}: {baseUrl: string, res: FastifyReply, error: unknown, context: string}): void { export function renderError({
res.code(500) baseUrl,
code = 500,
res,
error,
context,
errorUrl,
buttonText,
buttonUrl
}: { baseUrl: string, code?: number, res: FastifyReply, error: unknown, context: string, errorUrl?: string, buttonText?: string, buttonUrl?: string }): void {
res.code(code)
res.type("text/html") res.type("text/html")
res.send(renderFile("static/pages/error.pug", { res.send(renderFile("static/pages/setup/error.pug", {
baseUrl, baseUrl,
error, error,
context, context,
errorUrl,
buttonText: buttonText ?? "Return to Setup",
buttonUrl: buttonUrl ?? "setup"
})) }))
} }

@ -1,7 +1,7 @@
/** */ /** */
import {APIWebhook} from "discord-api-types"; import {APIWebhook} from "discord-api-types";
import {FastifyLoggerInstance} from "fastify"; import {FastifyLoggerInstance} from "fastify";
import {readFile as fsReadFile, writeFile as fsWriteFile, mkdir as fsMkdir, rm as fsRm} from "fs/promises"; import {mkdir as fsMkdir, readFile as fsReadFile, rm as fsRm, writeFile as fsWriteFile} from "fs/promises";
import axios from "axios"; import axios from "axios";
import {join} from "path"; import {join} from "path";
import {isENOENT} from "./NodeErrorHelpers.js"; import {isENOENT} from "./NodeErrorHelpers.js";
@ -11,8 +11,7 @@ const WebhookPath = "runtime/webhooks"
/** File-based storage for webhook instances. */ /** File-based storage for webhook instances. */
export class SavedWebhook { export class SavedWebhook {
readonly filename: string readonly filename: string
private _state: APIWebhook|null|undefined private readonly logger: FastifyLoggerInstance | null
private readonly logger: FastifyLoggerInstance|null
private readonly readFile: typeof fsReadFile private readonly readFile: typeof fsReadFile
private readonly writeFile: typeof fsWriteFile private readonly writeFile: typeof fsWriteFile
private readonly mkdir: typeof fsMkdir private readonly mkdir: typeof fsMkdir
@ -20,7 +19,15 @@ export class SavedWebhook {
private readonly delRequest: typeof axios["delete"] private readonly delRequest: typeof axios["delete"]
/** Initializes a SavedWebhook pointing at the given location. */ /** Initializes a SavedWebhook pointing at the given location. */
constructor(filename: string, {logger, state, readFile, writeFile, mkdir, rm, delRequest}: {logger?: FastifyLoggerInstance|null, state?: APIWebhook|null, readFile?: typeof fsReadFile, writeFile?: typeof fsWriteFile, mkdir?: typeof fsMkdir, rm?: typeof fsRm, delRequest?: typeof axios["delete"]}) { constructor(filename: string, {
logger,
state,
readFile,
writeFile,
mkdir,
rm,
delRequest
}: { logger?: FastifyLoggerInstance | null, state?: APIWebhook | null, readFile?: typeof fsReadFile, writeFile?: typeof fsWriteFile, mkdir?: typeof fsMkdir, rm?: typeof fsRm, delRequest?: typeof axios["delete"] }) {
this.filename = filename this.filename = filename
this._state = state this._state = state
this.logger = logger?.child({webhookFile: filename}) ?? null this.logger = logger?.child({webhookFile: filename}) ?? null
@ -31,6 +38,17 @@ export class SavedWebhook {
this.delRequest = delRequest ?? axios.delete.bind(axios) this.delRequest = delRequest ?? axios.delete.bind(axios)
} }
private _state: APIWebhook | null | undefined
/** Gets the current state of the webhook, either a webhook instance or null. */
get state(): APIWebhook | null {
if (this._state === undefined) {
this.logger?.warn("SavedWebhook was not initialized before having its state checked")
return null
}
return this._state
}
/** The path of the webhook, including WebhookPath. */ /** The path of the webhook, including WebhookPath. */
get path(): string { get path(): string {
return join(WebhookPath, this.filename) return join(WebhookPath, this.filename)
@ -41,17 +59,8 @@ export class SavedWebhook {
return this.state !== null return this.state !== null
} }
/** Gets the current state of the webhook, either a webhook instance or null. */
get state(): APIWebhook|null {
if (this._state === undefined) {
this.logger?.warn("SavedWebhook was not initialized before having its state checked")
return null
}
return this._state
}
/** Loads the current state from the disk, including a null state if not present. */ /** Loads the current state from the disk, including a null state if not present. */
async load(): Promise<APIWebhook|null> { async load(): Promise<APIWebhook | null> {
if (this._state !== undefined) { if (this._state !== undefined) {
this.logger?.warn(`SavedWebhook was double-initialized`) this.logger?.warn(`SavedWebhook was double-initialized`)
} }
@ -138,6 +147,6 @@ export class SavedWebhook {
return return
} }
await this.delRequest(`https://discord.com/api/webhooks/${this._state.id}/${this._state.token}`, await this.delRequest(`https://discord.com/api/webhooks/${this._state.id}/${this._state.token}`,
{validateStatus: (s) => s === 204 || s === 404 }) {validateStatus: (s) => s === 204 || s === 404})
} }
} }

@ -0,0 +1,192 @@
import {DiscordWebhookHandler, OAuthRoute} from "./DiscordWebhookHandler.js";
import {getBaseUrl} from "./FastifyHelpers.js";
import pug from "pug";
import {renderError} from "./PugRenderer.js";
import {GameServer} from "./GameServer.js";
import {checkAndClearXSRFCookie, generateXSRFCookie, XSRFRoute} from "./CookieHelpers.js";
import {SetupCommand, setupComponentInteractionHandler} from "./commands/SetupCommand.js";
import {BaseServer, BaseServerDeps} from "./BaseServer.js";
export class SetupServer extends BaseServer {
readonly gameFactory: () => GameServer
constructor(deps: BaseServerDeps & { gameFactory: () => GameServer }) {
super(deps)
this.gameFactory = deps.gameFactory
}
async _initInternal(): Promise<void> {
this.slashcmd.registerCommand(new SetupCommand(this.slashcmd))
this.slashcmd.on("componentInteraction", setupComponentInteractionHandler)
const gameHandler = new DiscordWebhookHandler({
webhook: this.gameWebhook,
templateFilename: "setup/game.pug",
appId: this.appId,
secret: this.clientSecret,
destinationFunc: () => {
if (this.adminWebhook.isPresent) {
return "clear"
} else {
return "adminChannel"
}
},
})
const adminHandler = new DiscordWebhookHandler({
webhook: this.adminWebhook,
templateFilename: "setup/admin.pug",
appId: this.appId,
secret: this.clientSecret,
destinationFunc: () => {
if (this.gameWebhook.isPresent) {
return "clear"
} else {
return "gameChannel"
}
},
})
this.server.get("/setup", async (req, res) => {
if (!this.gameWebhook.isPresent) {
res.redirect(`setup/gameChannel`)
} else if (!this.adminWebhook.isPresent) {
res.redirect(`setup/adminChannel`)
} else {
res.redirect(`setup/done`)
}
})
this.server.get("/setup/start", async (req, res) => {
res.redirect(".")
})
this.server.get("/game", async (req, res) => {
res.redirect("setup")
})
this.server.get("/game/started", async (req, res) => {
res.redirect("../setup")
})
this.server.get("/game/stop", async (req, res) => {
res.redirect("../setup")
})
this.server.get<XSRFRoute>("/game/start", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) {
return renderError({
baseUrl: getBaseUrl(req),
res,
code: 400,
error: "Token was incorrect or not set.",
context: "starting the game",
buttonText: "Return to Setup",
buttonUrl: "setup"
})
}
if (!this.gameWebhook.isPresent || !this.adminWebhook.isPresent) {
return renderError({
baseUrl: getBaseUrl(req),
res,
code: 409,
error: "You can't start the game while one of the channels is not set!",
context: "starting the game",
buttonText: "Finish Setup",
buttonUrl: "setup"
})
}
res.code(200)
res.type("text/html")
res.send(pug.renderFile("static/pages/setup/start.pug", {
startedUrl: "game/start"
}))
setImmediate(async () => {
this.server.log.info("Shutting down the setup server and switching to the game server.")
try {
await this.server.close()
} catch (e) {
this.server.log.error(e, "Failed to shut down the setup server")
}
try {
await this.gameFactory().initialize()
} catch (e) {
this.server.log.error(e, "Failed to start up the game server")
}
this.server.log.info("Successfully switched from the setup server to the game server.")
})
})
this.server.get<OAuthRoute>("/setup/gameChannel", async (req, res) => {
return await gameHandler.handleRequest(req, res)
})
this.server.get<OAuthRoute>("/setup/adminChannel", async (req, res) => {
return await adminHandler.handleRequest(req, res)
})
this.server.get<XSRFRoute>("/setup/clear", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) {
return renderError({
baseUrl: getBaseUrl(req),
res,
code: 400,
error: "Token was incorrect or not set.",
context: "clearing the channel setup",
buttonText: "Return to Setup",
buttonUrl: "setup"
})
}
try {
await Promise.all([this.gameWebhook, this.adminWebhook]
.filter((item) => item.isPresent)
.map((item) => item.clearHook()))
} catch (e) {
return renderError({
baseUrl: getBaseUrl(req),
res,
code: 500,
error: e,
context: "clearing and deleting the webhooks",
buttonUrl: "setup/clear",
buttonText: "Try Again"
})
}
res.redirect(".")
})
this.server.get("/setup/done", async (req, res) => {
if (!this.gameWebhook.isPresent) {
res.redirect("gameChannel")
} else if (!this.adminWebhook.isPresent) {
res.redirect("adminChannel")
} else {
const token = generateXSRFCookie(res)
res.code(200)
res.type("text/html")
res.send(pug.renderFile("static/pages/setup/done.pug", {
baseUrl: getBaseUrl(req),
gameModeUrl: `game/start?token=${token}`,
clearUrl: `setup/clear?token=${token}`,
gameSetupUrl: `setup/gameChannel`,
adminSetupUrl: `setup/adminChannel`,
shutdownUrl: `shutdown?token=${token}`
}))
}
})
this.server.get<XSRFRoute>("/shutdown", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) {
return renderError({
baseUrl: getBaseUrl(req),
res,
code: 400,
error: "Token was incorrect or not set.",
context: "shutting the server down",
buttonText: "Return to Setup",
buttonUrl: "setup"
})
}
res.code(200)
res.type("text/html")
res.send(pug.renderFile("static/pages/shutdown.pug"))
setImmediate(async () => {
this.server.log.info("Shutting down the setup server.")
try {
await this.server.close()
} catch (e) {
this.server.log.error(e, "Failed to shut down the setup server")
}
this.server.log.info("Shut down. Good night...")
})
})
}
}

@ -1,5 +1,11 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import {SetupServer} from "./SetupServer.js";
import {GameServer} from "./GameServer.js"; import {GameServer} from "./GameServer.js";
import cryptoRandomString from "crypto-random-string";
import {SavedWebhook} from "./SavedWebhook.js";
import pino from "pino"
const log = pino()
async function main(): Promise<void> { async function main(): Promise<void> {
const {parsed, error} = dotenv.config() const {parsed, error} = dotenv.config()
@ -8,14 +14,44 @@ async function main(): Promise<void> {
} }
const clientSecret = parsed["DISCORD_CLIENT_SECRET"] const clientSecret = parsed["DISCORD_CLIENT_SECRET"]
const appId = parsed["DISCORD_APP_ID"] const appId = parsed["DISCORD_APP_ID"]
const port = parseInt(parsed["HTTP_PORT"] ?? "5244") const botToken = parsed["DISCORD_BOT_TOKEN"]
const server = new GameServer({ const publicKey = parsed["DISCORD_PUBLIC_KEY"]
appId, port, secret: clientSecret const listenPort = parseInt(parsed["HTTP_PORT"] ?? "5244")
}) const listenAddress = parsed["HTTP_ADDRESS"] ?? "127.0.0.1"
return await server.initialize() const cookieSecret = parsed["COOKIE_SECRET"] ?? cryptoRandomString({length: 32, type: "base64"})
const gameWebhook = new SavedWebhook("game.json", {logger: log})
const adminWebhook = new SavedWebhook("admin.json", {logger: log})
const factory: { game: () => GameServer, setup: () => SetupServer } = {
game(): never {
throw Error("game factory not set up yet")
},
setup(): never {
throw Error("setup factory not set up yet")
},
}
const deps = {
appId,
listenAddress,
listenPort,
clientSecret,
cookieSecret,
gameFactory: () => factory.game(),
setupFactory: () => factory.setup(),
gameWebhook,
adminWebhook,
botToken,
publicKey,
}
factory.setup = () => new SetupServer(deps)
factory.game = () => new GameServer(deps)
await Promise.all([gameWebhook.load(), adminWebhook.load()])
if (gameWebhook.isPresent && adminWebhook.isPresent) {
await factory.game().initialize()
} else {
await factory.setup().initialize()
}
} }
main().catch((err) => { main().catch((err) => {
console.log("Main crashed!") log.fatal(err, "Startup failed!")
console.log(err)
}) })

@ -0,0 +1,28 @@
import {AutocompleteContext, CommandContext, ComponentContext, Message, SlashCommand, SlashCreator} from "slash-create";
export class SetupCommand extends SlashCommand {
constructor(creator: SlashCreator) {
super(creator, {
name: "setup-commands-disabled",
unknown: true
});
}
autocomplete(ctx: AutocompleteContext): Promise<any> {
return ctx.sendResults([])
}
run(ctx: CommandContext): Promise<any> {
return ctx.send({
ephemeral: true,
content: "The server is currently in setup mode! You can't run commands right now..."
})
}
}
export function setupComponentInteractionHandler(ctx: ComponentContext): Promise<boolean | Message> {
return ctx.send({
ephemeral: true,
content: "The server is currently in setup mode! You can't interact with its messages right now..."
})
}

@ -0,0 +1,56 @@
import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create";
import {SavedWebhook} from "../../SavedWebhook.js";
import {Chance} from "chance";
const rand = Chance()
export class PullCommand extends SlashCommand {
readonly gameWebhook: SavedWebhook
constructor(creator: SlashCreator, gameWebhook: SavedWebhook) {
super(creator, {
name: "pull",
guildIDs: gameWebhook.state?.guild_id,
description: "Pulls one or more new heroines from the ether.",
options: [
{
name: "count",
description: "The number of heroines to pull.",
required: false,
max_value: 10,
min_value: 1,
type: CommandOptionType.NUMBER,
}
]
});
this.gameWebhook = gameWebhook
}
run(ctx: CommandContext): Promise<any> {
if (ctx.guildID !== this.gameWebhook.state?.guild_id) {
return ctx.send({
content: "Sorry, you can't do that in this guild.",
ephemeral: true,
})
}
if (ctx.channelID !== this.gameWebhook.state?.channel_id) {
return ctx.send({
content: `Sorry, you can't do that here. You have to do it in <#${this.gameWebhook.state?.channel_id}>.`,
ephemeral: true,
})
}
const count: number = ctx.options.count ?? 1
const results: string[] = []
for (let x = 0; x < count; x += 1) {
results.push(rand.weighted(["**Nicole**: D tier Predator Podcaster",
"**Herja**: C tier Viking Warrior",
"**Sharla**: B tier Skark Girl",
"**Melpomene**: A tier Muse of Tragedy",
"**Lady Bootstrap**: S tier Time Traveler"], [20, 15, 10, 5, 1]))
}
return ctx.send({
content: `_${ctx.user.mention}_, you pulled...\n \\* ${results.join("\n \\* ")}`,
ephemeral: false,
})
}
}

@ -1,10 +0,0 @@
extends ../template/template
block title
| Setup Error
block content
| Something went wrong while #{context}. What did you do?
br
| The error was: #{error}
block link
a(href="setup") Return to Setup Page

@ -0,0 +1,11 @@
extends ../../template/template
block title
| Game Running
block content
| The game is up and running.
br
| To make changes to its configuration setup, click Return to Setup.
block link
a(href=setupModeUrl, rel="nofollow") Return to Setup
a(href=shutdownUrl, rel="nofollow") Shut Down Server

@ -0,0 +1,13 @@
extends ../../template/template
block title
| Game Stopping
block content
| You got it! The game is being stopped and returned to the setup phase now...
| If you aren't automatically redirected after a few seconds, click the button below.
script.
setTimeout(() => {
document.getElementById("stopButton").click()
}, 3000)
block link
a(href=stoppedUrl, id="stopButton", rel="nofollow") Verify Game Stopped

@ -1,4 +1,4 @@
extends ../template/template extends ../../template/template
block title block title
| Admin Setup | Admin Setup

@ -0,0 +1,14 @@
extends ../../template/template
block title
| Setup Complete!
block content
| You're all done! Time to just play the game.
br
| If you'd like to reconfigure the game's channels, click one of the buttons below.
block link
a(href=gameModeUrl, rel="nofollow") Start the Game!
a(href=gameSetupUrl, rel="nofollow") Reconfigure Game Channel
a(href=adminSetupUrl, rel="nofollow") Reconfigure Admin Channel
a(href=clearUrl, rel="nofollow") Clear All Channels
a(href=shutdownUrl, rel="nofollow") Shut Down Server

@ -0,0 +1,13 @@
extends ../../template/template
block title
| Setup Error
block content
| Something went wrong while #{context}. What did you do?
br
if errorUrl
| The error was: #[a(href=errorUrl) #{error}]
else
| The error was: #{error}
block link
a(href=buttonUrl, rel="nofollow") #{buttonText}

@ -1,4 +1,4 @@
extends ../template/template extends ../../template/template
block title block title
| Game Setup | Game Setup
@ -15,4 +15,4 @@ block content
br br
| Click the link to choose a game channel. | Click the link to choose a game channel.
block link block link
a(href=authUrl) Connect Game Channel a(href=authUrl, rel="nofollow") Connect Game Channel

@ -0,0 +1,13 @@
extends ../../template/template
block title
| Game Starting
block content
| You got it! The game is being started up now...
| If you aren't automatically redirected after a few seconds, click the button below.
script.
setTimeout(() => {
document.getElementById("startButton").click()
}, 3000)
block link
a(href=startedUrl, id="startButton", rel="nofollow") Verify Game Started

@ -1,12 +0,0 @@
extends ../template/template
block title
| Setup Complete!
block content
| You're all done! Time to just play the game.
br
| If you'd like to reconfigure the game's channels, click one of the buttons below.
block link
a(href=gameSetupUrl) Reconfigure Game Channel
a(href=adminSetupUrl) Reconfigure Admin Channel
a(href=clearUrl) Clear All Channels

@ -0,0 +1,6 @@
extends ../template/template
block title
| Server Shutdown
block content
| You got it. The server has been shut down. Good night...

@ -4,13 +4,51 @@ html
block title block title
base(href=baseUrl) base(href=baseUrl)
style. style.
body { display: flex; flex-flow: column; justify-content: center; align-items: center; min-height: 100vh; font-family: sans-serif; background-color: darkblue; color: aliceblue } body {
body * { max-width: 40em; } display: flex;
p.content { text-align: center; } flex-flow: column;
p.link { display: flex; flex-flow: row; justify-content: center; align-items: center; } justify-content: center;
p.link a { display: inline-block; border-radius: 5px; background-color: cadetblue; color: black; border: 2px solid darkslateblue; padding: 0.5em; box-shadow: 2px 4px 0 rgba(255,255,255,0.55); } align-items: center;
a:visited, a:active, a:link { color: inherit; text-decoration: none; } min-height: 100vh;
p.link a:active { transform: translate(2px, 4px); box-shadow: 0 0 transparent; } font-family: sans-serif;
background-color: darkblue;
color: aliceblue
}
body * {
max-width: 40em;
}
p.content {
text-align: center;
}
p.link {
display: flex;
flex-flow: row;
justify-content: center;
align-items: center;
}
p.link a {
display: inline-block;
border-radius: 5px;
background-color: cadetblue;
color: black;
border: 2px solid darkslateblue;
padding: 0.5em;
box-shadow: 2px 4px 0 rgba(255, 255, 255, 0.55);
}
p.link a:visited, p.link a:active, p.link a:link {
color: inherit;
text-decoration: none;
}
p.link a:active {
transform: translate(2px, 4px);
box-shadow: 0 0 transparent;
}
body body
h1 h1
block title block title

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "ES2018",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",

Loading…
Cancel
Save