3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-10 12:25:02 +00:00

Reorganize project. Add folder for shared code between backend/dashboard. Switch from jest to ava for tests.

This commit is contained in:
Dragory 2019-11-02 22:11:26 +02:00
parent 80a82fe348
commit 16111bbe84
162 changed files with 11056 additions and 9900 deletions

View file

@ -0,0 +1,34 @@
import express, { Request, Response } from "express";
import { GuildArchives } from "../data/GuildArchives";
import { notFound } from "./responses";
import moment from "moment-timezone";
export function initArchives(app: express.Express) {
const archives = new GuildArchives(null);
// Legacy redirect
app.get("/spam-logs/:id", (req: Request, res: Response) => {
res.redirect("/archives/" + req.params.id);
});
app.get("/archives/:id", async (req: Request, res: Response) => {
const archive = await archives.find(req.params.id);
if (!archive) return notFound(res);
let body = archive.body;
// Add some metadata at the end of the log file (but only if it doesn't already have it directly in the body)
if (archive.body.indexOf("Log file generated on") === -1) {
const createdAt = moment(archive.created_at).format("YYYY-MM-DD [at] HH:mm:ss [(+00:00)]");
body += `\n\nLog file generated on ${createdAt}`;
if (archive.expires_at !== null) {
const expiresAt = moment(archive.expires_at).format("YYYY-MM-DD [at] HH:mm:ss [(+00:00)]");
body += `\nExpires at ${expiresAt}`;
}
}
res.setHeader("Content-Type", "text/plain; charset=UTF-8");
res.end(body);
});
}

162
backend/src/api/auth.ts Normal file
View file

@ -0,0 +1,162 @@
import express, { Request, Response } from "express";
import passport, { Strategy } from "passport";
import OAuth2Strategy from "passport-oauth2";
import { Strategy as CustomStrategy } from "passport-custom";
import { ApiLogins } from "../data/ApiLogins";
import pick from "lodash.pick";
import https from "https";
import { ApiUserInfo } from "../data/ApiUserInfo";
import { ApiUserInfoData } from "../data/entities/ApiUserInfo";
import { ApiPermissions } from "../data/ApiPermissions";
import { ok } from "./responses";
interface IPassportApiUser {
apiKey: string;
userId: number;
}
declare global {
namespace Express {
// tslint:disable-next-line:no-empty-interface
interface User extends IPassportApiUser {}
}
}
const DISCORD_API_URL = "https://discordapp.com/api";
function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
return new Promise((resolve, reject) => {
const request = https.get(
`${DISCORD_API_URL}/${path}`,
{
headers: {
Authorization: `Bearer ${bearerToken}`,
},
},
res => {
if (res.statusCode !== 200) {
reject(new Error(`Discord API error ${res.statusCode}`));
return;
}
res.on("data", data => resolve(JSON.parse(data)));
},
);
request.on("error", err => reject(err));
});
}
export function initAuth(app: express.Express) {
app.use(passport.initialize());
if (!process.env.CLIENT_ID) {
throw new Error("Auth: CLIENT ID missing");
}
if (!process.env.CLIENT_SECRET) {
throw new Error("Auth: CLIENT SECRET missing");
}
if (!process.env.OAUTH_CALLBACK_URL) {
throw new Error("Auth: OAUTH CALLBACK URL missing");
}
if (!process.env.DASHBOARD_URL) {
throw new Error("DASHBOARD_URL missing!");
}
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
const apiLogins = new ApiLogins();
const apiUserInfo = new ApiUserInfo();
const apiPermissions = new ApiPermissions();
// Initialize API tokens
passport.use(
"api-token",
new CustomStrategy(async (req, cb) => {
const apiKey = req.header("X-Api-Key");
if (!apiKey) return cb("API key missing");
const userId = await apiLogins.getUserIdByApiKey(apiKey);
if (userId) {
return cb(null, { apiKey, userId });
}
cb("API key not found");
}),
);
// Initialize OAuth2 for Discord login
// When the user logs in through OAuth2, we create them a "login" (= api token) and update their user info in the DB
passport.use(
new OAuth2Strategy(
{
authorizationURL: "https://discordapp.com/api/oauth2/authorize",
tokenURL: "https://discordapp.com/api/oauth2/token",
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: process.env.OAUTH_CALLBACK_URL,
scope: ["identify"],
},
async (accessToken, refreshToken, profile, cb) => {
const user = await simpleDiscordAPIRequest(accessToken, "users/@me");
// Make sure the user is able to access at least 1 guild
const permissions = await apiPermissions.getByUserId(user.id);
if (permissions.length === 0) {
cb(null, {});
return;
}
// Generate API key
const apiKey = await apiLogins.addLogin(user.id);
const userData = pick(user, ["username", "discriminator", "avatar"]) as ApiUserInfoData;
await apiUserInfo.update(user.id, userData);
// TODO: Revoke access token, we don't need it anymore
cb(null, { apiKey });
},
),
);
app.get("/auth/login", passport.authenticate("oauth2"));
app.get(
"/auth/oauth-callback",
passport.authenticate("oauth2", { failureRedirect: "/", session: false }),
(req: Request, res: Response) => {
if (req.user && req.user.apiKey) {
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`);
} else {
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?error=noAccess`);
}
},
);
app.post("/auth/validate-key", async (req: Request, res: Response) => {
const key = req.body.key;
if (!key) {
return res.status(400).json({ error: "No key supplied" });
}
const userId = await apiLogins.getUserIdByApiKey(key);
if (!userId) {
return res.json({ valid: false });
}
res.json({ valid: true });
});
app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {
await apiLogins.expireApiKey(req.user.apiKey);
return ok(res);
});
}
export function apiTokenAuthHandlers() {
return [
passport.authenticate("api-token", { failWithError: true }),
(err, req: Request, res: Response, next) => {
return res.json({ error: err.message });
},
];
}

90
backend/src/api/docs.ts Normal file
View file

@ -0,0 +1,90 @@
import express from "express";
import { availablePlugins } from "../plugins/availablePlugins";
import { ZeppelinPlugin } from "../plugins/ZeppelinPlugin";
import { notFound } from "./responses";
import { dropPropertiesByName, indentLines } from "../utils";
import { IPluginCommandConfig, Plugin, pluginUtils } from "knub";
import { parseParameters } from "knub-command-manager";
function formatConfigSchema(schema) {
if (schema._tag === "InterfaceType" || schema._tag === "PartialType") {
return (
`{\n` +
Object.entries(schema.props)
.map(([k, value]) => indentLines(`${k}: ${formatConfigSchema(value)}`, 2))
.join("\n") +
"\n}"
);
} else if (schema._tag === "DictionaryType") {
return "{\n" + indentLines(`[string]: ${formatConfigSchema(schema.codomain)}`, 2) + "\n}";
} else if (schema._tag === "ArrayType") {
return `Array<${formatConfigSchema(schema.type)}>`;
} else if (schema._tag === "UnionType") {
if (schema.name.startsWith("Nullable<")) {
return `Nullable<${formatConfigSchema(schema.types[0])}>`;
} else {
return schema.types.map(t => formatConfigSchema(t)).join(" | ");
}
} else if (schema._tag === "IntersectionType") {
return schema.types.map(t => formatConfigSchema(t)).join(" & ");
} else {
return schema.name;
}
}
export function initDocs(app: express.Express) {
const docsPlugins = availablePlugins.filter(pluginClass => pluginClass.showInDocs);
app.get("/docs/plugins", (req: express.Request, res: express.Response) => {
res.json(
docsPlugins.map(pluginClass => {
const thinInfo = pluginClass.pluginInfo ? { prettyName: pluginClass.pluginInfo.prettyName } : {};
return {
name: pluginClass.pluginName,
info: thinInfo,
};
}),
);
});
app.get("/docs/plugins/:pluginName", (req: express.Request, res: express.Response) => {
const pluginClass = docsPlugins.find(obj => obj.pluginName === req.params.pluginName);
if (!pluginClass) {
return notFound(res);
}
const decoratorCommands = pluginUtils.getPluginDecoratorCommands(pluginClass as typeof Plugin) || [];
const commands = decoratorCommands.map(cmd => {
const trigger = typeof cmd.trigger === "string" ? cmd.trigger : cmd.trigger.source;
const parameters = cmd.parameters
? typeof cmd.parameters === "string"
? parseParameters(cmd.parameters)
: cmd.parameters
: [];
const config: IPluginCommandConfig = cmd.config || {};
if (config.overloads) {
config.overloads = config.overloads.map(overload => {
return typeof overload === "string" ? parseParameters(overload) : overload;
});
}
return {
trigger,
parameters,
config,
};
});
const defaultOptions = (pluginClass as typeof ZeppelinPlugin).getStaticDefaultOptions();
const configSchema = pluginClass.configSchema && formatConfigSchema(pluginClass.configSchema);
res.json({
name: pluginClass.pluginName,
info: pluginClass.pluginInfo || {},
configSchema,
defaultOptions,
commands,
});
});
}

