Files
zkldi_Tachi/bot/src/server/server.ts
T

225 lines
6.1 KiB
TypeScript

import { ValidateWebhookRequest } from "./middleware";
import { BotConfig, ProcessEnv } from "../config";
import { LoggerLayers } from "../data/data";
import db from "../database/mongo";
import { TachiServerV1Request, RequestTypes, TachiServerV1Get } from "../utils/fetchTachi";
import { CreateLayeredLogger } from "../utils/logger";
import { VERSION_PRETTY } from "../version";
import { HandleClassUpdateV1 } from "../webhookHandlers/classUpdate";
import { HandleGoalAchievedV1 } from "../webhookHandlers/goalAchieved";
import express from "express";
import path from "path";
import type { Express } from "express";
import type { APITokenDocument, PublicUserDocument, WebhookEvents } from "tachi-common";
export const app: Express = express();
const logger = CreateLayeredLogger(LoggerLayers.server);
app.use(express.json());
// Let NGINX work its magic.
app.set("trust proxy", "loopback");
// Disable query string nesting such as ?a[b]=4 -> {a: {b: 4}}. This
// almost always results in a painful security vuln.
app.set("query parser", "simple");
/**
* Return the status of this bot and the version it's running.
*
* @name GET /
*/
app.get("/", (req, res) =>
res.status(200).json({
success: true,
description: "Bot is online!",
body: {
time: Date.now(),
version: VERSION_PRETTY,
},
})
);
/**
* Our OAuth2 Callback handler. Note that this is a GET request, as per
* OAuth spec, but does perform *real* mutations on data. It's awkward.
*
* @param code - The intermediate code for us to send back.
* @param context - The discordID we fired this auth request with.
*
* @name GET /oauth/callback
*/
app.get("/oauth/callback", async (req, res) => {
if (typeof req.query.code !== "string") {
return res.status(400).send("Bad Request.");
}
if (typeof req.query.context !== "string") {
return res.status(400).send("Bad Request.");
}
const tokenRes = await TachiServerV1Request<APITokenDocument>(
RequestTypes.POST,
"/oauth/token",
null,
{
code: req.query.code,
client_id: BotConfig.OAUTH.CLIENT_ID,
client_secret: BotConfig.OAUTH.CLIENT_SECRET,
grant_type: "authorization_code",
redirect_uri: `${BotConfig.HTTP_SERVER.URL}/oauth/callback`,
}
);
if (!tokenRes.success) {
logger.error(
`Failed to convert code ${req.query.code} to a token. ${tokenRes.description} Cannot auth.`
);
return res.status(401).json({
success: false,
description: "Failed to authenticate.",
});
}
const discordID = req.query.context;
const apiToken = tokenRes.body.token!;
const whoamiRes = await TachiServerV1Get<PublicUserDocument>("/users/me", apiToken);
if (!whoamiRes.success) {
logger.severe("Failed to request user with token we just got?", { discordID });
return res
.status(500)
.send(
"Something's gone very wrong. An internal server error has occured. This has been reported."
);
}
const user = whoamiRes.body;
logger.info(`Saving user-discord-link for ${user.username} (id: ${user.id}).`);
const existingLink = await db.discordUserMap.findOne({ userID: user.id });
if (existingLink) {
logger.info(`Updating user-discord-link for ${user.username} (id: ${user.id})`);
await db.discordUserMap.update(
{
userID: user.id,
},
{
$set: {
discordID,
tachiApiToken: apiToken,
},
}
);
} else {
await db.discordUserMap.insert({
discordID,
tachiApiToken: apiToken,
userID: user.id,
});
}
res.sendFile(path.join(__dirname, "../../pages/account-linked.html"));
});
/**
* Listens for tachi-server style webhook calls.
*
* @name POST /webhook
*/
app.post("/webhook", ValidateWebhookRequest, async (req, res) => {
// We can be reasonably assured that the request body will be
// in this form. If it isn't, there are bigger problems!
const webhookEvent = req.body as WebhookEvents;
let statusCode = 200;
switch (webhookEvent.type) {
case "class-update/v1": {
statusCode = await HandleClassUpdateV1(webhookEvent.content);
break;
}
case "goals-achieved/v1": {
statusCode = await HandleGoalAchievedV1(webhookEvent.content);
break;
}
case "milestone-achieved/v1":
default: {
// get around to updating in time.
// to define new webhooks, and the bot might not
// However, tachi-(server/common) may recieve an update
// According to the types, this should never happen.
logger.warn(
`Received unknown webhook event ${
(webhookEvent as WebhookEvents).type
}. Have we got support for this?`
);
return res.status(501).json({
success: false,
description: `The type ${(webhookEvent as WebhookEvents).type} is unsupported.`,
});
}
}
return res.sendStatus(statusCode);
});
/**
* 404 Handler. If something gets to this point, they haven't matched with anything.
*
* @name ALL *
*/
app.all("*", (req, res) =>
res.status(404).json({
success: false,
description: "Nothing found here.",
})
);
interface ExpressJSONErr extends SyntaxError {
status: number;
message: string;
}
/**
* A catch-all emergency error handler for express. This returns 500
* on unknown errors, but has a hack in place to return 400 for JSON parsing
* errors. This is because the default express JSON Body Parser throws a
* fatal error on invalid JSON, but really, it should just be a return 400.
*/
// Although ESLint will whine about next being unused, it's necessary for
// express as it uses function arity to determine whether something is an
// error handler or not.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const MainExpressErrorHandler: express.ErrorRequestHandler = (err, req, res, _next) => {
if (err instanceof SyntaxError) {
const expErr: ExpressJSONErr = err as ExpressJSONErr;
if (expErr.status === 400 && "body" in expErr) {
logger.info(`Error in parsing JSON in request body from ${req.url}`, {
url: req.originalUrl,
});
return res.status(400).send({ success: false, description: err.message });
}
// else, this isn't a JSON parsing error
}
logger.error(err, req.route);
return res.status(500).json({
success: false,
description: "A fatal internal server error has occured.",
});
};
app.use(MainExpressErrorHandler);
logger.info(`Starting express server on port ${ProcessEnv.port}.`);