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

Dashboard work. Move configs to DB. Some script reorganization. Add nodemon configs.

This commit is contained in:
Dragory 2019-06-22 18:52:24 +03:00
parent 168d82a966
commit 2adc5af8d7
39 changed files with 8441 additions and 2915 deletions

8
dashboard/.babelrc Normal file
View file

@ -0,0 +1,8 @@
{
"plugins": [
["transform-runtime", {
"regenerator": true
}],
"transform-object-rest-spread"
]
}

3
dashboard/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/.cache
/dist
/node_modules

7668
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
dashboard/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "zeppelin-dashboard",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"build": "rimraf dist && parcel build src/index.html --out-dir dist",
"watch": "parcel src/index.html"
},
"devDependencies": {
"@vue/component-compiler-utils": "^3.0.0",
"babel-core": "^6.26.3",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0",
"parcel-bundler": "^1.12.3",
"vue-template-compiler": "^2.6.10"
},
"dependencies": {
"js-cookie": "^2.2.0",
"vue": "^2.6.10",
"vue-hot-reload-api": "^2.3.3",
"vue-router": "^3.0.6"
},
"browserslist": [
"last 2 Chrome versions"
]
}

53
dashboard/src/api.ts Normal file
View file

@ -0,0 +1,53 @@
const apiUrl = process.env.API_URL;
type QueryParamObject = { [key: string]: string | null };
function buildQueryString(params: QueryParamObject) {
if (Object.keys(params).length === 0) return "";
return (
"?" +
Array.from(Object.entries(params))
.map(pair => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1] || "")}`)
.join("&")
);
}
let apiKey = null;
export function setApiKey(newKey) {
apiKey = newKey;
}
export function hasApiKey() {
return apiKey != null;
}
export function resetApiKey() {
apiKey = null;
}
export function request(resource, fetchOpts: RequestInit = {}) {
return fetch(`${apiUrl}/${resource}`, fetchOpts)
.then(res => res.json())
.catch(err => {
console.log(err);
});
}
export function get(resource: string, params: QueryParamObject = {}) {
const headers: Record<string, string> = apiKey ? { "X-Api-Key": (apiKey as unknown) as string } : {};
return request(resource + buildQueryString(params), {
method: "GET",
headers,
});
}
export function post(resource: string, params: QueryParamObject = {}) {
const headers: Record<string, string> = apiKey ? { "X-Api-Key": (apiKey as unknown) as string } : {};
return request(resource + buildQueryString(params), {
method: "POST",
body: JSON.stringify(params),
headers: {
...headers,
"Content-Type": "application/json",
},
});
}

15
dashboard/src/auth.ts Normal file
View file

@ -0,0 +1,15 @@
import { NavigationGuard } from "vue-router";
import { RootStore } from "./store";
export const authGuard: NavigationGuard = async (to, from, next) => {
if (RootStore.state.auth.apiKey) return next(); // We have an API key -> authenticated
if (RootStore.state.auth.loadedInitialAuth) return next("/login"); // No API key and initial auth data was already loaded -> not authenticated
await RootStore.dispatch("auth/loadInitialAuth"); // Initial auth data wasn't loaded yet (per above check) -> load it now
if (RootStore.state.auth.apiKey) return next();
next("/login"); // Still no API key -> not authenticated
};
export const loginCallbackGuard: NavigationGuard = async (to, from, next) => {
await RootStore.dispatch("auth/setApiKey", to.query.apiKey);
next("/dashboard");
};

View file

@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

View file

@ -0,0 +1,36 @@
<template>
<div v-if="loading">
Loading...
</div>
<div v-else>
<h1>Guilds</h1>
<table v-for="guild in guilds">
<tr>
<td>{{ guild.id }}</td>
<td>{{ guild.name }}</td>
<td>
<a v-bind:href="'/dashboard/guilds/' + guild.id + '/config'">Config</a>
</td>
</tr>
</table>
</div>
</template>
<script>
import {mapGetters, mapState} from "vuex";
import {LoadStatus} from "../store/types";
export default {
mounted() {
this.$store.dispatch("guilds/loadAvailableGuilds");
},
computed: {
loading() {
return this.$state.guilds.availableGuildsLoadStatus !== LoadStatus.Done;
},
...mapState({
guilds: 'guilds/available',
}),
},
};
</script>

View file

@ -0,0 +1,11 @@
<template>
<p>Redirecting...</p>
</template>
<script>
export default {
created() {
this.$router.push('/login');
}
};
</script>

View file

@ -0,0 +1,14 @@
<template>
<a v-bind:href="env.API_URL + '/auth/login'">Login</a>
</template>
<script>
export default {
computed: {
blah() {
return this.$state.apiKey;
}
}
};
</script>

View file

@ -8,7 +8,8 @@
<title>Zeppelin Dashboard</title> <title>Zeppelin Dashboard</title>
</head> </head>
<body> <body>
<button id="login-button">Login</button> <div id="app"></div>
<script src="./index.js"></script>
<script src="./main.ts"></script>
</body> </body>
</html> </html>

31
dashboard/src/main.ts Normal file
View file

@ -0,0 +1,31 @@
import Vue from "vue";
import { RootStore } from "./store";
import { router } from "./routes";
get("/foo", { bar: "baz" });
// Set up a read-only global variable to access specific env vars
Vue.mixin({
data() {
return {
get env() {
return Object.freeze({
API_URL: process.env.API_URL,
});
},
};
},
});
import App from "./components/App.vue";
import Login from "./components/Login.vue";
import { get } from "./api";
const app = new Vue({
router,
store: RootStore,
el: "#app",
render(h) {
return h(App);
},
});

27
dashboard/src/routes.ts Normal file
View file

@ -0,0 +1,27 @@
import Vue from "vue";
import VueRouter, { RouteConfig } from "vue-router";
import Index from "./components/Index.vue";
import Login from "./components/Login.vue";
import LoginCallback from "./components/LoginCallback.vue";
import Dashboard from "./components/Dashboard.vue";
import store from "./store";
import { authGuard, loginCallbackGuard } from "./auth";
Vue.use(VueRouter);
const publicRoutes: RouteConfig[] = [
{ path: "/", component: Index },
{ path: "/login", component: Login },
{ path: "/login-callback", beforeEnter: loginCallbackGuard },
];
const authenticatedRoutes: RouteConfig[] = [{ path: "/dashboard", component: Dashboard }];
authenticatedRoutes.forEach(route => {
route.beforeEnter = authGuard;
});
export const router = new VueRouter({
mode: "history",
routes: [...publicRoutes, ...authenticatedRoutes],
});

View file

@ -0,0 +1,46 @@
import { get, hasApiKey, post, setApiKey } from "../api";
import { ActionTree, Module } from "vuex";
import { AuthState, RootState } from "./types";
export const AuthStore: Module<AuthState, RootState> = {
namespaced: true,
state: {
apiKey: null,
loadedInitialAuth: false,
},
actions: {
async loadInitialAuth({ dispatch, commit, state }) {
if (state.loadedInitialAuth) return;
const storedKey = localStorage.getItem("apiKey");
if (storedKey) {
console.log("key?", storedKey);
const result = await post("auth/validate-key", { key: storedKey });
if (result.isValid) {
await dispatch("setApiKey", storedKey);
} else {
localStorage.removeItem("apiKey");
}
}
commit("markInitialAuthLoaded");
},
setApiKey({ commit, state }, newKey: string) {
localStorage.setItem("apiKey", newKey);
commit("setApiKey", newKey);
},
},
mutations: {
setApiKey(state: AuthState, key) {
state.apiKey = key;
},
markInitialAuthLoaded(state: AuthState) {
state.loadedInitialAuth = true;
},
},
};

View file

@ -0,0 +1,34 @@
import { get } from "../api";
import { Module } from "vuex";
import { GuildState, LoadStatus, RootState } from "./types";
export const GuildStore: Module<GuildState, RootState> = {
namespaced: true,
state: {
availableGuildsLoadStatus: LoadStatus.None,
available: [],
configs: {},
},
actions: {
async loadAvailableGuilds({ dispatch, commit, state }) {
if (state.availableGuildsLoadStatus !== LoadStatus.None) return;
commit("setAvailableGuildsLoadStatus", LoadStatus.Loading);
const availableGuilds = await get("guilds/available");
commit("setAvailableGuilds", availableGuilds);
},
},
mutations: {
setAvailableGuildsLoadStatus(state: GuildState, status: LoadStatus) {
state.availableGuildsLoadStatus = status;
},
setAvailableGuilds(state: GuildState, guilds) {
state.available = guilds;
state.availableGuildsLoadStatus = LoadStatus.Done;
},
},
};

View file

@ -0,0 +1,28 @@
import Vue from "vue";
import Vuex, { Store } from "vuex";
Vue.use(Vuex);
import { RootState } from "./types";
import { AuthStore } from "./auth";
import { GuildStore } from "./guilds";
export const RootStore = new Vuex.Store<RootState>({
modules: {
auth: AuthStore,
guilds: GuildStore,
},
});
// Set up typings so Vue/our components know about the state's types
declare module "vue/types/options" {
interface ComponentOptions<V extends Vue> {
store?: Store<RootState>;
}
}
declare module "vue/types/vue" {
interface Vue {
$store: Store<RootState>;
}
}

View file

@ -0,0 +1,27 @@
export enum LoadStatus {
None = 1,
Loading,
Done,
}
export interface AuthState {
apiKey: string | null;
loadedInitialAuth: boolean;
}
export interface GuildState {
availableGuildsLoadStatus: LoadStatus;
available: Array<{
guild_id: string;
name: string;
icon: string | null;
}>;
configs: {
[key: string]: string;
};
}
export type RootState = {
auth: AuthState;
guilds: GuildState;
};

4
dashboard/ts-vue-shim.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}

28
dashboard/tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"moduleResolution": "node",
"module": "es2015",
"noImplicitAny": false,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "esnext",
"strict": true,
"lib": [
"es2017",
"esnext",
"dom"
],
"baseUrl": "./",
"resolveJsonModule": true,
"esModuleInterop": true,
"outDir": "./dist"
},
"include": [
"src/**/*.ts",
"src/**/*.vue"
],
"files": [
"ts-vue-shim.d.ts"
]
}

5
nodemon-api.json Normal file
View file

@ -0,0 +1,5 @@
{
"watch": "src",
"ext": "ts",
"exec": "ts-node ./src/api/index.ts"
}

View file

@ -1,5 +0,0 @@
{
"watch": "src/dashboard/api",
"ext": "ts",
"exec": "ts-node ./src/dashboard/api/index.ts"
}

2888
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,27 +4,26 @@
"description": "", "description": "",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest src",
"start-bot-dev": "ts-node src/index.ts", "start-bot-dev": "ts-node src/index.ts",
"start-bot-watch": "nodemon --config nodemon-bot.json",
"start-bot-prod": "cross-env NODE_ENV=production node dist/index.js", "start-bot-prod": "cross-env NODE_ENV=production node dist/index.js",
"format": "prettier --write \"./**/*.ts\"", "watch-bot": "nodemon --config nodemon-bot.json",
"typeorm": "ts-node ./node_modules/typeorm/cli.js",
"build": "rimraf dist && tsc", "build": "rimraf dist && tsc",
"build-dashboard-frontend": "rimraf dist-frontend && parcel build src/dashboard/frontend/index.html --out-dir dist-frontend", "start-api-dev": "ts-node src/api/index.ts",
"start-dashboard-frontend-dev": "parcel src/dashboard/frontend/index.html", "start-api-prod": "cross-env NODE_ENV=production node dist/api/index.js",
"start-dashboard-api-dev": "ts-node src/dashboard/server/index.ts", "watch-api": "nodemon --config nodemon-api.json",
"start-dashboard-api-watch": "nodemon --config nodemon-dashboard-api.json", "format": "prettier --write \"./src/**/*.ts\"",
"start-dashboard-api": "cross-env NODE_ENV=production node dist/dashboard/server/index.js", "typeorm": "ts-node ./node_modules/typeorm/cli.js",
"migrate": "npm run typeorm -- migration:run", "migrate": "npm run typeorm -- migration:run",
"migrate-rollback": "npm run typeorm -- migration:revert" "migrate-rollback": "npm run typeorm -- migration:revert",
"test": "jest src"
}, },
"dependencies": { "dependencies": {
"ajv": "^6.7.0", "ajv": "^6.7.0",
"cors": "^2.8.5",
"cross-env": "^5.2.0", "cross-env": "^5.2.0",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emoji-regex": "^7.0.1", "emoji-regex": "^7.0.1",
"eris": "github:abalabahaha/eris#dev", "eris": "^0.10.0",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
"express": "^4.17.0", "express": "^4.17.0",
"humanize-duration": "^3.15.0", "humanize-duration": "^3.15.0",
@ -48,12 +47,14 @@
"ts-node": "^3.3.0", "ts-node": "^3.3.0",
"typeorm": "^0.2.14", "typeorm": "^0.2.14",
"typescript": "^3.3.3333", "typescript": "^3.3.3333",
"uuid": "^3.3.2" "uuid": "^3.3.2",
"vuex": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.3.4", "@babel/core": "^7.3.4",
"@babel/preset-env": "^7.3.4", "@babel/preset-env": "^7.3.4",
"@babel/preset-typescript": "^7.3.3", "@babel/preset-typescript": "^7.3.3",
"@types/cors": "^2.8.5",
"@types/express": "^4.16.1", "@types/express": "^4.16.1",
"@types/jest": "^24.0.11", "@types/jest": "^24.0.11",
"@types/lodash.at": "^4.6.3", "@types/lodash.at": "^4.6.3",
@ -67,7 +68,6 @@
"jest": "^24.7.1", "jest": "^24.7.1",
"lint-staged": "^8.1.5", "lint-staged": "^8.1.5",
"nodemon": "^1.17.5", "nodemon": "^1.17.5",
"parcel-bundler": "^1.12.3",
"prettier": "^1.16.4", "prettier": "^1.16.4",
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"tslint": "^5.13.1", "tslint": "^5.13.1",

View file

@ -1,14 +1,14 @@
import express from "express"; import express, { Request, Response } from "express";
import passport from "passport"; import passport from "passport";
import OAuth2Strategy from "passport-oauth2"; import OAuth2Strategy from "passport-oauth2";
import CustomStrategy from "passport-custom"; import CustomStrategy from "passport-custom";
import { DashboardLogins, DashboardLoginUserData } from "../../data/DashboardLogins"; import { DashboardLogins, DashboardLoginUserData } from "../data/DashboardLogins";
import pick from "lodash.pick"; import pick from "lodash.pick";
import https from "https"; import https from "https";
const DISCORD_API_URL = "https://discordapp.com/api"; const DISCORD_API_URL = "https://discordapp.com/api";
function simpleAPIRequest(bearerToken, path): Promise<any> { function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = https.get( const request = https.get(
`${DISCORD_API_URL}/${path}`, `${DISCORD_API_URL}/${path}`,
@ -31,7 +31,7 @@ function simpleAPIRequest(bearerToken, path): Promise<any> {
}); });
} }
export default function initAuth(app: express.Express) { export function initAuth(app: express.Express) {
app.use(passport.initialize()); app.use(passport.initialize());
if (!process.env.CLIENT_ID) { if (!process.env.CLIENT_ID) {
@ -84,10 +84,11 @@ export default function initAuth(app: express.Express) {
scope: ["identify"], scope: ["identify"],
}, },
async (accessToken, refreshToken, profile, cb) => { async (accessToken, refreshToken, profile, cb) => {
const user = await simpleAPIRequest(accessToken, "users/@me"); const user = await simpleDiscordAPIRequest(accessToken, "users/@me");
const userData = pick(user, ["username", "discriminator", "avatar"]) as DashboardLoginUserData; const userData = pick(user, ["username", "discriminator", "avatar"]) as DashboardLoginUserData;
const apiKey = await dashboardLogins.addLogin(user.id, userData); const apiKey = await dashboardLogins.addLogin(user.id, userData);
// TODO: Revoke access token, we don't need it anymore // TODO: Revoke access token, we don't need it anymore
console.log("done, calling cb with", apiKey);
cb(null, { apiKey }); cb(null, { apiKey });
}, },
), ),
@ -98,7 +99,21 @@ export default function initAuth(app: express.Express) {
"/auth/oauth-callback", "/auth/oauth-callback",
passport.authenticate("oauth2", { failureRedirect: "/", session: false }), passport.authenticate("oauth2", { failureRedirect: "/", session: false }),
(req, res) => { (req, res) => {
console.log("redirecting to a non-existent page haHAA");
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`); res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`);
}, },
); );
app.post("/auth/validate-key", async (req: Request, res: Response) => {
const key = req.params.key || req.query.key;
if (!key) {
return res.status(400).json({ error: "No key supplied" });
}
const userId = await dashboardLogins.getUserIdByApiKey(key);
if (!userId) {
return res.status(403).json({ error: "Invalid key" });
}
res.json({ status: "ok" });
});
} }

15
src/api/guilds.ts Normal file
View file

@ -0,0 +1,15 @@
import express from "express";
import passport from "passport";
import { AllowedGuilds } from "../data/AllowedGuilds";
export function initGuildsAPI(app: express.Express) {
const guildAPIRouter = express.Router();
guildAPIRouter.use(passport.authenticate("api-token"));
const allowedGuilds = new AllowedGuilds();
guildAPIRouter.get("/guilds/available", async (req, res) => {
const guilds = await allowedGuilds.getForDashboardUser(req.user.userId);
res.end(guilds);
});
}

View file

@ -1,17 +1,27 @@
require("dotenv").config(); require("dotenv").config();
import express from "express"; import express from "express";
import initAuth from "./auth"; import cors from "cors";
import { connect } from "../../data/db"; import { initAuth } from "./auth";
import { initGuildsAPI } from "./guilds";
import { connect } from "../data/db";
console.log("Connecting to database..."); console.log("Connecting to database...");
connect().then(() => { connect().then(() => {
const app = express(); const app = express();
app.use(
cors({
origin: process.env.DASHBOARD_URL,
}),
);
app.use(express.json());
initAuth(app); initAuth(app);
initGuildsAPI(app);
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.end("Hi"); res.end({ status: "cookies" });
}); });
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;

View file

@ -1,4 +0,0 @@
const API_URL = process.env.API_URL;
document.getElementById('login-button').addEventListener('click', () => {
window.location.href = `${API_URL}/auth/login`;
});

41
src/data/AllowedGuilds.ts Normal file
View file

@ -0,0 +1,41 @@
import { AllowedGuild } from "./entities/AllowedGuild";
import {
getConnection,
getRepository,
Repository,
Transaction,
TransactionManager,
TransactionRepository,
} from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { BaseRepository } from "./BaseRepository";
export class AllowedGuilds extends BaseRepository {
private allowedGuilds: Repository<AllowedGuild>;
constructor() {
super();
this.allowedGuilds = getRepository(AllowedGuild);
}
async isAllowed(guildId) {
const count = await this.allowedGuilds.count({
where: {
guild_id: guildId,
},
});
return count !== 0;
}
getForDashboardUser(userId) {
return this.allowedGuilds
.createQueryBuilder("allowed_guilds")
.innerJoin(
"dashboard_users",
"dashboard_users",
"dashboard_users.guild_id = allowed_guilds.guild_id AND dashboard_users.user_id = :userId",
{ userId },
)
.getMany();
}
}

View file

@ -7,5 +7,5 @@ export enum CaseTypes {
Mute, Mute,
Unmute, Unmute,
Expunged, Expunged,
Softban Softban,
} }

49
src/data/Configs.ts Normal file
View file

@ -0,0 +1,49 @@
import { Config } from "./entities/Config";
import {
getConnection,
getRepository,
Repository,
Transaction,
TransactionManager,
TransactionRepository,
} from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { connection } from "./db";
import { BaseRepository } from "./BaseRepository";
export class Configs extends BaseRepository {
private configs: Repository<Config>;
constructor() {
super();
this.configs = getRepository(Config);
}
getActiveByKey(key) {
return this.configs.findOne({
where: {
key,
is_active: true,
},
});
}
async hasConfig(key) {
return (await this.getActiveByKey(key)) != null;
}
async saveNewRevision(key, config, editedBy) {
return connection.transaction(async entityManager => {
const repo = entityManager.getRepository(Config);
// Mark all old revisions inactive
await repo.update({ key }, { is_active: false });
// Add new, active revision
await repo.insert({
key,
config,
is_active: true,
edited_by: editedBy,
});
});
}
}

View file

@ -32,7 +32,7 @@ export class DashboardLogins extends BaseRepository {
const login = await this.dashboardLogins const login = await this.dashboardLogins
.createQueryBuilder() .createQueryBuilder()
.where("id = :id", { id: loginId }) .where("id = :id", { id: loginId })
.where("expires_at > NOW()") .andWhere("expires_at > NOW()")
.getOne(); .getOne();
if (!login) { if (!login) {
@ -40,7 +40,7 @@ export class DashboardLogins extends BaseRepository {
} }
const hash = crypto.createHash("sha256"); const hash = crypto.createHash("sha256");
hash.update(token); hash.update(loginId + token); // Remember to use loginId as the salt
const hashedToken = hash.digest("hex"); const hashedToken = hash.digest("hex");
if (hashedToken !== login.token) { if (hashedToken !== login.token) {
return null; return null;
@ -65,7 +65,7 @@ export class DashboardLogins extends BaseRepository {
// Generate token // Generate token
const token = uuidv4(); const token = uuidv4();
const hash = crypto.createHash("sha256"); const hash = crypto.createHash("sha256");
hash.update(token); hash.update(loginId + token); // Use loginId as a salt
const hashedToken = hash.digest("hex"); const hashedToken = hash.digest("hex");
// Save this to the DB // Save this to the DB

View file

@ -0,0 +1,6 @@
export enum DashboardRoles {
Viewer = 1,
Editor,
Manager,
ServerOwner,
}

View file

@ -201,7 +201,7 @@ export class GuildSavedMessages extends BaseGuildRepository {
const deleted = await this.messages const deleted = await this.messages
.createQueryBuilder() .createQueryBuilder()
.where("id IN (:ids)", { ids }) .where("id IN (:ids)", { ids })
.where("deleted_at = :deletedAt", { deletedAt }) .andWhere("deleted_at = :deletedAt", { deletedAt })
.getMany(); .getMany();
if (deleted.length) { if (deleted.length) {

View file

@ -0,0 +1,14 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
@Entity("allowed_guilds")
export class AllowedGuild {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
name: string;
@Column()
icon: string;
}

View file

@ -0,0 +1,23 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
@Entity("configs")
export class Config {
@Column()
@PrimaryColumn()
id: number;
@Column()
key: string;
@Column()
config: string;
@Column()
is_active: boolean;
@Column()
edited_by: string;
@Column()
edited_at: string;
}

View file

@ -10,6 +10,8 @@ import { SimpleError } from "./SimpleError";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
import DiscordHTTPError from "eris/lib/errors/DiscordHTTPError"; // tslint:disable-line import DiscordHTTPError from "eris/lib/errors/DiscordHTTPError"; // tslint:disable-line
import { Configs } from "./data/Configs";
require("dotenv").config(); require("dotenv").config();
// Error handling // Error handling
@ -71,8 +73,8 @@ import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
import { customArgumentTypes } from "./customArgumentTypes"; import { customArgumentTypes } from "./customArgumentTypes";
import { errorMessage, successMessage } from "./utils"; import { errorMessage, successMessage } from "./utils";
import { startUptimeCounter } from "./uptime"; import { startUptimeCounter } from "./uptime";
import { AllowedGuilds } from "./data/AllowedGuilds";
// Run latest database migrations
logger.info("Connecting to database"); logger.info("Connecting to database");
connect().then(async conn => { connect().then(async conn => {
const client = new Client(`Bot ${process.env.TOKEN}`, { const client = new Client(`Bot ${process.env.TOKEN}`, {
@ -87,18 +89,25 @@ connect().then(async conn => {
} }
}); });
const allowedGuilds = new AllowedGuilds();
const guildConfigs = new Configs();
const bot = new Knub(client, { const bot = new Knub(client, {
plugins: availablePlugins, plugins: availablePlugins,
globalPlugins: availableGlobalPlugins, globalPlugins: availableGlobalPlugins,
options: { options: {
canLoadGuild(guildId): Promise<boolean> {
return allowedGuilds.isAllowed(guildId);
},
/** /**
* Plugins are enabled if they... * Plugins are enabled if they...
* - are base plugins, i.e. always enabled, or * - are base plugins, i.e. always enabled, or
* - are dependencies of other enabled plugins, or * - are dependencies of other enabled plugins, or
* - are explicitly enabled in the guild config * - are explicitly enabled in the guild config
*/ */
getEnabledPlugins(guildId, guildConfig): string[] { async getEnabledPlugins(guildId, guildConfig): Promise<string[]> {
const configuredPlugins = guildConfig.plugins || {}; const configuredPlugins = guildConfig.plugins || {};
const pluginNames: string[] = Array.from(this.plugins.keys()); const pluginNames: string[] = Array.from(this.plugins.keys());
const plugins: Array<typeof Plugin> = Array.from(this.plugins.values()); const plugins: Array<typeof Plugin> = Array.from(this.plugins.values());
@ -125,22 +134,15 @@ connect().then(async conn => {
return Array.from(finalEnabledPlugins.values()); return Array.from(finalEnabledPlugins.values());
}, },
/**
* Loads the requested config file from the config dir
* TODO: Move to the database
*/
async getConfig(id) { async getConfig(id) {
const configFile = id ? `${id}.yml` : "global.yml"; const key = id === "global" ? "global" : `guild-${id}`;
const configPath = path.join("config", configFile); const row = await guildConfigs.getActiveByKey(key);
if (row) {
try { return yaml.safeLoad(row.config);
await fsp.access(configPath);
} catch (e) {
return {};
} }
const yamlString = await fsp.readFile(configPath, { encoding: "utf8" }); logger.warn(`No config with key "${key}"`);
return yaml.safeLoad(yamlString); return {};
}, },
logFn: (level, msg) => { logFn: (level, msg) => {

39
src/migrateConfigsToDB.ts Normal file
View file

@ -0,0 +1,39 @@
import { connect } from "./data/db";
import { Configs } from "./data/Configs";
import path from "path";
import * as _fs from "fs";
const fs = _fs.promises;
const authorId = process.argv[2];
if (!authorId) {
console.error("No author id specified");
process.exit(1);
}
console.log("Connecting to database");
connect().then(async () => {
const configs = new Configs();
console.log("Loading config files");
const configDir = path.join(__dirname, "..", "config");
const configFiles = await fs.readdir(configDir);
console.log("Looping through config files");
for (const configFile of configFiles) {
const parts = configFile.split(".");
const ext = parts[parts.length - 1];
if (ext !== "yml") continue;
const id = parts.slice(0, -1).join(".");
const key = id === "global" ? "global" : `guild-${id}`;
if (await configs.hasConfig(key)) continue;
const content = await fs.readFile(path.join(configDir, configFile), { encoding: "utf8" });
console.log(`Migrating config for ${key}`);
await configs.saveNewRevision(key, content, authorId);
}
console.log("Done!");
process.exit(0);
});

View file

@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
export class CreateConfigsTable1561111990357 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(
new Table({
name: "configs",
columns: [
{
name: "id",
type: "int",
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "key",
type: "varchar",
length: "48",
},
{
name: "config",
type: "mediumtext",
},
{
name: "is_active",
type: "tinyint",
},
{
name: "edited_by",
type: "bigint",
},
{
name: "edited_at",
type: "datetime",
default: "now()",
},
],
indices: [
{
columnNames: ["key", "is_active"],
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable("configs", true);
}
}

View file

@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner, Table } from "typeorm";
export class CreateAllowedGuildsTable1561117545258 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(
new Table({
name: "allowed_guilds",
columns: [
{
name: "guild_id",
type: "bigint",
isPrimary: true,
},
{
name: "name",
type: "varchar",
length: "255",
},
{
name: "icon",
type: "varchar",
length: "255",
collation: "ascii_general_ci",
isNullable: true,
},
{
name: "owner_id",
type: "bigint",
},
],
indices: [{ columnNames: ["owner_id"] }],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable("allowed_guilds", true);
}
}