From d03d729438d7b608f2e30e2de76578418dff7773 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 23 May 2020 16:22:03 +0300 Subject: [PATCH] dashboard/api: add support for Zeppelin staff members; add ViewGuild permission; code cleanup --- backend/api.env.example | 1 + backend/src/api/auth.ts | 2 +- backend/src/api/guilds.ts | 41 ++++++++++------ backend/src/api/index.ts | 48 +------------------ backend/src/api/permissions.ts | 33 +++++++++++++ backend/src/api/start.ts | 48 +++++++++++++++++++ backend/src/data/AllowedGuilds.ts | 4 ++ backend/src/staff.ts | 6 +++ .../dashboard/GuildConfigEditor.vue | 14 +++++- .../src/components/dashboard/GuildList.vue | 2 +- dashboard/src/store/guilds.ts | 24 ++++++++-- dashboard/src/store/types.ts | 13 +++-- shared/src/apiPermissions.ts | 14 +++++- 13 files changed, 175 insertions(+), 75 deletions(-) create mode 100644 backend/src/api/permissions.ts create mode 100644 backend/src/api/start.ts create mode 100644 backend/src/staff.ts diff --git a/backend/api.env.example b/backend/api.env.example index 5a003966..87afe866 100644 --- a/backend/api.env.example +++ b/backend/api.env.example @@ -7,3 +7,4 @@ DB_HOST= DB_USER= DB_PASSWORD= DB_DATABASE= +STAFF= diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index cc3750c7..8708b759 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -12,7 +12,7 @@ import { ok } from "./responses"; interface IPassportApiUser { apiKey: string; - userId: number; + userId: string; } declare global { diff --git a/backend/src/api/guilds.ts b/backend/src/api/guilds.ts index 7ba01ca5..c902943c 100644 --- a/backend/src/api/guilds.ts +++ b/backend/src/api/guilds.ts @@ -5,35 +5,46 @@ import { Configs } from "../data/Configs"; import { validateGuildConfig } from "../configValidator"; import yaml, { YAMLException } from "js-yaml"; import { apiTokenAuthHandlers } from "./auth"; -import { ApiPermissions, hasPermission, permissionArrToSet } from "@shared/apiPermissions"; -import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments"; +import { ApiPermissions } from "@shared/apiPermissions"; +import { hasGuildPermission, requireGuildPermission } from "./permissions"; export function initGuildsAPI(app: express.Express) { const allowedGuilds = new AllowedGuilds(); - const apiPermissionAssignments = new ApiPermissionAssignments(); const configs = new Configs(); - app.get("/guilds/available", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { + const guildRouter = express.Router(); + guildRouter.use(...apiTokenAuthHandlers()); + + guildRouter.get("/available", async (req: Request, res: Response) => { const guilds = await allowedGuilds.getForApiUser(req.user.userId); res.json(guilds); }); - app.get("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { - const permAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, req.user.userId); - if (!permAssignment || !hasPermission(permissionArrToSet(permAssignment.permissions), ApiPermissions.ReadConfig)) { + guildRouter.get("/:guildId", async (req: Request, res: Response) => { + if (!(await hasGuildPermission(req.user.userId, req.params.guildId, ApiPermissions.ViewGuild))) { return unauthorized(res); } - const config = await configs.getActiveByKey(`guild-${req.params.guildId}`); - res.json({ config: config ? config.config : "" }); + const guild = await allowedGuilds.find(req.params.guildId); + res.json(guild); }); - app.post("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req, res) => { - const permAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, req.user.userId); - if (!permAssignment || !hasPermission(permissionArrToSet(permAssignment.permissions), ApiPermissions.EditConfig)) { - return unauthorized(res); - } + guildRouter.post("/:guildId/check-permission", async (req: Request, res: Response) => { + const permission = req.body.permission; + const hasPermission = await hasGuildPermission(req.user.userId, req.params.guildId, permission); + res.json({ result: hasPermission }); + }); + guildRouter.get( + "/:guildId/config", + requireGuildPermission(ApiPermissions.ReadConfig), + async (req: Request, res: Response) => { + const config = await configs.getActiveByKey(`guild-${req.params.guildId}`); + res.json({ config: config ? config.config : "" }); + }, + ); + + guildRouter.post("/:guildId/config", requireGuildPermission(ApiPermissions.EditConfig), async (req, res) => { let config = req.body.config; if (config == null) return clientError(res, "No config supplied"); @@ -70,4 +81,6 @@ export function initGuildsAPI(app: express.Express) { await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user.userId); ok(res); }); + + app.use("/guilds", guildRouter); } diff --git a/backend/src/api/index.ts b/backend/src/api/index.ts index 85d2e744..575b8b51 100644 --- a/backend/src/api/index.ts +++ b/backend/src/api/index.ts @@ -1,14 +1,5 @@ -import { clientError, error, notFound } from "./responses"; -import express from "express"; -import cors from "cors"; -import { initAuth } from "./auth"; -import { initGuildsAPI } from "./guilds"; -import { initArchives } from "./archives"; -import { initDocs } from "./docs"; import { connect } from "../data/db"; import path from "path"; -import { TokenError } from "passport-oauth2"; -import { PluginError } from "knub"; require("dotenv").config({ path: path.resolve(process.cwd(), "api.env") }); @@ -19,43 +10,8 @@ function errorHandler(err) { process.on("unhandledRejection", errorHandler); +// Connect to the database before loading the rest of the code (that depend on the database connection) console.log("Connecting to database..."); // tslint:disable-line connect().then(() => { - const app = express(); - - app.use( - cors({ - origin: process.env.DASHBOARD_URL, - }), - ); - app.use(express.json()); - - initAuth(app); - initGuildsAPI(app); - initArchives(app); - initDocs(app); - - // Default route - app.get("/", (req, res) => { - res.json({ status: "cookies", with: "milk" }); - }); - - // Error response - app.use((err, req, res, next) => { - if (err instanceof TokenError) { - clientError(res, "Invalid code"); - } else { - console.error(err); // tslint:disable-line - error(res, "Server error", err.status || 500); - } - }); - - // 404 response - app.use((req, res, next) => { - return notFound(res); - }); - - const port = process.env.PORT || 3000; - // tslint:disable-next-line - app.listen(port, () => console.log(`API server listening on port ${port}`)); + import("./start"); }); diff --git a/backend/src/api/permissions.ts b/backend/src/api/permissions.ts new file mode 100644 index 00000000..68e22162 --- /dev/null +++ b/backend/src/api/permissions.ts @@ -0,0 +1,33 @@ +import { ApiPermissions, hasPermission, permissionArrToSet } from "@shared/apiPermissions"; +import { isStaff } from "../staff"; +import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments"; +import { Request, Response } from "express"; +import { unauthorized } from "./responses"; + +const apiPermissionAssignments = new ApiPermissionAssignments(); + +export const hasGuildPermission = async (userId: string, guildId: string, permission: ApiPermissions) => { + if (isStaff(userId)) { + return true; + } + + const permAssignment = await apiPermissionAssignments.getByGuildAndUserId(guildId, userId); + if (!permAssignment) { + return false; + } + + return hasPermission(permissionArrToSet(permAssignment.permissions), permission); +}; + +/** + * Requires `guildId` in req.params + */ +export function requireGuildPermission(permission: ApiPermissions) { + return async (req: Request, res: Response, next) => { + if (!(await hasGuildPermission(req.user.userId, req.params.guildId, permission))) { + return unauthorized(res); + } + + next(); + }; +} diff --git a/backend/src/api/start.ts b/backend/src/api/start.ts new file mode 100644 index 00000000..2717bff4 --- /dev/null +++ b/backend/src/api/start.ts @@ -0,0 +1,48 @@ +import { clientError, error, notFound } from "./responses"; +import express from "express"; +import cors from "cors"; +import { initAuth } from "./auth"; +import { initGuildsAPI } from "./guilds"; +import { initArchives } from "./archives"; +import { initDocs } from "./docs"; +import { connect } from "../data/db"; +import path from "path"; +import { TokenError } from "passport-oauth2"; +import { PluginError } from "knub"; + +const app = express(); + +app.use( + cors({ + origin: process.env.DASHBOARD_URL, + }), +); +app.use(express.json()); + +initAuth(app); +initGuildsAPI(app); +initArchives(app); +initDocs(app); + +// Default route +app.get("/", (req, res) => { + res.json({ status: "cookies", with: "milk" }); +}); + +// Error response +app.use((err, req, res, next) => { + if (err instanceof TokenError) { + clientError(res, "Invalid code"); + } else { + console.error(err); // tslint:disable-line + error(res, "Server error", 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/backend/src/data/AllowedGuilds.ts b/backend/src/data/AllowedGuilds.ts index 3b5a89fb..bf104790 100644 --- a/backend/src/data/AllowedGuilds.ts +++ b/backend/src/data/AllowedGuilds.ts @@ -28,6 +28,10 @@ export class AllowedGuilds extends BaseRepository { return count !== 0; } + find(guildId) { + return this.allowedGuilds.findOne(guildId); + } + getForApiUser(userId) { return this.allowedGuilds .createQueryBuilder("allowed_guilds") diff --git a/backend/src/staff.ts b/backend/src/staff.ts new file mode 100644 index 00000000..7c14f0b6 --- /dev/null +++ b/backend/src/staff.ts @@ -0,0 +1,6 @@ +/** + * Zeppelin staff have full access to the dashboard + */ +export function isStaff(userId: string) { + return (process.env.STAFF ?? "").split(",").includes(userId); +} diff --git a/dashboard/src/components/dashboard/GuildConfigEditor.vue b/dashboard/src/components/dashboard/GuildConfigEditor.vue index a17bc457..596b7e81 100644 --- a/dashboard/src/components/dashboard/GuildConfigEditor.vue +++ b/dashboard/src/components/dashboard/GuildConfigEditor.vue @@ -41,7 +41,17 @@ AceEditor, }, async mounted() { - await this.$store.dispatch("guilds/loadAvailableGuilds"); + try { + await this.$store.dispatch("guilds/loadGuild", this.$route.params.guildId); + } catch (err) { + if (err instanceof ApiError) { + this.$router.push('/dashboard'); + return; + } + + throw err; + } + if (this.guild == null) { this.$router.push('/dashboard'); return; @@ -66,7 +76,7 @@ computed: { ...mapState('guilds', { guild() { - return this.$store.state.guilds.available.find(g => g.id === this.$route.params.guildId); + return this.$store.state.guilds.available.get(this.$route.params.guildId); }, config() { return this.$store.state.guilds.configs[this.$route.params.guildId]; diff --git a/dashboard/src/components/dashboard/GuildList.vue b/dashboard/src/components/dashboard/GuildList.vue index 13437253..3b126ce6 100644 --- a/dashboard/src/components/dashboard/GuildList.vue +++ b/dashboard/src/components/dashboard/GuildList.vue @@ -42,7 +42,7 @@ computed: { ...mapState('guilds', { guilds: state => { - const guilds = Array.from(state.available || []); + const guilds = Array.from(state.available.values()); guilds.sort((a, b) => { if (a.name > b.name) return 1; if (a.name < b.name) return -1; diff --git a/dashboard/src/store/guilds.ts b/dashboard/src/store/guilds.ts index fb2367b1..62e7ff62 100644 --- a/dashboard/src/store/guilds.ts +++ b/dashboard/src/store/guilds.ts @@ -7,7 +7,7 @@ export const GuildStore: Module = { state: { availableGuildsLoadStatus: LoadStatus.None, - available: [], + available: new Map(), configs: {}, }, @@ -17,7 +17,22 @@ export const GuildStore: Module = { commit("setAvailableGuildsLoadStatus", LoadStatus.Loading); const availableGuilds = await get("guilds/available"); - commit("setAvailableGuilds", availableGuilds); + for (const guild of availableGuilds) { + commit("addGuild", guild); + } + + commit("setAvailableGuildsLoadStatus", LoadStatus.Done); + }, + + async loadGuild({ commit, state }, guildId) { + if (state.available.has(guildId)) { + return; + } + + const guild = await get(`guilds/${guildId}`); + if (guild) { + commit("addGuild", guild); + } }, async loadConfig({ commit }, guildId) { @@ -35,9 +50,8 @@ export const GuildStore: Module = { state.availableGuildsLoadStatus = status; }, - setAvailableGuilds(state: GuildState, guilds) { - state.available = guilds; - state.availableGuildsLoadStatus = LoadStatus.Done; + addGuild(state: GuildState, guild) { + state.available.set(guild.id, guild); }, setConfig(state: GuildState, { guildId, config }) { diff --git a/dashboard/src/store/types.ts b/dashboard/src/store/types.ts index cefdacf2..4deb8fba 100644 --- a/dashboard/src/store/types.ts +++ b/dashboard/src/store/types.ts @@ -11,11 +11,14 @@ export interface AuthState { export interface GuildState { availableGuildsLoadStatus: LoadStatus; - available: Array<{ - guild_id: string; - name: string; - icon: string | null; - }>; + available: Map< + string, + { + id: string; + name: string; + icon: string | null; + } + >; configs: { [key: string]: string; }; diff --git a/shared/src/apiPermissions.ts b/shared/src/apiPermissions.ts index bf96ea84..1d8a71d0 100644 --- a/shared/src/apiPermissions.ts +++ b/shared/src/apiPermissions.ts @@ -3,6 +3,7 @@ export enum ApiPermissions { ManageAccess = "MANAGE_ACCESS", EditConfig = "EDIT_CONFIG", ReadConfig = "READ_CONFIG", + ViewGuild = "VIEW_GUILD", } const reverseApiPermissions = Object.entries(ApiPermissions).reduce((map, [key, value]) => { @@ -15,13 +16,24 @@ export const permissionNames = { [ApiPermissions.ManageAccess]: "Manage dashboard access", [ApiPermissions.EditConfig]: "Edit config", [ApiPermissions.ReadConfig]: "Read config", + [ApiPermissions.ViewGuild]: "View server", }; export type TPermissionHierarchy = Array; +// prettier-ignore-start export const permissionHierarchy: TPermissionHierarchy = [ - [ApiPermissions.Owner, [[ApiPermissions.ManageAccess, [[ApiPermissions.EditConfig, [ApiPermissions.ReadConfig]]]]]], + [ + ApiPermissions.Owner, + [ + [ + ApiPermissions.ManageAccess, + [[ApiPermissions.EditConfig, [[ApiPermissions.ReadConfig, [ApiPermissions.ViewGuild]]]]], + ], + ], + ], ]; +// prettier-ignore-end export function permissionArrToSet(permissions: string[]): Set { return new Set(permissions.filter(p => reverseApiPermissions[p])) as Set;