3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-03-15 05:41:51 +00:00

Switch from ajv to io-ts for config validation; validate configs on save in the API/dashboard; start work on creating io-ts schemas for all plugins

This commit is contained in:
Dragory 2019-07-11 12:23:57 +03:00
parent b230a73a6f
commit da114c0e60
14 changed files with 256 additions and 41 deletions

View file

@ -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<string, string> = 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: {

View file

@ -5,8 +5,17 @@
<div v-else>
<h2 class="title is-1">Config for {{ guild.name }}</h2>
<codemirror v-model="editableConfig" :options="cmConfig"></codemirror>
<button class="button" v-on:click="save" :disabled="saving">Save</button>
<span v-if="saving">Saving...</span>
<div class="editor-footer">
<div class="actions">
<button class="button" v-on:click="save" :disabled="saving">Save</button>
</div>
<div class="status">
<div class="status-message" v-if="saving">Saving...</div>
<div class="status-message error" v-if="errors.length">
<div v-for="error in errors">{{ error }}</div>
</div>
</div>
</div>
</div>
</template>
@ -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");
},
},

View file

@ -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,

View file

@ -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<AuthState, RootState> = {
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");

30
package-lock.json generated
View file

@ -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"
}

View file

@ -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",

View file

@ -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);
});

View file

@ -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);
}

51
src/configValidator.ts Normal file
View file

@ -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<string, typeof ZeppelinPlugin>();
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;
}

View file

@ -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<typeof configSchema>;
export class AutoReactionsPlugin extends ZeppelinPlugin<IAutoReactionsPluginConfig> {
export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "auto_reactions";
protected static configSchema = configSchema;
protected savedMessages: GuildSavedMessages;
protected autoReactions: GuildAutoReactions;
private onMessageCreateFn;
getDefaultOptions(): IPluginOptions<IAutoReactionsPluginConfig> {
getDefaultOptions(): IPluginOptions<TConfigSchema> {
return {
config: {
can_manage: false,

View file

@ -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<TConfig extends {} = IBasePluginConfig> extends GlobalPlugin<TConfig> {
protected static configSchema: t.TypeC<any>;
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;
}
}

View file

@ -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<IMessageSaverPluginConfig> {
export class MessageSaverPlugin extends ZeppelinPlugin<IMessageSaverPluginConfig> {
public static pluginName = "message_saver";
protected savedMessages: GuildSavedMessages;

View file

@ -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<TConfig extends {} = IBasePluginConfig> extends Plugin<TConfig> {
protected static configSchema: any;
protected static configSchema: t.TypeC<any>;
public static dependencies = [];
protected throwPluginRuntimeError(message: string) {
@ -26,22 +29,19 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> 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;
}
}
}

27
src/validatorUtils.ts Normal file
View file

@ -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<any, any, any>, 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;
},
),
);
}