diff --git a/package.json b/package.json index c331d89..d7d6a6c 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,14 @@ "dependencies": { "@eartharoid/dbf": "^0.0.1", "@eartharoid/dtf": "^2.0.1", + "@eartharoid/i18n": "^1.0.4", "@fastify/cookie": "^6.0.0", "@fastify/cors": "^8.0.0", "@fastify/jwt": "^5.0.1", "@fastify/oauth2": "^5.0.0", - "@prisma/client": "^3.15.2", + "@prisma/client": "^4.0.0", + "cryptr": "^6.0.3", + "deep-object-diff": "^1.1.7", "discord.js": "^13.8.1", "dotenv": "^16.0.1", "fastify": "^4.2.1", @@ -56,6 +59,6 @@ "eslint": "^8.19.0", "eslint-plugin-unused-imports": "^2.0.0", "nodemon": "^2.0.19", - "prisma": "^3.15.2" + "prisma": "^4.0.0" } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fdda180..06f40cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -109,7 +109,8 @@ model Feedback { } model Guild { - autoTag Json? + autoClose Int? + autoTag Json @default("[]") archive Boolean @default(true) blocklist Json @default("[]") categories Category[] @@ -118,11 +119,14 @@ model Guild { feedback Feedback[] footer String? @default("Discord Tickets by eartharoid") id String @id @db.VarChar(19) + locale String @default("en-GB") logChannel String? @db.VarChar(19) primaryColour String @default("#009999") + staleAfter Int? successColour String @default("GREEN") tags Tag[] tickets Ticket[] + workingHours Json @default("[null, null, null, null, null, null, null]") @@map("guilds") } diff --git a/src/client.js b/src/client.js index ee713ee..b2683aa 100644 --- a/src/client.js +++ b/src/client.js @@ -2,9 +2,14 @@ const { Client: FrameworkClient }= require('@eartharoid/dbf'); const { Intents } = require('discord.js'); const { PrismaClient } = require('@prisma/client'); const Keyv = require('keyv'); +const I18n = require('@eartharoid/i18n'); +const fs = require('fs'); +const { join } = require('path'); +const YAML = require('yaml'); +const middleware = require('./lib/prisma'); module.exports = class Client extends FrameworkClient { - constructor() { + constructor(config, log) { super({ intents: [ Intents.FLAGS.GUILDS, @@ -12,11 +17,26 @@ module.exports = class Client extends FrameworkClient { Intents.FLAGS.GUILD_MESSAGES, ], }); + + const locales = {}; + fs.readdirSync(join(__dirname, 'i18n')) + .filter(file => file.endsWith('.yml')) + .forEach(file => { + const data = fs.readFileSync(join(__dirname, 'i18n/' + file), { encoding: 'utf8' }); + const name = file.slice(0, file.length - 4); + locales[name] = YAML.parse(data); + }); + + /** @type {I18n} */ + this.i18n = new I18n('en-GB', locales); + this.config = config; + this.log = log; } async login(token) { + /** @type {PrismaClient} */ this.prisma = new PrismaClient(); - // this.prisma.$use((params, next) => {}) + this.prisma.$use(middleware); this.keyv = new Keyv(); return super.login(token); } diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml new file mode 100644 index 0000000..8809366 --- /dev/null +++ b/src/i18n/en-GB.yml @@ -0,0 +1,19 @@ +command: +log: + admin: + description: + joined: '{user} {verb} {targetType}' + target: + category: 'a category' + settings: 'the settings' + differences: 'Differences' + title: + joined: '{targetType} {verb}' + target: + category: 'Category' + settings: 'Settings' + verb: + create: 'created' + delete: 'deleted' + update: 'updated' + ticket: \ No newline at end of file diff --git a/src/index.js b/src/index.js index 3b00fac..9411cb0 100644 --- a/src/index.js +++ b/src/index.js @@ -72,9 +72,7 @@ process.on('unhandledRejection', error => { log.error(error); }); -const client = new Client(); -client.config = config; -client.log = log; +const client = new Client(config, log); client.login().then(() => { http(client); }); \ No newline at end of file diff --git a/src/lib/logging.js b/src/lib/logging.js new file mode 100644 index 0000000..a586614 --- /dev/null +++ b/src/lib/logging.js @@ -0,0 +1,97 @@ +const { MessageEmbed } = require('discord.js'); + +/** + * @param {import("client")} client + * @param {string} guildId + * @returns {import("discord.js").TextChannel?} +*/ +async function getLogChannel(client, guildId) { + const { logChannel: channelId } = await client.prisma.guild.findUnique({ + select: { logChannel: true }, + where: { id: guildId }, + }); + return channelId && client.channels.cache.get(channelId); +} + +/** + * @param {import("client")} client + * @param {*} target + * @returns {string} target.type + * @returns {string} target.id +*/ +async function getTargetName(client, target) { + if (target.type === 'settings') { + return client.guilds.cache.get(target.id).name; + } else { + const row = await client.prisma[target.type].findUnique({ where: { id: target.id } }); + return row.name ?? target.id; + } +} + +/** + * @param {import("client")} client + * @param {object} details + * @param {string} details.guildId + * @param {string} details.userId + * @param {string} details.action +*/ +async function logAdminEvent(client, { + guildId, userId, action, target, diff, +}) { + const user = await client.users.fetch(userId); + client.log.info(`${user.tag} ${action}d ${target.type} ${target.id}`); + const settings = await client.prisma.guild.findUnique({ + select: { + footer: true, + locale: true, + logChannel: true, + }, + where: { id: guildId }, + }); + if (!settings.logChannel) return; + const getMessage = client.i18n.getLocale(settings.locale); + const i18nOptions = { + user: `<@${user.id}>`, + verb: getMessage(`log.admin.verb.${action}`), + }; + const channel = client.channels.cache.get(settings.logChannel); + if (!channel) return; + const targetName = await getTargetName(client, target); + return await channel.send({ + embeds: [ + new MessageEmbed() + .setColor('ORANGE') + .setAuthor({ + iconURL: user.avatarURL(), + name: user.username, + }) + .setTitle(getMessage('log.admin.title.joined', { + ...i18nOptions, + targetType: getMessage(`log.admin.title.target.${target.type}`), + verb: getMessage(`log.admin.verb.${action}`), + })) + .setDescription(getMessage('log.admin.description.joined', { + ...i18nOptions, + targetType: getMessage(`log.admin.description.target.${target.type}`), + verb: getMessage(`log.admin.verb.${action}`), + })) + .addField(getMessage(`log.admin.title.target.${target.type}`), targetName), + // .setFooter({ + // iconURL: client.guilds.cache.get(guildId).iconURL(), + // text: settings.footer, + // }), + ...[ + action === 'update' && diff && + new MessageEmbed() + .setColor('ORANGE') + .setTitle(getMessage('log.admin.differences')) + .setDescription(`\`\`\`json\n${JSON.stringify(diff)}\n\`\`\``), + ], + ], + }); +} + +module.exports = { + getLogChannel, + logAdminEvent, +}; \ No newline at end of file diff --git a/src/lib/prisma.js b/src/lib/prisma.js new file mode 100644 index 0000000..81c1e42 --- /dev/null +++ b/src/lib/prisma.js @@ -0,0 +1,41 @@ +const Cryptr = require('cryptr'); +const cryptr = new Cryptr(process.env.ENCRYPTION_KEY); +const fields = [ + 'name', + 'content', + 'username', + 'displayName', + 'channelName', + 'openingMessage', + 'description', + 'value', + 'placeholder', + 'closedReason', + 'topic', + 'comment', + 'label', + 'regex', +]; +const shouldEncrypt = ['create', 'createMany', 'update', 'updateMany', 'upsert']; +const shouldDecrypt = ['findUnique', 'findFirst', 'findMany']; + +module.exports = async (params, next) => { + if (params.args.data && shouldEncrypt.includes(params.action)) { + for (const field of fields) { + if (field in params.args.data) { + params.args.data[field] = cryptr.encrypt(params.args.data[field]); + } + } + } + + const result = await next(params); + + if (result && shouldDecrypt.includes(params.action)) { + for (const field of fields) { + if (field in result) { + result[field] = cryptr.decrypt(params.result[field]); + } + } + } + return result; +}; \ No newline at end of file diff --git a/src/lib/strings.js b/src/lib/strings.js new file mode 100644 index 0000000..12c79f1 --- /dev/null +++ b/src/lib/strings.js @@ -0,0 +1 @@ +module.exports.capitalise = string => string.charAt(0).toUpperCase() + string.slice(1); \ No newline at end of file diff --git a/src/routes/api/admin/guilds/[guild]/settings.js b/src/routes/api/admin/guilds/[guild]/settings.js index 6004ad5..32887a7 100644 --- a/src/routes/api/admin/guilds/[guild]/settings.js +++ b/src/routes/api/admin/guilds/[guild]/settings.js @@ -1,3 +1,6 @@ +const { logAdminEvent } = require('../../../../../lib/logging.js'); +const { updatedDiff } = require('deep-object-diff'); + module.exports.delete = fastify => ({ handler: async (req, res) => { /** @type {import('client')} */ @@ -5,7 +8,15 @@ module.exports.delete = fastify => ({ const id = req.params.guild; await client.prisma.guild.delete({ where: { id } }); const settings = await client.prisma.guild.create({ data: { id } }); - + logAdminEvent(client, { + action: 'delete', + guildId: id, + target: { + id, + type: 'settings', + }, + userId: req.user.payload.id, + }); return settings; }, onRequest: [fastify.authenticate, fastify.isAdmin], @@ -29,11 +40,21 @@ module.exports.patch = fastify => ({ /** @type {import('client')} */ const client = res.context.config.client; const id = req.params.guild; + const original = await client.prisma.guild.findUnique({ where: { id } }); const settings = await client.prisma.guild.update({ data: req.body, where: { id }, }); - + logAdminEvent(client, { + action: 'update', + diff: updatedDiff(original, settings), + guildId: id, + target: { + id, + type: 'settings', + }, + userId: req.user.payload.id, + }); return settings; }, onRequest: [fastify.authenticate, fastify.isAdmin], diff --git a/src/routes/api/locales.js b/src/routes/api/locales.js new file mode 100644 index 0000000..2da51c1 --- /dev/null +++ b/src/routes/api/locales.js @@ -0,0 +1,7 @@ +module.exports.get = () => ({ + handler: async (req, res) => { + /** @type {import("client")} */ + const client = res.context.config.client; + return client.i18n.locales; + }, +}); \ No newline at end of file