From b230a73a6f1ac64c25198bb8cbaea1c19fb34761 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 23 Jun 2019 19:18:41 +0300 Subject: [PATCH] Dashboard work and related --- dashboard/.editorconfig | 7 ++ dashboard/package-lock.json | 19 ++++ dashboard/package.json | 3 + dashboard/src/auth.ts | 20 +++- dashboard/src/components/App.vue | 3 + dashboard/src/components/Dashboard.vue | 70 +++++++------ ...tor.vue => DashboardGuildConfigEditor.vue} | 22 +++-- .../src/components/DashboardGuildList.vue | 61 ++++++++++++ dashboard/src/components/Index.vue | 11 --- dashboard/src/components/Login.vue | 14 --- dashboard/src/components/Splash.vue | 19 ++++ dashboard/src/img/logo.png | Bin 0 -> 26908 bytes dashboard/src/main.ts | 2 + dashboard/src/routes.ts | 45 +++++---- dashboard/src/style/base.scss | 6 ++ dashboard/src/style/dark-bulma-variables.scss | 0 dashboard/src/style/dashboard.scss | 5 + dashboard/src/style/splash.scss | 72 ++++++++++++++ src/api/archives.ts | 34 +++++++ src/api/auth.ts | 18 ++-- src/api/guilds.ts | 16 +-- src/api/index.ts | 20 +++- src/api/responses.ts | 14 ++- src/data/AllowedGuilds.ts | 14 ++- src/data/{DashboardLogins.ts => ApiLogins.ts} | 23 ++--- .../{DashboardUsers.ts => ApiPermissions.ts} | 10 +- src/data/{DashboardRoles.ts => ApiRoles.ts} | 2 +- src/data/ApiUserInfo.ts | 38 ++++++++ src/data/Configs.ts | 12 +++ src/data/entities/AllowedGuild.ts | 2 +- src/data/entities/ApiLogin.ts | 25 +++++ src/data/entities/ApiPermission.ts | 20 ++++ src/data/entities/ApiUserInfo.ts | 28 ++++++ src/data/entities/Config.ts | 7 +- src/data/entities/DashboardLogin.ts | 24 ----- src/data/entities/DashboardUser.ts | 18 ---- ...151982-RenameBackendDashboardStuffToAPI.ts | 13 +++ ...282552734-RenameAllowedGuildGuildIdToId.ts | 11 +++ .../1561282950483-CreateApiUserInfoTable.ts | 31 ++++++ ...83165823-RenameApiUsersToApiPermissions.ts | 11 +++ ...01-DropUserDataFromLoginsAndPermissions.ts | 17 ++++ src/plugins/GuildInfoSaver.ts | 24 +++++ src/plugins/LogServer.ts | 92 ------------------ src/plugins/availablePlugins.ts | 6 +- 44 files changed, 637 insertions(+), 272 deletions(-) create mode 100644 dashboard/.editorconfig rename dashboard/src/components/{GuildConfigEditor.vue => DashboardGuildConfigEditor.vue} (79%) create mode 100644 dashboard/src/components/DashboardGuildList.vue delete mode 100644 dashboard/src/components/Index.vue delete mode 100644 dashboard/src/components/Login.vue create mode 100644 dashboard/src/components/Splash.vue create mode 100644 dashboard/src/img/logo.png create mode 100644 dashboard/src/style/base.scss create mode 100644 dashboard/src/style/dark-bulma-variables.scss create mode 100644 dashboard/src/style/dashboard.scss create mode 100644 dashboard/src/style/splash.scss create mode 100644 src/api/archives.ts rename src/data/{DashboardLogins.ts => ApiLogins.ts} (73%) rename src/data/{DashboardUsers.ts => ApiPermissions.ts} (51%) rename src/data/{DashboardRoles.ts => ApiRoles.ts} (64%) create mode 100644 src/data/ApiUserInfo.ts create mode 100644 src/data/entities/ApiLogin.ts create mode 100644 src/data/entities/ApiPermission.ts create mode 100644 src/data/entities/ApiUserInfo.ts delete mode 100644 src/data/entities/DashboardLogin.ts delete mode 100644 src/data/entities/DashboardUser.ts create mode 100644 src/migrations/1561282151982-RenameBackendDashboardStuffToAPI.ts create mode 100644 src/migrations/1561282552734-RenameAllowedGuildGuildIdToId.ts create mode 100644 src/migrations/1561282950483-CreateApiUserInfoTable.ts create mode 100644 src/migrations/1561283165823-RenameApiUsersToApiPermissions.ts create mode 100644 src/migrations/1561283405201-DropUserDataFromLoginsAndPermissions.ts create mode 100644 src/plugins/GuildInfoSaver.ts delete mode 100644 src/plugins/LogServer.ts diff --git a/dashboard/.editorconfig b/dashboard/.editorconfig new file mode 100644 index 00000000..b3dfee7a --- /dev/null +++ b/dashboard/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 84ca0758..9181fa9e 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -2020,6 +2020,16 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", "dev": true }, + "bulma": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.7.5.tgz", + "integrity": "sha512-cX98TIn0I6sKba/DhW0FBjtaDpxTelU166pf7ICXpCCuplHWyu6C9LYZmL5PEsnePIeJaiorsTEzzNk3Tsm1hw==" + }, + "bulmaswatch": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/bulmaswatch/-/bulmaswatch-0.7.2.tgz", + "integrity": "sha512-qickVpky/vv/PX/G7DVz1UpYPncJIjoxbovVELf48tgZ7Fd8UAzfWLSzniPWHwPt1YAq/UX9CGTtcl0AbxSMYg==" + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -6551,6 +6561,15 @@ "clones": "^1.2.0" } }, + "sass": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.21.0.tgz", + "integrity": "sha512-67hIIOZZtarbhI2aSgKBPDUgn+VqetduKoD+ZSYeIWg+ksNioTzeX+R2gUdebDoolvKNsQ/GY9NDxctbXluTNA==", + "dev": true, + "requires": { + "chokidar": "^2.0.0" + } + }, "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 1677ccf6..01fa35cf 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -13,9 +13,12 @@ "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-runtime": "^6.23.0", "parcel-bundler": "^1.12.3", + "sass": "^1.21.0", "vue-template-compiler": "^2.6.10" }, "dependencies": { + "bulma": "^0.7.5", + "bulmaswatch": "^0.7.2", "js-cookie": "^2.2.0", "vue": "^2.6.10", "vue-codemirror": "^4.0.6", diff --git a/dashboard/src/auth.ts b/dashboard/src/auth.ts index f5293927..4b934970 100644 --- a/dashboard/src/auth.ts +++ b/dashboard/src/auth.ts @@ -1,15 +1,25 @@ 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 +const isAuthenticated = async () => { + if (RootStore.state.auth.apiKey) return true; // We have an API key -> authenticated + if (RootStore.state.auth.loadedInitialAuth) return false; // 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 + if (RootStore.state.auth.apiKey) return true; + return false; // Still no API key -> not authenticated +}; + +export const authGuard: NavigationGuard = async (to, from, next) => { + if (await isAuthenticated()) return next(); + next("/"); }; export const loginCallbackGuard: NavigationGuard = async (to, from, next) => { await RootStore.dispatch("auth/setApiKey", to.query.apiKey); next("/dashboard"); }; + +export const authRedirectGuard: NavigationGuard = async (to, form, next) => { + if (await isAuthenticated()) return next("/dashboard"); + window.location.href = `${process.env.API_URL}/auth/login`; +}; diff --git a/dashboard/src/components/App.vue b/dashboard/src/components/App.vue index 497d4700..c208cf2f 100644 --- a/dashboard/src/components/App.vue +++ b/dashboard/src/components/App.vue @@ -1,3 +1,6 @@ + + diff --git a/dashboard/src/components/Dashboard.vue b/dashboard/src/components/Dashboard.vue index 3587d0fe..e610668c 100644 --- a/dashboard/src/components/Dashboard.vue +++ b/dashboard/src/components/Dashboard.vue @@ -1,36 +1,46 @@ - diff --git a/dashboard/src/components/GuildConfigEditor.vue b/dashboard/src/components/DashboardGuildConfigEditor.vue similarity index 79% rename from dashboard/src/components/GuildConfigEditor.vue rename to dashboard/src/components/DashboardGuildConfigEditor.vue index 4ace825c..96e8fb8a 100644 --- a/dashboard/src/components/GuildConfigEditor.vue +++ b/dashboard/src/components/DashboardGuildConfigEditor.vue @@ -3,14 +3,23 @@ Loading...
-

