diff --git a/backend/src/api/guilds/importExport.ts b/backend/src/api/guilds/importExport.ts new file mode 100644 index 00000000..a6f3eb59 --- /dev/null +++ b/backend/src/api/guilds/importExport.ts @@ -0,0 +1,163 @@ +import { ApiPermissions } from "@shared/apiPermissions"; +import express, { Request, Response } from "express"; +import { requireGuildPermission } from "../permissions"; +import { clientError, ok } from "../responses"; +import { GuildCases } from "../../data/GuildCases"; +import { z } from "zod"; +import { Case } from "../../data/entities/Case"; + +const caseHandlingModeSchema = z.union([ + z.literal("replace"), + z.literal("bumpExistingCases"), + z.literal("bumpImportedCases"), +]); + +type CaseHandlingMode = z.infer; + +const caseNoteData = z.object({ + mod_id: z.string(), + mod_name: z.string(), + body: z.string(), + created_at: z.string(), +}); + +const caseData = z.object({ + case_number: z.number(), + user_id: z.string(), + user_name: z.string(), + mod_id: z.nullable(z.string()), + mod_name: z.nullable(z.string()), + type: z.number(), + created_at: z.string(), + is_hidden: z.boolean(), + pp_id: z.nullable(z.string()), + pp_name: z.nullable(z.string()), + + notes: z.array(caseNoteData), +}); + +const importExportData = z.object({ + cases: z.array(caseData), +}); +type TImportExportData = z.infer; + +export function initGuildsImportExportAPI(guildRouter: express.Router) { + const importExportRouter = express.Router(); + + importExportRouter.get( + "/:guildId/pre-import", + requireGuildPermission(ApiPermissions.ManageAccess), + async (req: Request, res: Response) => { + const guildCases = GuildCases.getGuildInstance(req.params.guildId); + const minNum = await guildCases.getMinCaseNumber(); + const maxNum = await guildCases.getMaxCaseNumber(); + + return { + minCaseNumber: minNum, + maxCaseNumber: maxNum, + }; + }, + ); + + importExportRouter.post( + "/:guildId/import", + requireGuildPermission(ApiPermissions.ManageAccess), + async (req: Request, res: Response) => { + let data: TImportExportData; + try { + data = importExportData.parse(req.body.data); + } catch (err) { + return clientError(res, "Invalid import data format"); + return; + } + + let caseHandlingMode: CaseHandlingMode; + try { + caseHandlingMode = caseHandlingModeSchema.parse(req.body.caseHandlingMode); + } catch (err) { + return clientError(res, "Invalid case handling mode"); + return; + } + + const guildCases = GuildCases.getGuildInstance(req.params.guildId); + + // Prepare cases + if (caseHandlingMode === "replace") { + // Replace existing cases + await guildCases.deleteAllCases(); + } else if (caseHandlingMode === "bumpExistingCases") { + // Bump existing numbers + const maxNumberInData = data.cases.reduce((max, theCase) => Math.max(max, theCase.case_number), 0); + await guildCases.bumpCaseNumbers(maxNumberInData); + } else if (caseHandlingMode === "bumpImportedCases") { + const maxExistingNumber = await guildCases.getMaxCaseNumber(); + for (const theCase of data.cases) { + theCase.case_number += maxExistingNumber; + } + } + + // Import cases + for (const theCase of data.cases) { + const insertData: any = { + ...theCase, + is_hidden: theCase.is_hidden ? 1 : 0, + guild_id: req.params.guildId, + notes: undefined, + }; + + const caseInsertData = await guildCases.createInternal(insertData); + for (const note of theCase.notes) { + await guildCases.createNote(caseInsertData.identifiers[0].id, note); + } + } + + ok(res); + }, + ); + + const exportBatchSize = 500; + importExportRouter.post( + "/:guildId/export", + requireGuildPermission(ApiPermissions.ManageAccess), + async (req: Request, res: Response) => { + const guildCases = GuildCases.getGuildInstance(req.params.guildId); + + const data: TImportExportData = { + cases: [], + }; + + let n = 0; + let cases: Case[]; + do { + cases = await guildCases.getExportCases(n, exportBatchSize); + n += cases.length; + + for (const theCase of cases) { + data.cases.push({ + case_number: theCase.case_number, + user_id: theCase.user_id, + user_name: theCase.user_name, + mod_id: theCase.mod_id, + mod_name: theCase.mod_name, + type: theCase.type, + created_at: theCase.created_at, + is_hidden: theCase.is_hidden, + pp_id: theCase.pp_id, + pp_name: theCase.pp_name, + + notes: theCase.notes.map((note) => ({ + mod_id: note.mod_id, + mod_name: note.mod_name, + body: note.body, + created_at: note.created_at, + })), + }); + } + } while (cases.length === exportBatchSize); + + res.json(data); + }, + ); + + guildRouter.use("/", importExportRouter); +} diff --git a/backend/src/api/guilds/index.ts b/backend/src/api/guilds/index.ts new file mode 100644 index 00000000..36fe4383 --- /dev/null +++ b/backend/src/api/guilds/index.ts @@ -0,0 +1,14 @@ +import express from "express"; +import { apiTokenAuthHandlers } from "../auth"; +import { initGuildsMiscAPI } from "./misc"; +import { initGuildsImportExportAPI } from "./importExport"; + +export function initGuildsAPI(app: express.Express) { + const guildRouter = express.Router(); + guildRouter.use(...apiTokenAuthHandlers()); + + initGuildsMiscAPI(guildRouter); + initGuildsImportExportAPI(guildRouter); + + app.use("/guilds", guildRouter); +} diff --git a/backend/src/api/guilds/misc.ts b/backend/src/api/guilds/misc.ts new file mode 100644 index 00000000..c4083c05 --- /dev/null +++ b/backend/src/api/guilds/misc.ts @@ -0,0 +1,180 @@ +import { ApiPermissions } from "@shared/apiPermissions"; +import express, { Request, Response } from "express"; +import { YAMLException } from "js-yaml"; +import { validateGuildConfig } from "../../configValidator"; +import { AllowedGuilds } from "../../data/AllowedGuilds"; +import { ApiPermissionAssignments, ApiPermissionTypes } from "../../data/ApiPermissionAssignments"; +import { Configs } from "../../data/Configs"; +import { apiTokenAuthHandlers } from "../auth"; +import { hasGuildPermission, requireGuildPermission } from "../permissions"; +import { clientError, ok, serverError, unauthorized } from "../responses"; +import { loadYamlSafely } from "../../utils/loadYamlSafely"; +import { ObjectAliasError } from "../../utils/validateNoObjectAliases"; +import { isSnowflake } from "../../utils"; +import moment from "moment-timezone"; +import { ApiAuditLog } from "../../data/ApiAuditLog"; +import { AuditLogEventTypes } from "../../data/apiAuditLogTypes"; +import { Queue } from "../../Queue"; +import { GuildCases } from "../../data/GuildCases"; +import { z } from "zod"; + +const apiPermissionAssignments = new ApiPermissionAssignments(); +const auditLog = new ApiAuditLog(); + +export function initGuildsMiscAPI(router: express.Router) { + const allowedGuilds = new AllowedGuilds(); + const configs = new Configs(); + + const miscRouter = express.Router(); + + miscRouter.get("/available", async (req: Request, res: Response) => { + const guilds = await allowedGuilds.getForApiUser(req.user!.userId); + res.json(guilds); + }); + + miscRouter.get( + "/my-permissions", // a + async (req: Request, res: Response) => { + const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId); + res.json(permissions); + }, + ); + + miscRouter.get("/:guildId", async (req: Request, res: Response) => { + if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) { + return unauthorized(res); + } + + const guild = await allowedGuilds.find(req.params.guildId); + res.json(guild); + }); + + miscRouter.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 }); + }); + + miscRouter.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 : "" }); + }, + ); + + miscRouter.post("/:guildId/config", requireGuildPermission(ApiPermissions.EditConfig), async (req, res) => { + let config = req.body.config; + if (config == null) return clientError(res, "No config supplied"); + + config = config.trim() + "\n"; // Normalize start/end whitespace in the config + + const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`); + if (currentConfig && config === currentConfig.config) { + return ok(res); + } + + // Validate config + let parsedConfig; + try { + parsedConfig = loadYamlSafely(config); + } catch (e) { + if (e instanceof YAMLException) { + return res.status(400).json({ errors: [e.message] }); + } + + if (e instanceof ObjectAliasError) { + return res.status(400).json({ errors: [e.message] }); + } + + // tslint:disable-next-line:no-console + console.error("Error when loading YAML: " + e.message); + return serverError(res, "Server error"); + } + + if (parsedConfig == null) { + parsedConfig = {}; + } + + const error = await validateGuildConfig(parsedConfig); + if (error) { + return res.status(422).json({ errors: [error] }); + } + + await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user!.userId); + + ok(res); + }); + + miscRouter.get( + "/:guildId/permissions", + requireGuildPermission(ApiPermissions.ManageAccess), + async (req: Request, res: Response) => { + const permissions = await apiPermissionAssignments.getByGuildId(req.params.guildId); + res.json(permissions); + }, + ); + + const permissionManagementQueue = new Queue(); + miscRouter.post( + "/:guildId/set-target-permissions", + requireGuildPermission(ApiPermissions.ManageAccess), + async (req: Request, res: Response) => { + await permissionManagementQueue.add(async () => { + const { type, targetId, permissions, expiresAt } = req.body; + + if (type !== ApiPermissionTypes.User) { + return clientError(res, "Invalid type"); + } + if (!isSnowflake(targetId)) { + return clientError(res, "Invalid targetId"); + } + const validPermissions = new Set(Object.values(ApiPermissions)); + validPermissions.delete(ApiPermissions.Owner); + if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) { + return clientError(res, "Invalid permissions"); + } + if (expiresAt != null && !moment.utc(expiresAt).isValid()) { + return clientError(res, "Invalid expiresAt"); + } + + const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); + if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) { + return clientError(res, "Can't change owner permissions"); + } + + if (permissions.length === 0) { + await apiPermissionAssignments.removeUser(req.params.guildId, targetId); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + }); + } else { + const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); + if (existing) { + await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + permissions, + expires_at: existing.expires_at, + }); + } else { + await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + permissions, + expires_at: expiresAt, + }); + } + } + + ok(res); + }); + }, + ); + + router.use("/", miscRouter); +} diff --git a/backend/src/api/start.ts b/backend/src/api/start.ts index b4eed63b..22fa0368 100644 --- a/backend/src/api/start.ts +++ b/backend/src/api/start.ts @@ -4,7 +4,7 @@ import { TokenError } from "passport-oauth2"; import { initArchives } from "./archives"; import { initAuth } from "./auth"; import { initDocs } from "./docs"; -import { initGuildsAPI } from "./guilds"; +import { initGuildsAPI } from "./guilds/index"; import { clientError, error, notFound } from "./responses"; import { startBackgroundTasks } from "./tasks"; diff --git a/backend/src/data/GuildCases.ts b/backend/src/data/GuildCases.ts index a5d6e20b..3010ee36 100644 --- a/backend/src/data/GuildCases.ts +++ b/backend/src/data/GuildCases.ts @@ -4,7 +4,8 @@ import { CaseTypes } from "./CaseTypes"; import { connection } from "./db"; import { Case } from "./entities/Case"; import { CaseNote } from "./entities/CaseNote"; -import moment = require("moment-timezone"); +import moment from "moment-timezone"; +import { chunkArray } from "../utils"; import { Queue } from "../Queue"; const CASE_SUMMARY_REASON_MAX_LENGTH = 300; @@ -111,6 +112,26 @@ export class GuildCases extends BaseGuildRepository { }); } + async getMinCaseNumber(): Promise { + const result = await this.cases + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .select(["MIN(case_number) AS min_case_number"]) + .getRawOne<{ min_case_number: number }>(); + + return result?.min_case_number || 0; + } + + async getMaxCaseNumber(): Promise { + const result = await this.cases + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .select(["MAX(case_number) AS max_case_number"]) + .getRawOne<{ max_case_number: number }>(); + + return result?.max_case_number || 0; + } + async setHidden(id: number, hidden: boolean): Promise { await this.cases.update( { id }, @@ -197,4 +218,42 @@ export class GuildCases extends BaseGuildRepository { case_id: caseId, }); } + + async deleteAllCases(): Promise { + const idRows = await this.cases + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .select(["id"]) + .getRawMany<{ id: number }>(); + const ids = idRows.map((r) => r.id); + const batches = chunkArray(ids, 500); + for (const batch of batches) { + await this.cases.createQueryBuilder().where("id IN (:ids)", { ids: batch }).delete().execute(); + } + } + + async bumpCaseNumbers(amount: number): Promise { + await this.cases + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .update() + .set({ + case_number: () => `case_number + ${parseInt(amount as unknown as string, 10)}`, + }) + .execute(); + } + + getExportCases(skip: number, take: number): Promise { + return this.cases.find({ + where: { + guild_id: this.guildId, + }, + relations: ["notes"], + order: { + case_number: "ASC", + }, + skip, + take, + }); + } } diff --git a/dashboard/src/components/dashboard/GuildImportExport.vue b/dashboard/src/components/dashboard/GuildImportExport.vue new file mode 100644 index 00000000..31716f45 --- /dev/null +++ b/dashboard/src/components/dashboard/GuildImportExport.vue @@ -0,0 +1,159 @@ + + + diff --git a/dashboard/src/components/dashboard/GuildList.vue b/dashboard/src/components/dashboard/GuildList.vue index 2842d7f3..edc9dc94 100644 --- a/dashboard/src/components/dashboard/GuildList.vue +++ b/dashboard/src/components/dashboard/GuildList.vue @@ -19,6 +19,7 @@
Config Access + Import/export
diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index 4ef8e996..e442f615 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -86,6 +86,10 @@ export const router = new VueRouter({ path: "guilds/:guildId/access", component: () => import("./components/dashboard/GuildAccess.vue"), }, + { + path: "guilds/:guildId/import-export", + component: () => import("./components/dashboard/GuildImportExport.vue"), + }, ], }, ], diff --git a/dashboard/src/store/guilds.ts b/dashboard/src/store/guilds.ts index 10c2eb70..bfdbe71b 100644 --- a/dashboard/src/store/guilds.ts +++ b/dashboard/src/store/guilds.ts @@ -66,6 +66,17 @@ export const GuildStore: Module = { await post(`guilds/${guildId}/set-target-permissions`, { guildId, targetId, type, permissions, expiresAt }); commit("setTargetPermissions", { guildId, targetId, type, permissions, expiresAt }); }, + + async importData({ commit }, { guildId, data, caseHandlingMode }) { + return post(`guilds/${guildId}/import`, { + data, + caseHandlingMode, + }); + }, + + async exportData({ commit }, { guildId }) { + return post(`guilds/${guildId}/export`); + }, }, mutations: {