69
backend/src/api/guilds.ts Normal file
View file

@ -0,0 +1,69 @@
import express from "express";
import passport from "passport";
import { AllowedGuilds } from "../data/AllowedGuilds";
import { ApiPermissions } from "../data/ApiPermissions";
import { clientError, error, ok, serverError, unauthorized } from "./responses";
import { Configs } from "../data/Configs";
import { ApiRoles } from "../data/ApiRoles";
import { validateGuildConfig } from "../configValidator";
import yaml, { YAMLException } from "js-yaml";
import { apiTokenAuthHandlers } from "./auth";
export function initGuildsAPI(app: express.Express) {
const allowedGuilds = new AllowedGuilds();
const apiPermissions = new ApiPermissions();
const configs = new Configs();
app.get("/guilds/available", ...apiTokenAuthHandlers(), async (req, res) => {
const guilds = await allowedGuilds.getForApiUser(req.user.userId);
res.json(guilds);
});
app.get("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req, res) => {
const permissions = await apiPermissions.getByGuildAndUserId(req.params.guildId, req.user.userId);
if (!permissions) return unauthorized(res);
const config = await configs.getActiveByKey(`guild-${req.params.guildId}`);
res.json({ config: config ? config.config : "" });
});
app.post("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req, res) => {
const permissions = await apiPermissions.getByGuildAndUserId(req.params.guildId, req.user.userId);
if (!permissions || ApiRoles[permissions.role] < ApiRoles.Editor) return unauthorized(res);
let config = req.body.config;
if (config == null) return clientError(res, "No config supplied");
config = config.trim() + "\n"; // Normalize start/end whitespace in the config
const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`);
if (config === currentConfig.config) {
return ok(res);
}
// Validate config
let parsedConfig;
try {
parsedConfig = yaml.safeLoad(config);
} catch (e) {
if (e instanceof YAMLException) {
return res.status(400).json({ errors: [e.message] });
}
console.error("Error when loading YAML: " + e.message);
return serverError(res, "Server error");
}
if (parsedConfig == null) {
parsedConfig = {};
}
const errors = validateGuildConfig(parsedConfig);
if (errors) {
return res.status(422).json({ errors });
}
await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user.userId);
ok(res);
});
}

61
backend/src/api/index.ts Normal file
View file

@ -0,0 +1,61 @@
import { clientError, error, notFound } from "./responses";
import express from "express";
import cors from "cors";
import { initAuth } from "./auth";
import { initGuildsAPI } from "./guilds";
import { initArchives } from "./archives";
import { initDocs } from "./docs";
import { connect } from "../data/db";
import path from "path";
import { TokenError } from "passport-oauth2";
import { PluginError } from "knub";
require("dotenv").config({ path: path.resolve(__dirname, "..", "..", "api.env") });
function errorHandler(err) {
console.error(err.stack || err); // tslint:disable-line:no-console
process.exit(1);
}
process.on("unhandledRejection", errorHandler);
console.log("Connecting to database..."); // tslint:disable-line
connect().then(() => {
const app = express();
app.use(
cors({
origin: process.env.DASHBOARD_URL,
}),
);
app.use(express.json());
initAuth(app);
initGuildsAPI(app);
initArchives(app);
initDocs(app);
// Default route
app.get("/", (req, res) => {
res.json({ status: "cookies", with: "milk" });
});
// Error response
app.use((err, req, res, next) => {
if (err instanceof TokenError) {
clientError(res, "Invalid code");
} else {
console.error(err); // tslint:disable-line
error(res, "Server error", err.status || 500);
}
});
// 404 response
app.use((req, res, next) => {
return notFound(res);
});
const port = process.env.PORT || 3000;
// tslint:disable-next-line
app.listen(port, () => console.log(`API server listening on port ${port}`));
});

View file

@ -0,0 +1,25 @@
import { Response } from "express";
export function unauthorized(res: Response) {
res.status(403).json({ error: "Unauthorized" });
}
export function error(res: Response, message: string, statusCode: number = 500) {
res.status(statusCode).json({ error: message });
}
export function serverError(res: Response, message = "Server error") {
error(res, message, 500);
}
export function clientError(res: Response, message: string) {
error(res, message, 400);
}
export function notFound(res: Response) {
res.status(404).json({ error: "Not found" });
}
export function ok(res: Response) {
res.json({ result: "ok" });
}