diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 90bba8d5..0ca2145f 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -3,6 +3,19 @@ const apiUrl = process.env.API_URL; type QueryParamObject = { [key: string]: string | null }; +export class ApiError extends Error { + protected body: object; + protected status: number; + protected res: Response; + + constructor(message: string, body: object, status: number, res: Response) { + super(message); + this.body = body; + this.status = status; + this.res = res; + } +} + function buildQueryString(params: QueryParamObject) { if (Object.keys(params).length === 0) return ""; @@ -15,7 +28,14 @@ function buildQueryString(params: QueryParamObject) { } export function request(resource, fetchOpts: RequestInit = {}) { - return fetch(`${apiUrl}/${resource}`, fetchOpts).then(res => res.json()); + return fetch(`${apiUrl}/${resource}`, fetchOpts).then(async res => { + if (!res.ok) { + const body = await res.json(); + throw new ApiError(res.statusText, body, res.status, res); + } + + return res.json(); + }); } export function get(resource: string, params: QueryParamObject = {}) { @@ -32,7 +52,7 @@ export function post(resource: string, params: QueryParamObject = {}) { const headers: Record = RootStore.state.auth.apiKey ? { "X-Api-Key": RootStore.state.auth.apiKey } : {}; - return request(resource + buildQueryString(params), { + return request(resource, { method: "POST", body: JSON.stringify(params), headers: { diff --git a/dashboard/src/components/DashboardGuildConfigEditor.vue b/dashboard/src/components/DashboardGuildConfigEditor.vue index 96e8fb8a..4c080259 100644 --- a/dashboard/src/components/DashboardGuildConfigEditor.vue +++ b/dashboard/src/components/DashboardGuildConfigEditor.vue @@ -5,8 +5,17 @@

Config for {{ guild.name }}

- - Saving... +
@@ -26,6 +35,7 @@ import "codemirror/lib/codemirror.css"; import "codemirror/theme/oceanic-next.css"; import "codemirror/mode/yaml/yaml.js"; + import {ApiError} from "../api"; export default { components: { @@ -47,6 +57,7 @@ loading: true, saving: false, editableConfig: null, + errors: [], cmConfig: { indentWithTabs: false, indentUnit: 2, @@ -69,10 +80,23 @@ methods: { async save() { this.saving = true; - await this.$store.dispatch("guilds/saveConfig", { - guildId: this.$route.params.guildId, - config: this.editableConfig, - }); + this.errors = []; + + try { + await this.$store.dispatch("guilds/saveConfig", { + guildId: this.$route.params.guildId, + config: this.editableConfig, + }); + } catch (e) { + if (e instanceof ApiError && (e.status === 400 || e.status === 422)) { + this.errors = e.body.errors || ['Error while saving config']; + this.saving = false; + return; + } + + throw e; + } + this.$router.push("/dashboard"); }, }, diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index e3431bca..493413a6 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -19,7 +19,6 @@ Vue.mixin({ import App from "./components/App.vue"; import Login from "./components/Login.vue"; -import { get } from "./api"; const app = new Vue({ router, diff --git a/dashboard/src/store/auth.ts b/dashboard/src/store/auth.ts index a2757f18..dd00d335 100644 --- a/dashboard/src/store/auth.ts +++ b/dashboard/src/store/auth.ts @@ -1,4 +1,4 @@ -import { get, hasApiKey, post, setApiKey } from "../api"; +import { get, post } from "../api"; import { ActionTree, Module } from "vuex"; import { AuthState, RootState } from "./types"; @@ -16,13 +16,16 @@ export const AuthStore: Module = { const storedKey = localStorage.getItem("apiKey"); if (storedKey) { - const result = await post("auth/validate-key", { key: storedKey }); - if (result.valid) { - await dispatch("setApiKey", storedKey); - } else { - console.log("Unable to validate key, removing from localStorage"); - localStorage.removeItem("apiKey"); - } + try { + const result = await post("auth/validate-key", { key: storedKey }); + if (result.valid) { + await dispatch("setApiKey", storedKey); + return; + } + } catch (e) {} + + console.log("Unable to validate key, removing from localStorage"); + localStorage.removeItem("apiKey"); } commit("markInitialAuthLoaded"); diff --git a/package-lock.json b/package-lock.json index 57028168..d00a2faf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1904,6 +1904,12 @@ "integrity": "sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==", "dev": true }, + "@types/js-yaml": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.1.tgz", + "integrity": "sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA==", + "dev": true + }, "@types/lodash": { "version": "4.14.110", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.110.tgz", @@ -2057,6 +2063,7 @@ "version": "6.7.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.7.0.tgz", "integrity": "sha512-RZXPviBTtfmtka9n9sy1N5M5b82CbxWIR6HIis4s3WQTXDJamc/0gpCWNGz6EWdWp4DOfjzJfhz/AS9zVPjjWg==", + "dev": true, "requires": { "fast-deep-equal": "^2.0.1", "fast-json-stable-stringify": "^2.0.0", @@ -3860,12 +3867,14 @@ "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true }, "fast-json-stable-stringify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", - "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true }, "fast-levenshtein": { "version": "2.0.6", @@ -4002,6 +4011,11 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" }, + "fp-ts": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.0.1.tgz", + "integrity": "sha512-tw/L1f6hd29bYk3IlhA3qdnqz9LJ13yeecjk0XqC8g9E76P5ZXzNcjXA5zQ0zNlsGhdVhDIHRJB738oGpB8PnA==" + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -5057,6 +5071,11 @@ "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" }, + "io-ts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.0.0.tgz", + "integrity": "sha512-6i8PKyNR/dvEbUU9uE+v4iVFU7l674ZEGQsh92y6xEZF/rj46fXbPy+uPPXJEsCP0J0X3UpzXAxp04K4HR2jVw==" + }, "ipaddr.js": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", @@ -7771,7 +7790,8 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "json-stringify-safe": { "version": "5.0.1", @@ -9196,7 +9216,8 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true }, "qs": { "version": "6.5.2", @@ -10780,6 +10801,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, "requires": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index 9f80fad3..35fd1783 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "test": "jest src" }, "dependencies": { - "ajv": "^6.7.0", "cors": "^2.8.5", "cross-env": "^5.2.0", "dotenv": "^4.0.0", @@ -26,7 +25,9 @@ "eris": "^0.10.0", "escape-string-regexp": "^1.0.5", "express": "^4.17.0", + "fp-ts": "^2.0.1", "humanize-duration": "^3.15.0", + "io-ts": "^2.0.0", "js-yaml": "^3.13.1", "knub": "^20.1.0", "last-commit-log": "^2.1.0", @@ -57,6 +58,7 @@ "@types/cors": "^2.8.5", "@types/express": "^4.16.1", "@types/jest": "^24.0.11", + "@types/js-yaml": "^3.12.1", "@types/lodash.at": "^4.6.3", "@types/moment-timezone": "^0.5.6", "@types/node": "^10.12.0", diff --git a/src/api/guilds.ts b/src/api/guilds.ts index 72edcb7e..b84ac628 100644 --- a/src/api/guilds.ts +++ b/src/api/guilds.ts @@ -3,9 +3,11 @@ import passport from "passport"; import { AllowedGuilds } from "../data/AllowedGuilds"; import { requireAPIToken } from "./auth"; import { ApiPermissions } from "../data/ApiPermissions"; -import { clientError, ok, unauthorized } from "./responses"; +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"; export function initGuildsAPI(app: express.Express) { const guildAPIRouter = express.Router(); @@ -35,6 +37,28 @@ export function initGuildsAPI(app: express.Express) { const config = req.body.config; if (config == null) return clientError(res, "No config supplied"); + // Validate config + let parsedConfig; + try { + parsedConfig = yaml.safeLoad(config); + } catch (e) { + if (e instanceof YAMLException) { + return error(res, e.message, 400); + } + + 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); }); diff --git a/src/api/responses.ts b/src/api/responses.ts index 2a8b0a89..8f7f8a37 100644 --- a/src/api/responses.ts +++ b/src/api/responses.ts @@ -8,7 +8,7 @@ export function error(res: Response, message: string, statusCode: number = 500) res.status(statusCode).json({ error: message }); } -export function serverError(res: Response, message: string) { +export function serverError(res: Response, message = "Server error") { error(res, message, 500); } diff --git a/src/configValidator.ts b/src/configValidator.ts new file mode 100644 index 00000000..bd1aad7c --- /dev/null +++ b/src/configValidator.ts @@ -0,0 +1,51 @@ +import * as t from "io-ts"; +import { IPluginOptions } from "knub"; +import { pipe } from "fp-ts/lib/pipeable"; +import { fold } from "fp-ts/lib/Either"; +import { PathReporter } from "io-ts/lib/PathReporter"; +import { availablePlugins } from "./plugins/availablePlugins"; +import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; +import { validateStrict } from "./validatorUtils"; + +const pluginNameToClass = new Map(); +for (const pluginClass of availablePlugins) { + // @ts-ignore + pluginNameToClass.set(pluginClass.pluginName, pluginClass); +} + +const guildConfigRootSchema = t.type({ + prefix: t.string, + levels: t.record(t.string, t.number), + plugins: t.record(t.string, t.unknown), +}); +const partialGuildConfigRootSchema = t.exact(t.partial(guildConfigRootSchema.props)); + +const globalConfigRootSchema = t.type({ + url: t.string, + owners: t.array(t.string), + plugins: t.record(t.string, t.unknown), +}); + +const partialMegaTest = t.partial({ name: t.string }); + +export function validateGuildConfig(config: any): string[] | null { + const rootErrors = validateStrict(partialGuildConfigRootSchema, config); + if (rootErrors) return rootErrors; + + if (config.plugins) { + for (const [pluginName, pluginOptions] of Object.entries(config.plugins)) { + if (!pluginNameToClass.has(pluginName)) { + return [`Unknown plugin: ${pluginName}`]; + } + + const pluginClass = pluginNameToClass.get(pluginName); + let pluginErrors = pluginClass.validateOptions(pluginOptions); + if (pluginErrors) { + pluginErrors = pluginErrors.map(err => `${pluginName}: ${err}`); + return pluginErrors; + } + } + } + + return null; +} diff --git a/src/plugins/AutoReactionsPlugin.ts b/src/plugins/AutoReactionsPlugin.ts index a9a791f1..91ad1153 100644 --- a/src/plugins/AutoReactionsPlugin.ts +++ b/src/plugins/AutoReactionsPlugin.ts @@ -5,20 +5,23 @@ import { GuildAutoReactions } from "../data/GuildAutoReactions"; import { Message } from "eris"; import { customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import * as t from "io-ts"; -interface IAutoReactionsPluginConfig { - can_manage: boolean; -} +const configSchema = t.type({ + can_manage: t.boolean, +}); +type TConfigSchema = t.TypeOf; -export class AutoReactionsPlugin extends ZeppelinPlugin { +export class AutoReactionsPlugin extends ZeppelinPlugin { public static pluginName = "auto_reactions"; + protected static configSchema = configSchema; protected savedMessages: GuildSavedMessages; protected autoReactions: GuildAutoReactions; private onMessageCreateFn; - getDefaultOptions(): IPluginOptions { + getDefaultOptions(): IPluginOptions { return { config: { can_manage: false, diff --git a/src/plugins/GlobalZeppelinPlugin.ts b/src/plugins/GlobalZeppelinPlugin.ts new file mode 100644 index 00000000..cc182be0 --- /dev/null +++ b/src/plugins/GlobalZeppelinPlugin.ts @@ -0,0 +1,39 @@ +import { GlobalPlugin, IBasePluginConfig, IPluginOptions, logger } from "knub"; +import { PluginRuntimeError } from "../PluginRuntimeError"; +import * as t from "io-ts"; +import { pipe } from "fp-ts/lib/pipeable"; +import { fold } from "fp-ts/lib/Either"; +import { PathReporter } from "io-ts/lib/PathReporter"; +import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; +import { Member, User } from "eris"; +import { performance } from "perf_hooks"; +import { validateStrict } from "../validatorUtils"; + +const SLOW_RESOLVE_THRESHOLD = 1500; + +export class GlobalZeppelinPlugin extends GlobalPlugin { + protected static configSchema: t.TypeC; + public static dependencies = []; + + public static validateOptions(options: IPluginOptions): string[] | null { + // Validate config values + if (this.configSchema) { + if (options.config) { + const errors = validateStrict(this.configSchema, options.config); + if (errors) return errors; + } + + if (options.overrides) { + for (const override of options.overrides) { + if (override.config) { + const errors = validateStrict(this.configSchema, override.config); + if (errors) return errors; + } + } + } + } + + // No errors, return null + return null; + } +} diff --git a/src/plugins/MessageSaver.ts b/src/plugins/MessageSaver.ts index 24adafc3..78685a0a 100644 --- a/src/plugins/MessageSaver.ts +++ b/src/plugins/MessageSaver.ts @@ -2,12 +2,13 @@ import { Plugin, decorators as d, IPluginOptions } from "knub"; import { GuildChannel, Message, TextChannel } from "eris"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { successMessage } from "../utils"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; interface IMessageSaverPluginConfig { can_manage: boolean; } -export class MessageSaverPlugin extends Plugin { +export class MessageSaverPlugin extends ZeppelinPlugin { public static pluginName = "message_saver"; protected savedMessages: GuildSavedMessages; diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index e55ebac0..2e0b4a98 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -1,15 +1,18 @@ import { IBasePluginConfig, IPluginOptions, logger, Plugin } from "knub"; import { PluginRuntimeError } from "../PluginRuntimeError"; -import Ajv, { ErrorObject } from "ajv"; +import * as t from "io-ts"; +import { pipe } from "fp-ts/lib/pipeable"; +import { fold } from "fp-ts/lib/Either"; +import { PathReporter } from "io-ts/lib/PathReporter"; import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; import { Member, User } from "eris"; - import { performance } from "perf_hooks"; +import { validateStrict } from "../validatorUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; export class ZeppelinPlugin extends Plugin { - protected static configSchema: any; + protected static configSchema: t.TypeC; public static dependencies = []; protected throwPluginRuntimeError(message: string) { @@ -26,22 +29,19 @@ export class ZeppelinPlugin extends Plug return ourLevel > memberLevel; } - public static validateOptions(options: IPluginOptions): ErrorObject[] | null { + public static validateOptions(options: any): string[] | null { // Validate config values if (this.configSchema) { - const ajv = new Ajv(); - const validate = ajv.compile(this.configSchema); - if (options.config) { - const isValid = validate(options.config); - if (!isValid) return validate.errors; + const errors = validateStrict(this.configSchema, options.config); + if (errors) return errors; } if (options.overrides) { for (const override of options.overrides) { if (override.config) { - const isValid = validate(override.config); - if (!isValid) return validate.errors; + const errors = validateStrict(this.configSchema, override.config); + if (errors) return errors; } } } diff --git a/src/validatorUtils.ts b/src/validatorUtils.ts new file mode 100644 index 00000000..80714cbd --- /dev/null +++ b/src/validatorUtils.ts @@ -0,0 +1,27 @@ +import * as t from "io-ts"; +import { pipe } from "fp-ts/lib/pipeable"; +import { fold } from "fp-ts/lib/Either"; +import { PathReporter } from "io-ts/lib/PathReporter"; + +/** + * Validates the given value against the given schema while also disallowing extra properties + * See: https://github.com/gcanti/io-ts/issues/322 + */ +export function validateStrict(schema: t.Type, value: any): string[] | null { + const validationResult = schema.decode(value); + return pipe( + validationResult, + fold( + err => PathReporter.report(validationResult), + result => { + // Make sure there are no extra properties + if (JSON.stringify(value) !== JSON.stringify(result)) { + // TODO: Actually mention what the unknown property is + return ["Found unknown properties"]; + } + + return null; + }, + ), + ); +}