azerothcore-armory/src/armory/Armory.ts
2025-04-28 21:49:07 -04:00

282 lines
9.7 KiB
TypeScript

import * as path from "path";
import * as uuid from "uuid";
import { Express } from "express";
import * as express from "express";
import * as winston from "winston";
import * as morgan from "morgan";
import { Pool, createPool } from "mysql2/promise";
import { engine as handlebarsEngine } from "express-handlebars";
import { Config, IRealmConfig } from "./Config";
import { DbcManager } from "./data/DbcReader";
import { CharacterCustomization } from "./data/CharacterCustomization";
import { IndexController } from "./controllers/IndexController";
import { CharacterController } from "./controllers/CharacterController";
import { GuildController } from "./controllers/GuildController";
import { ArenaController } from "./controllers/ArenaController";
export class Armory {
public characterCustomization: CharacterCustomization;
public dbc: DbcManager;
public config: Config;
public worldDb: Pool;
public logger: winston.Logger;
public charsetCache: { [key: string]: string };
private charsDbs: { [key: string]: Pool };
private errorNames: { [key: number]: string };
private errorDescriptions: { [key: number]: string };
public constructor() {
this.dbc = new DbcManager();
this.characterCustomization = new CharacterCustomization();
this.charsDbs = {};
this.logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
winston.format.printf((info) => `[${info.timestamp}] [${info.level.toUpperCase()}]: ${info.message}`),
),
transports: [
new winston.transports.Console({ level: "debug" }),
new winston.transports.File({ filename: path.join("logs", "armory.error.log"), level: "error" }),
new winston.transports.File({ filename: path.join("logs", "armory.combined.log"), level: "http" }),
],
});
this.charsetCache = {};
this.errorNames = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
500: "Internal Server Error",
};
this.errorDescriptions = {
400: "Invalid request.",
404: "Sorry, we could not find what you were looking for.",
500: "An unexpected internal error has occurred. Please contact the site owner.",
};
}
public async start(): Promise<void> {
const app: Express = express();
const listenPort = 48733;
this.logger.info("Loading config...");
this.config = await Config.load(this.logger);
this.logger.info("Loading data files...");
if (this.config.loadDbcs) {
await this.dbc.loadAllFiles();
}
await this.characterCustomization.loadData();
this.logger.info("Connecting to databases...");
this.worldDb = createPool(this.config.worldDatabase);
for (const realm of this.config.realms) {
this.charsDbs[realm.name.toLowerCase()] = createPool(realm.charactersDatabase);
}
this.logger.info("Starting server...");
const locals = {
aowow: this.config.aowowUrl,
wowhead: this.config.wowheadUrl,
websiteUrl: this.config.websiteUrl,
websiteName: this.config.websiteName,
websiteRoot: this.config.websiteRoot,
iframeMode: this.config.iframeMode,
};
for (const key of Object.keys(locals)) {
app.locals[key] = locals[key];
}
app.locals.locals = locals;
app.engine(
".hbs",
handlebarsEngine({
extname: "hbs",
partialsDir: path.join(process.cwd(), "static", "partials"),
layoutsDir: path.join(process.cwd(), "static"),
defaultLayout: "layout.hbs",
helpers: {
// eslint-disable-next-line @typescript-eslint/no-var-requires
...require("handlebars-helpers")(),
eq: function(a: any, b: any) {
return a === b;
},
hasInProgressQuests: function(quests: any[]) {
return quests.some(quest => quest.status === 'In Progress');
},
getDiffClass: function(quest: any) {
if (!quest.char1Status && !quest.char2Status) return '';
if (!quest.char1Status) return 'quest-missing-1';
if (!quest.char2Status) return 'quest-missing-2';
if (quest.char1Status !== quest.char2Status) return 'quest-diff';
return '';
}
},
}),
);
app.set("view engine", "handlebars");
app.set("views", path.join(process.cwd(), "static"));
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
req.id = uuid.v4();
next();
});
morgan.token("id", (req: express.Request) => {
return req.id;
});
morgan.token("ip", (req: express.Request) => {
const forwardedFor = req.headers["x-forwarded-for"];
if (forwardedFor) {
if (typeof forwardedFor === "string") {
return forwardedFor;
}
return forwardedFor.join(", ");
}
return req.socket.remoteAddress;
});
app.use(
morgan(":method :url :status - ID :id - IP :ip - :response-time ms", {
stream: {
write: (msg) => this.logger.http(msg.trim()),
},
}),
);
app.use("/js", express.static("static/js"));
app.use("/css", express.static("static/css"));
app.use("/img", express.static("static/img"));
app.use("/data/mo3", express.static("data/mo3"));
app.use("/data/meta", express.static("data/meta"));
app.use("/data/bone", express.static("data/bone"));
app.use("/data/textures", express.static("data/textures"));
app.use("/data/background.png", express.static("data/modelviewer-background.png"));
const indexController = new IndexController(this);
app.get("/", this.wrapRoute(indexController.index.bind(indexController)));
app.get("/search", this.wrapRoute(indexController.search.bind(indexController)));
const charsController = new CharacterController(this);
await charsController.load();
app.get("/character/:realm/:name", this.wrapRoute(charsController.character.bind(charsController)));
app.get("/character/:realm/:name/talents", this.wrapRoute(charsController.talents.bind(charsController)));
app.get("/character/:realm/:name/skills", this.wrapRoute(charsController.skills.bind(charsController)));
app.get("/character/:realm/:name/achievements", this.wrapRoute(charsController.achievements.bind(charsController)));
app.get("/character/:realm/:character/achievements/data", this.wrapRoute(charsController.achievementsData.bind(charsController)));
app.get("/character/:realm/:name/pvp", this.wrapRoute(charsController.pvp.bind(charsController)));
app.get("/character/:realm/:name/reputation", this.wrapRoute(charsController.reputation.bind(charsController)));
app.get("/character/:realm/:name/quests", this.wrapRoute(charsController.quests.bind(charsController)));
app.get("/character/:realm/:name/quests/compare/:otherRealm/:otherName", this.wrapRoute(charsController.questsCompare.bind(charsController)));
const guildsController = new GuildController(this);
app.get("/guild/:realm/:name", this.wrapRoute(guildsController.guild.bind(guildsController)));
app.get("/guild/:realm/:guild/members", this.wrapRoute(guildsController.members.bind(guildsController)));
const arenaController = new ArenaController(this);
app.get("/arena", this.wrapRoute(arenaController.index.bind(arenaController)));
app.get("/arena/ladder", this.wrapRoute(arenaController.ladder.bind(arenaController)));
app.get("/arena/team/:realm/:name", this.wrapRoute(arenaController.team.bind(arenaController)));
app.use((err, req: express.Request, res: express.Response, next: express.NextFunction) => {
// Error handler
if (err instanceof Error) {
const contents = err.stack ?? `${err.name}: ${err.message}`;
this.logger.error(`Error on request ${req.id}. ${contents}`);
}
let status = 500;
if (typeof err === "number") {
status = err;
}
res.status(status).render("error.hbs", this.getErrorViewData(status, req));
});
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
// 404 handler
res.status(404);
// Respond with html page
if (req.accepts("html")) {
return res.render("error.hbs", this.getErrorViewData(404, req));
}
// Respond with json
if (req.accepts("json")) {
return res.json({ error: this.errorNames[404] });
}
// Default to plain-text
res.type("txt").send(this.errorNames[404]);
});
this.gc();
app.listen(listenPort, "0.0.0.0", () => {
this.logger.info(`Server is listening on 0.0.0.0:${listenPort}.`);
});
}
public getCharactersDb(realm: string): Pool {
return this.charsDbs[realm.toLowerCase()];
}
public getRealm(realm: string): IRealmConfig {
return this.config.realms.find((r) => r.name.toLowerCase() === realm.toLowerCase());
}
public async getDatabaseCharset(realm: string): Promise<string> {
const db = this.getCharactersDb(realm);
if (!(realm in this.charsetCache)) {
const [rows] = await db.query({
sql: `
SELECT CCSA.character_set_name AS charset FROM information_schema.\`TABLES\` T,
information_schema.\`COLLATION_CHARACTER_SET_APPLICABILITY\` CCSA
WHERE CCSA.collation_name = T.table_collation
AND T.table_schema = "${(await db.getConnection()).config.database}"
AND T.table_name = "characters"
`,
timeout: this.config.dbQueryTimeout,
});
this.charsetCache[realm] = rows[0].charset;
}
return this.charsetCache[realm];
}
public gc(): void {
if (this.config.loadDbcs) {
return;
}
setTimeout(() => {
if (global.gc) {
global.gc();
}
}, 500);
}
private wrapRoute(fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<void>) {
// Adds error handling for promise-based controller methods
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
await fn(req, res, next);
} catch (e) {
next(e);
}
};
}
private getErrorViewData(status: number, req: express.Request) {
return {
status,
name: this.errorNames[status] || "An error occurred",
description: this.errorDescriptions[status] || "",
reqId: req.id,
};
}
}