Config for {{ guild.name }}

+

Config for {{ guild.name }}

- + Saving... - Saved!
+ + diff --git a/dashboard/src/components/Index.vue b/dashboard/src/components/Index.vue deleted file mode 100644 index b0b76e84..00000000 --- a/dashboard/src/components/Index.vue +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/dashboard/src/components/Login.vue b/dashboard/src/components/Login.vue deleted file mode 100644 index 947583b9..00000000 --- a/dashboard/src/components/Login.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/dashboard/src/components/Splash.vue b/dashboard/src/components/Splash.vue new file mode 100644 index 00000000..51a07a78 --- /dev/null +++ b/dashboard/src/components/Splash.vue @@ -0,0 +1,19 @@ + + + diff --git a/dashboard/src/img/logo.png b/dashboard/src/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7c80b9fbf008ca85a9fb695af973fe1ffc14a8d5 GIT binary patch literal 26908 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 E2c$r^5C8xG literal 0 HcmV?d00001 diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index fd08c979..e3431bca 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -1,3 +1,5 @@ +import "./style/base.scss"; + import Vue from "vue"; import { RootStore } from "./store"; import { router } from "./routes"; diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index e203ed57..7157c49d 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -1,30 +1,37 @@ import Vue from "vue"; import VueRouter, { RouteConfig } from "vue-router"; -import Index from "./components/Index.vue"; +import Splash from "./components/Splash.vue"; import Login from "./components/Login.vue"; import LoginCallback from "./components/LoginCallback.vue"; -import GuildConfigEditor from "./components/GuildConfigEditor.vue"; +import DashboardGuildList from "./components/DashboardGuildList.vue"; +import DashboardGuildConfigEditor from "./components/DashboardGuildConfigEditor.vue"; import Dashboard from "./components/Dashboard.vue"; -import { authGuard, loginCallbackGuard } from "./auth"; +import { authGuard, authRedirectGuard, 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 }, - { path: "/dashboard/guilds/:guildId/config", component: GuildConfigEditor }, -]; - -authenticatedRoutes.forEach(route => { - route.beforeEnter = authGuard; -}); - export const router = new VueRouter({ mode: "history", - routes: [...publicRoutes, ...authenticatedRoutes], + routes: [ + { path: "/", component: Splash }, + { path: "/login", beforeEnter: authRedirectGuard }, + { path: "/login-callback", beforeEnter: loginCallbackGuard }, + + // Dashboard + { + path: "/dashboard", + component: Dashboard, + beforeEnter: authGuard, + children: [ + { + path: "", + component: DashboardGuildList, + }, + { + path: "guilds/:guildId/config", + component: DashboardGuildConfigEditor, + }, + ], + }, + ], }); diff --git a/dashboard/src/style/base.scss b/dashboard/src/style/base.scss new file mode 100644 index 00000000..c40f12c3 --- /dev/null +++ b/dashboard/src/style/base.scss @@ -0,0 +1,6 @@ +@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400,600&display=swap'); +@import "~bulma/sass/base/minireset"; + +body { + font: normal 16px/1.4 'Open Sans', sans-serif; +} diff --git a/dashboard/src/style/dark-bulma-variables.scss b/dashboard/src/style/dark-bulma-variables.scss new file mode 100644 index 00000000..e69de29b diff --git a/dashboard/src/style/dashboard.scss b/dashboard/src/style/dashboard.scss new file mode 100644 index 00000000..30219c94 --- /dev/null +++ b/dashboard/src/style/dashboard.scss @@ -0,0 +1,5 @@ +$family-primary: 'Open Sans', sans-serif; + +@import "~bulmaswatch/superhero/_variables"; +@import "~bulma/bulma"; +@import "~bulmaswatch/superhero/_overrides"; diff --git a/dashboard/src/style/splash.scss b/dashboard/src/style/splash.scss new file mode 100644 index 00000000..0d5cc145 --- /dev/null +++ b/dashboard/src/style/splash.scss @@ -0,0 +1,72 @@ +.splash { + width: 100vw; + height: 100vh; + + background-color: #7289da; + background-image: linear-gradient(225deg, #7289da 0%, #5d70b4 100%); + color: #fff; + + display: flex; + flex-direction: column; + align-items: center; + + a { + color: #fff; + } + + .wrapper { + display: grid; + grid-template-columns: auto 400px; + grid-template-rows: auto repeat(4, 1fr); + align-items: start; + + .logo { + grid-column: 1; + grid-row: 1/-1; // Span all + + width: 300px; + height: 300px; + margin-right: 64px; + } + + h1 { + grid-column: 2; + + font-size: 80px; + font-weight: 300; + margin-top: 40px + } + + .description { + grid-column: 2; + + color: #f1f5ff; + } + + .actions { + grid-column: 2; + + display: flex; + + .btn { + margin: 12px; + text-decoration: none; + padding: 8px 24px; + border: 1px solid #fff; + border-radius: 4px; + transition: all 120ms ease-in-out; + background-color: hsla(0, 0%, 100%, 0.05); + + &:not(.disabled):hover { + background-color: hsla(0, 0%, 100%, 0.25); + box-shadow: 0 3px 12px -2px hsla(0, 0%, 0%, 0.2); + } + + &.disabled { + cursor: default; + opacity: 0.75; + } + } + } + } +} diff --git a/src/api/archives.ts b/src/api/archives.ts new file mode 100644 index 00000000..0193ca9a --- /dev/null +++ b/src/api/archives.ts @@ -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); + }); +} diff --git a/src/api/auth.ts b/src/api/auth.ts index e960998d..b4231462 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -2,9 +2,11 @@ import express, { Request, Response } from "express"; import passport from "passport"; import OAuth2Strategy from "passport-oauth2"; import CustomStrategy from "passport-custom"; -import { DashboardLogins, DashboardLoginUserData } from "../data/DashboardLogins"; +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"; const DISCORD_API_URL = "https://discordapp.com/api"; @@ -53,7 +55,8 @@ export function initAuth(app: express.Express) { passport.serializeUser((user, done) => done(null, user)); passport.deserializeUser((user, done) => done(null, user)); - const dashboardLogins = new DashboardLogins(); + const apiLogins = new ApiLogins(); + const apiUserInfo = new ApiUserInfo(); // Initialize API tokens passport.use( @@ -62,7 +65,7 @@ export function initAuth(app: express.Express) { const apiKey = req.header("X-Api-Key"); if (!apiKey) return cb(); - const userId = await dashboardLogins.getUserIdByApiKey(apiKey); + const userId = await apiLogins.getUserIdByApiKey(apiKey); if (userId) { return cb(null, { userId }); } @@ -72,6 +75,7 @@ export function initAuth(app: express.Express) { ); // 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( { @@ -84,10 +88,10 @@ export function initAuth(app: express.Express) { }, async (accessToken, refreshToken, profile, cb) => { const user = await simpleDiscordAPIRequest(accessToken, "users/@me"); - const userData = pick(user, ["username", "discriminator", "avatar"]) as DashboardLoginUserData; - const apiKey = await dashboardLogins.addLogin(user.id, userData); + 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 - console.log("done, calling cb with", apiKey); cb(null, { apiKey }); }, ), @@ -108,7 +112,7 @@ export function initAuth(app: express.Express) { return res.status(400).json({ error: "No key supplied" }); } - const userId = await dashboardLogins.getUserIdByApiKey(key); + const userId = await apiLogins.getUserIdByApiKey(key); if (!userId) { return res.json({ valid: false }); } diff --git a/src/api/guilds.ts b/src/api/guilds.ts index f51d6094..72edcb7e 100644 --- a/src/api/guilds.ts +++ b/src/api/guilds.ts @@ -2,35 +2,35 @@ import express from "express"; import passport from "passport"; import { AllowedGuilds } from "../data/AllowedGuilds"; import { requireAPIToken } from "./auth"; -import { DashboardUsers } from "../data/DashboardUsers"; +import { ApiPermissions } from "../data/ApiPermissions"; import { clientError, ok, unauthorized } from "./responses"; import { Configs } from "../data/Configs"; -import { DashboardRoles } from "../data/DashboardRoles"; +import { ApiRoles } from "../data/ApiRoles"; export function initGuildsAPI(app: express.Express) { const guildAPIRouter = express.Router(); requireAPIToken(guildAPIRouter); const allowedGuilds = new AllowedGuilds(); - const dashboardUsers = new DashboardUsers(); + const apiPermissions = new ApiPermissions(); const configs = new Configs(); guildAPIRouter.get("/guilds/available", async (req, res) => { - const guilds = await allowedGuilds.getForDashboardUser(req.user.userId); + const guilds = await allowedGuilds.getForApiUser(req.user.userId); res.json(guilds); }); guildAPIRouter.get("/guilds/:guildId/config", async (req, res) => { - const dbUser = await dashboardUsers.getByGuildAndUserId(req.params.guildId, req.user.userId); - if (!dbUser) return unauthorized(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 : "" }); }); guildAPIRouter.post("/guilds/:guildId/config", async (req, res) => { - const dbUser = await dashboardUsers.getByGuildAndUserId(req.params.guildId, req.user.userId); - if (!dbUser || DashboardRoles[dbUser.role] < DashboardRoles.Editor) return unauthorized(res); + const permissions = await apiPermissions.getByGuildAndUserId(req.params.guildId, req.user.userId); + if (!permissions || ApiRoles[permissions.role] < ApiRoles.Editor) return unauthorized(res); const config = req.body.config; if (config == null) return clientError(res, "No config supplied"); diff --git a/src/api/index.ts b/src/api/index.ts index eaac13c9..c2de62b3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,9 +1,12 @@ +import { error, notFound } from "./responses"; + require("dotenv").config(); import express from "express"; import cors from "cors"; import { initAuth } from "./auth"; import { initGuildsAPI } from "./guilds"; +import { initArchives } from "./archives"; import { connect } from "../data/db"; console.log("Connecting to database..."); @@ -19,16 +22,23 @@ connect().then(() => { initAuth(app); initGuildsAPI(app); + initArchives(app); - app.use((err, req, res, next) => { - res.status(err.status || 500); - res.json({ error: err.message }); - }); - + // Default route app.get("/", (req, res) => { res.end({ status: "cookies" }); }); + // Error response + app.use((err, req, res, next) => { + error(res, err.message, err.status || 500); + }); + + // 404 response + app.use((req, res, next) => { + return notFound(res); + }); + const port = process.env.PORT || 3000; app.listen(port, () => console.log(`API server listening on port ${port}`)); }); diff --git a/src/api/responses.ts b/src/api/responses.ts index 976f3a15..2a8b0a89 100644 --- a/src/api/responses.ts +++ b/src/api/responses.ts @@ -4,8 +4,20 @@ 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: string) { + error(res, message, 500); +} + export function clientError(res: Response, message: string) { - res.status(400).json({ error: message }); + error(res, message, 400); +} + +export function notFound(res: Response) { + res.status(404).json({ error: "Not found" }); } export function ok(res: Response) { diff --git a/src/data/AllowedGuilds.ts b/src/data/AllowedGuilds.ts index 248c0132..78a701f6 100644 --- a/src/data/AllowedGuilds.ts +++ b/src/data/AllowedGuilds.ts @@ -21,21 +21,25 @@ export class AllowedGuilds extends BaseRepository { async isAllowed(guildId) { const count = await this.allowedGuilds.count({ where: { - guild_id: guildId, + id: guildId, }, }); return count !== 0; } - getForDashboardUser(userId) { + getForApiUser(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", + "api_permissions", + "api_permissions", + "api_permissions.guild_id = allowed_guilds.id AND api_permissions.user_id = :userId", { userId }, ) .getMany(); } + + updateInfo(id, name, icon) { + return this.allowedGuilds.update({ id }, { name, icon }); + } } diff --git a/src/data/DashboardLogins.ts b/src/data/ApiLogins.ts similarity index 73% rename from src/data/DashboardLogins.ts rename to src/data/ApiLogins.ts index ff7597e5..f9c2edfa 100644 --- a/src/data/DashboardLogins.ts +++ b/src/data/ApiLogins.ts @@ -1,5 +1,5 @@ import { getRepository, Repository } from "typeorm"; -import { DashboardLogin } from "./entities/DashboardLogin"; +import { ApiLogin } from "./entities/ApiLogin"; import { BaseRepository } from "./BaseRepository"; import crypto from "crypto"; import moment from "moment-timezone"; @@ -9,18 +9,12 @@ import uuidv4 from "uuid/v4"; import { DBDateFormat } from "../utils"; import { log } from "util"; -export interface DashboardLoginUserData { - username: string; - discriminator: string; - avatar: string; -} - -export class DashboardLogins extends BaseRepository { - private dashboardLogins: Repository; +export class ApiLogins extends BaseRepository { + private apiLogins: Repository; constructor() { super(); - this.dashboardLogins = getRepository(DashboardLogin); + this.apiLogins = getRepository(ApiLogin); } async getUserIdByApiKey(apiKey: string): Promise { @@ -29,7 +23,7 @@ export class DashboardLogins extends BaseRepository { return null; } - const login = await this.dashboardLogins + const login = await this.apiLogins .createQueryBuilder() .where("id = :id", { id: loginId }) .andWhere("expires_at > NOW()") @@ -49,12 +43,12 @@ export class DashboardLogins extends BaseRepository { return login.user_id; } - async addLogin(userId: string, userData: DashboardLoginUserData): Promise { + async addLogin(userId: string): Promise { // Generate random login id let loginId; while (true) { loginId = uuidv4(); - const existing = await this.dashboardLogins.findOne({ + const existing = await this.apiLogins.findOne({ where: { id: loginId, }, @@ -69,11 +63,10 @@ export class DashboardLogins extends BaseRepository { const hashedToken = hash.digest("hex"); // Save this to the DB - await this.dashboardLogins.insert({ + await this.apiLogins.insert({ id: loginId, token: hashedToken, user_id: userId, - user_data: userData, logged_in_at: moment().format(DBDateFormat), expires_at: moment() .add(1, "day") diff --git a/src/data/DashboardUsers.ts b/src/data/ApiPermissions.ts similarity index 51% rename from src/data/DashboardUsers.ts rename to src/data/ApiPermissions.ts index e0ce8489..f956ab77 100644 --- a/src/data/DashboardUsers.ts +++ b/src/data/ApiPermissions.ts @@ -1,17 +1,17 @@ import { getRepository, Repository } from "typeorm"; -import { DashboardUser } from "./entities/DashboardUser"; +import { ApiPermission } from "./entities/ApiPermission"; import { BaseRepository } from "./BaseRepository"; -export class DashboardUsers extends BaseRepository { - private dashboardUsers: Repository; +export class ApiPermissions extends BaseRepository { + private apiPermissions: Repository; constructor() { super(); - this.dashboardUsers = getRepository(DashboardUser); + this.apiPermissions = getRepository(ApiPermission); } getByGuildAndUserId(guildId, userId) { - return this.dashboardUsers.findOne({ + return this.apiPermissions.findOne({ where: { guild_id: guildId, user_id: userId, diff --git a/src/data/DashboardRoles.ts b/src/data/ApiRoles.ts similarity index 64% rename from src/data/DashboardRoles.ts rename to src/data/ApiRoles.ts index 91a68d7c..663982b1 100644 --- a/src/data/DashboardRoles.ts +++ b/src/data/ApiRoles.ts @@ -1,4 +1,4 @@ -export enum DashboardRoles { +export enum ApiRoles { Viewer = 1, Editor, Manager, diff --git a/src/data/ApiUserInfo.ts b/src/data/ApiUserInfo.ts new file mode 100644 index 00000000..c7ef64d2 --- /dev/null +++ b/src/data/ApiUserInfo.ts @@ -0,0 +1,38 @@ +import { getRepository, Repository } from "typeorm"; +import { ApiUserInfo as ApiUserInfoEntity, ApiUserInfoData } from "./entities/ApiUserInfo"; +import { BaseRepository } from "./BaseRepository"; +import { connection } from "./db"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../utils"; + +export class ApiUserInfo extends BaseRepository { + private apiUserInfo: Repository; + + constructor() { + super(); + this.apiUserInfo = getRepository(ApiUserInfoEntity); + } + + get(id) { + return this.apiUserInfo.findOne({ + where: { + id, + }, + }); + } + + update(id, data: ApiUserInfoData) { + return connection.transaction(async entityManager => { + const repo = entityManager.getRepository(ApiUserInfoEntity); + + const existingInfo = await repo.findOne({ where: { id } }); + const updatedAt = moment().format(DBDateFormat); + + if (existingInfo) { + await repo.update({ id }, { data, updated_at: updatedAt }); + } else { + await repo.insert({ id, data, updated_at: updatedAt }); + } + }); + } +} diff --git a/src/data/Configs.ts b/src/data/Configs.ts index 431314b7..5cb0a700 100644 --- a/src/data/Configs.ts +++ b/src/data/Configs.ts @@ -32,6 +32,18 @@ export class Configs extends BaseRepository { return (await this.getActiveByKey(key)) != null; } + getRevisions(key, num = 10) { + return this.configs.find({ + relations: this.getRelations(), + where: { key }, + select: ["id", "key", "is_active", "edited_by", "edited_at"], + order: { + edited_at: "DESC", + }, + take: num, + }); + } + async saveNewRevision(key, config, editedBy) { return connection.transaction(async entityManager => { const repo = entityManager.getRepository(Config); diff --git a/src/data/entities/AllowedGuild.ts b/src/data/entities/AllowedGuild.ts index 23240880..3c3e983f 100644 --- a/src/data/entities/AllowedGuild.ts +++ b/src/data/entities/AllowedGuild.ts @@ -4,7 +4,7 @@ import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm"; export class AllowedGuild { @Column() @PrimaryColumn() - guild_id: string; + id: string; @Column() name: string; diff --git a/src/data/entities/ApiLogin.ts b/src/data/entities/ApiLogin.ts new file mode 100644 index 00000000..76204224 --- /dev/null +++ b/src/data/entities/ApiLogin.ts @@ -0,0 +1,25 @@ +import { Entity, Column, PrimaryColumn, OneToOne, ManyToOne, JoinColumn } from "typeorm"; +import { ApiUserInfo } from "./ApiUserInfo"; + +@Entity("api_logins") +export class ApiLogin { + @Column() + @PrimaryColumn() + id: string; + + @Column() + token: string; + + @Column() + user_id: string; + + @Column() + logged_in_at: string; + + @Column() + expires_at: string; + + @ManyToOne(type => ApiUserInfo, userInfo => userInfo.logins) + @JoinColumn({ name: "user_id" }) + userInfo: ApiUserInfo; +} diff --git a/src/data/entities/ApiPermission.ts b/src/data/entities/ApiPermission.ts new file mode 100644 index 00000000..b648eb27 --- /dev/null +++ b/src/data/entities/ApiPermission.ts @@ -0,0 +1,20 @@ +import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from "typeorm"; +import { ApiUserInfo } from "./ApiUserInfo"; + +@Entity("api_permissions") +export class ApiPermission { + @Column() + @PrimaryColumn() + guild_id: string; + + @Column() + @PrimaryColumn() + user_id: string; + + @Column() + role: string; + + @ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissions) + @JoinColumn({ name: "user_id" }) + userInfo: ApiUserInfo; +} diff --git a/src/data/entities/ApiUserInfo.ts b/src/data/entities/ApiUserInfo.ts new file mode 100644 index 00000000..f39addf2 --- /dev/null +++ b/src/data/entities/ApiUserInfo.ts @@ -0,0 +1,28 @@ +import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm"; +import { ApiLogin } from "./ApiLogin"; +import { ApiPermission } from "./ApiPermission"; + +export interface ApiUserInfoData { + username: string; + discriminator: string; + avatar: string; +} + +@Entity("api_user_info") +export class ApiUserInfo { + @Column() + @PrimaryColumn() + id: string; + + @Column("simple-json") + data: ApiUserInfoData; + + @Column() + updated_at: string; + + @OneToMany(type => ApiLogin, login => login.userInfo) + logins: ApiLogin[]; + + @OneToMany(type => ApiPermission, perm => perm.userInfo) + permissions: ApiPermission[]; +} diff --git a/src/data/entities/Config.ts b/src/data/entities/Config.ts index 08ec19a3..36bcef95 100644 --- a/src/data/entities/Config.ts +++ b/src/data/entities/Config.ts @@ -1,4 +1,5 @@ -import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm"; +import { Entity, Column, PrimaryColumn, CreateDateColumn, ManyToOne, JoinColumn } from "typeorm"; +import { ApiUserInfo } from "./ApiUserInfo"; @Entity("configs") export class Config { @@ -20,4 +21,8 @@ export class Config { @Column() edited_at: string; + + @ManyToOne(type => ApiUserInfo) + @JoinColumn({ name: "edited_by" }) + userInfo: ApiUserInfo; } diff --git a/src/data/entities/DashboardLogin.ts b/src/data/entities/DashboardLogin.ts deleted file mode 100644 index 31943284..00000000 --- a/src/data/entities/DashboardLogin.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Entity, Column, PrimaryColumn } from "typeorm"; -import { DashboardLoginUserData } from "../DashboardLogins"; - -@Entity("dashboard_logins") -export class DashboardLogin { - @Column() - @PrimaryColumn() - id: string; - - @Column() - token: string; - - @Column() - user_id: string; - - @Column("simple-json") - user_data: DashboardLoginUserData; - - @Column() - logged_in_at: string; - - @Column() - expires_at: string; -} diff --git a/src/data/entities/DashboardUser.ts b/src/data/entities/DashboardUser.ts deleted file mode 100644 index bc29d5f1..00000000 --- a/src/data/entities/DashboardUser.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Entity, Column, PrimaryColumn } from "typeorm"; - -@Entity("dashboard_users") -export class DashboardUser { - @Column() - @PrimaryColumn() - guild_id: string; - - @Column() - @PrimaryColumn() - user_id: string; - - @Column() - username: string; - - @Column() - role: string; -} diff --git a/src/migrations/1561282151982-RenameBackendDashboardStuffToAPI.ts b/src/migrations/1561282151982-RenameBackendDashboardStuffToAPI.ts new file mode 100644 index 00000000..856d851a --- /dev/null +++ b/src/migrations/1561282151982-RenameBackendDashboardStuffToAPI.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RenameBackendDashboardStuffToAPI1561282151982 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE dashboard_users RENAME api_users`); + await queryRunner.query(`ALTER TABLE dashboard_logins RENAME api_logins`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE api_users RENAME dashboard_users`); + await queryRunner.query(`ALTER TABLE api_logins RENAME dashboard_logins`); + } +} diff --git a/src/migrations/1561282552734-RenameAllowedGuildGuildIdToId.ts b/src/migrations/1561282552734-RenameAllowedGuildGuildIdToId.ts new file mode 100644 index 00000000..3a934c54 --- /dev/null +++ b/src/migrations/1561282552734-RenameAllowedGuildGuildIdToId.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RenameAllowedGuildGuildIdToId1561282552734 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE `allowed_guilds` CHANGE COLUMN `guild_id` `id` BIGINT(20) NOT NULL FIRST;"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE `allowed_guilds` CHANGE COLUMN `id` `guild_id` BIGINT(20) NOT NULL FIRST;"); + } +} diff --git a/src/migrations/1561282950483-CreateApiUserInfoTable.ts b/src/migrations/1561282950483-CreateApiUserInfoTable.ts new file mode 100644 index 00000000..de8a4ad6 --- /dev/null +++ b/src/migrations/1561282950483-CreateApiUserInfoTable.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateApiUserInfoTable1561282950483 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "api_user_info", + columns: [ + { + name: "id", + type: "bigint", + isPrimary: true, + }, + { + name: "data", + type: "text", + }, + { + name: "updated_at", + type: "datetime", + default: "now()", + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("api_user_info", true); + } +} diff --git a/src/migrations/1561283165823-RenameApiUsersToApiPermissions.ts b/src/migrations/1561283165823-RenameApiUsersToApiPermissions.ts new file mode 100644 index 00000000..2da93b77 --- /dev/null +++ b/src/migrations/1561283165823-RenameApiUsersToApiPermissions.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RenameApiUsersToApiPermissions1561283165823 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE api_users RENAME api_permissions`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE api_permissions RENAME api_users`); + } +} diff --git a/src/migrations/1561283405201-DropUserDataFromLoginsAndPermissions.ts b/src/migrations/1561283405201-DropUserDataFromLoginsAndPermissions.ts new file mode 100644 index 00000000..d47c8d2d --- /dev/null +++ b/src/migrations/1561283405201-DropUserDataFromLoginsAndPermissions.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DropUserDataFromLoginsAndPermissions1561283405201 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE `api_logins` DROP COLUMN `user_data`"); + await queryRunner.query("ALTER TABLE `api_permissions` DROP COLUMN `username`"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "ALTER TABLE `api_logins` ADD COLUMN `user_data` TEXT NOT NULL COLLATE 'utf8mb4_swedish_ci' AFTER `user_id`", + ); + await queryRunner.query( + "ALTER TABLE `api_permissions` ADD COLUMN `username` VARCHAR(255) NOT NULL COLLATE 'utf8mb4_swedish_ci' AFTER `user_id`", + ); + } +} diff --git a/src/plugins/GuildInfoSaver.ts b/src/plugins/GuildInfoSaver.ts new file mode 100644 index 00000000..b89dc508 --- /dev/null +++ b/src/plugins/GuildInfoSaver.ts @@ -0,0 +1,24 @@ +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { AllowedGuilds } from "../data/AllowedGuilds"; +import { MINUTES } from "../utils"; + +export class GuildInfoSaverPlugin extends ZeppelinPlugin { + public static pluginName = "guild_info_saver"; + protected allowedGuilds: AllowedGuilds; + private updateInterval; + + onLoad() { + this.allowedGuilds = new AllowedGuilds(); + + this.updateGuildInfo(); + this.updateInterval = setInterval(() => this.updateGuildInfo(), 60 * MINUTES); + } + + onUnload() { + clearInterval(this.updateInterval); + } + + protected updateGuildInfo() { + this.allowedGuilds.updateInfo(this.guildId, this.guild.name, this.guild.iconURL); + } +} diff --git a/src/plugins/LogServer.ts b/src/plugins/LogServer.ts deleted file mode 100644 index 9a94b638..00000000 --- a/src/plugins/LogServer.ts +++ /dev/null @@ -1,92 +0,0 @@ -import http, { ServerResponse } from "http"; -import { GlobalPlugin, IPluginOptions, logger } from "knub"; -import { GuildArchives } from "../data/GuildArchives"; -import { sleep } from "../utils"; -import moment from "moment-timezone"; - -const DEFAULT_PORT = 9920; -const archivesRegex = /^\/(spam-logs|archives)\/([a-z0-9\-]+)\/?$/i; - -function notFound(res: ServerResponse) { - res.statusCode = 404; - res.end("Not Found"); -} - -interface ILogServerPluginConfig { - port: number; -} - -export class LogServerPlugin extends GlobalPlugin { - public static pluginName = "log_server"; - - protected archives: GuildArchives; - protected server: http.Server; - - protected getDefaultOptions(): IPluginOptions { - return { - config: { - port: DEFAULT_PORT, - }, - }; - } - - async onLoad() { - this.archives = new GuildArchives(null); - - this.server = http.createServer(async (req, res) => { - const pathMatch = req.url.match(archivesRegex); - if (!pathMatch) return notFound(res); - - const logId = pathMatch[2]; - - if (pathMatch[1] === "spam-logs") { - res.statusCode = 301; - res.setHeader("Location", `/archives/${logId}`); - return; - } - - if (pathMatch) { - const log = await this.archives.find(logId); - if (!log) return notFound(res); - - let body = log.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 (log.body.indexOf("Log file generated on") === -1) { - const createdAt = moment(log.created_at).format("YYYY-MM-DD [at] HH:mm:ss [(+00:00)]"); - body += `\n\nLog file generated on ${createdAt}`; - - if (log.expires_at !== null) { - const expiresAt = moment(log.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); - } - }); - - const port = this.getConfig().port; - let retried = false; - - this.server.on("error", async (err: any) => { - if (err.code === "EADDRINUSE" && !retried) { - logger.info("Got EADDRINUSE, retrying in 2 sec..."); - retried = true; - await sleep(2000); - this.server.listen(port); - } else { - throw err; - } - }); - - this.server.listen(port); - } - - async onUnload() { - return new Promise(resolve => { - this.server.close(() => resolve()); - }); - } -} diff --git a/src/plugins/availablePlugins.ts b/src/plugins/availablePlugins.ts index 856b3519..82974437 100644 --- a/src/plugins/availablePlugins.ts +++ b/src/plugins/availablePlugins.ts @@ -19,9 +19,9 @@ import { SelfGrantableRolesPlugin } from "./SelfGrantableRolesPlugin"; import { RemindersPlugin } from "./Reminders"; import { WelcomeMessagePlugin } from "./WelcomeMessage"; import { BotControlPlugin } from "./BotControl"; -import { LogServerPlugin } from "./LogServer"; import { UsernameSaver } from "./UsernameSaver"; import { CustomEventsPlugin } from "./CustomEvents"; +import { GuildInfoSaverPlugin } from "./GuildInfoSaver"; /** * Plugins available to be loaded for individual guilds @@ -48,12 +48,14 @@ export const availablePlugins = [ RemindersPlugin, WelcomeMessagePlugin, CustomEventsPlugin, + GuildInfoSaverPlugin, ]; /** * Plugins that are always loaded (subset of the names of the plugins in availablePlugins) */ export const basePlugins = [ + GuildInfoSaverPlugin.pluginName, MessageSaverPlugin.pluginName, NameHistoryPlugin.pluginName, CasesPlugin.pluginName, @@ -63,4 +65,4 @@ export const basePlugins = [ /** * Available global plugins (can't be loaded per-guild, only globally) */ -export const availableGlobalPlugins = [BotControlPlugin, LogServerPlugin, UsernameSaver]; +export const availableGlobalPlugins = [BotControlPlugin, UsernameSaver];