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:
parent
80a82fe348
commit
16111bbe84
162 changed files with 11056 additions and 9900 deletions
34
backend/src/api/archives.ts
Normal file
34
backend/src/api/archives.ts
Normal 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
162
backend/src/api/auth.ts
Normal 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
90
backend/src/api/docs.ts
Normal 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
69
backend/src/api/guilds.ts
Normal 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
61
backend/src/api/index.ts
Normal 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}`));
|
||||
});
|
25
backend/src/api/responses.ts
Normal file
25
backend/src/api/responses.ts
Normal 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" });
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue