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 00000000..7c80b9fb Binary files /dev/null and b/dashboard/src/img/logo.png differ 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];