diff --git a/.eslintrc.json b/.eslintrc.json index e472619..c0a3d52 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -109,7 +109,7 @@ "max-len": [ "warn", { - "code": 150, + "code": 180, "ignoreRegExpLiterals": true, "ignoreStrings": true, "ignoreTemplateLiterals": true, diff --git a/README.md b/README.md index 5d6f6e5..b3f23b4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ CONFIG_PATH=./user/config.yml DISCORD_SECRET= DISCORD_TOKEN= -DB_ENCRYPTION_KEY= +ENCRYPTION_KEY= DB_CONNECTION_URL="" HTTP_BIND=3000 HTTP_EXTERNAL= \ No newline at end of file diff --git a/package.json b/package.json index faf7d7a..c331d89 100644 --- a/package.json +++ b/package.json @@ -33,25 +33,29 @@ "@eartharoid/dbf": "^0.0.1", "@eartharoid/dtf": "^2.0.1", "@fastify/cookie": "^6.0.0", + "@fastify/cors": "^8.0.0", "@fastify/jwt": "^5.0.1", "@fastify/oauth2": "^5.0.0", - "@prisma/client": "^3.13.0", - "discord.js": "^13.6.0", - "dotenv": "^16.0.0", - "fastify": "^3.29.0", + "@prisma/client": "^3.15.2", + "discord.js": "^13.8.1", + "dotenv": "^16.0.1", + "fastify": "^4.2.1", "figlet": "^1.5.2", + "keyv": "^4.3.2", "leeks.js": "^0.2.4", "leekslazylogger": "^4.1.7", + "ms": "^2.1.3", "node-dir": "^0.1.17", - "node-fetch": "2", + "node-fetch": "^2.6.7", "semver": "^7.3.7", "terminal-link": "^2.1.1", "yaml": "^1.10.2" }, "devDependencies": { "all-contributors-cli": "^6.20.0", - "eslint": "^8.14.0", + "eslint": "^8.19.0", "eslint-plugin-unused-imports": "^2.0.0", - "prisma": "^3.13.0" + "nodemon": "^2.0.19", + "prisma": "^3.15.2" } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cbd42cd..fdda180 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,6 +116,7 @@ model Guild { createdAt DateTime @default(now()) errorColour String @default("RED") feedback Feedback[] + footer String? @default("Discord Tickets by eartharoid") id String @id @db.VarChar(19) logChannel String? @db.VarChar(19) primaryColour String @default("#009999") diff --git a/scripts/keygen.js b/scripts/keygen.js index 1750a61..bb91e25 100644 --- a/scripts/keygen.js +++ b/scripts/keygen.js @@ -2,7 +2,7 @@ const { randomBytes } = require('crypto'); const { short } = require('leeks.js'); console.log(short( - 'Set the "DB_ENCRYPTION_KEY" environment variable to: \n&1&!f' + + 'Set the "ENCRYPTION_KEY" environment variable to: \n&1&!f' + randomBytes(24).toString('hex') + '&r\n\n&0&!e WARNING &r &e&lIf you lose the encryption key, most of the data in the database will become unreadable, requiring a new key and a full reset.', )); \ No newline at end of file diff --git a/src/client.js b/src/client.js index 3632196..ee713ee 100644 --- a/src/client.js +++ b/src/client.js @@ -1,6 +1,7 @@ const { Client: FrameworkClient }= require('@eartharoid/dbf'); const { Intents } = require('discord.js'); const { PrismaClient } = require('@prisma/client'); +const Keyv = require('keyv'); module.exports = class Client extends FrameworkClient { constructor() { @@ -16,6 +17,7 @@ module.exports = class Client extends FrameworkClient { async login(token) { this.prisma = new PrismaClient(); // this.prisma.$use((params, next) => {}) + this.keyv = new Keyv(); return super.login(token); } diff --git a/src/http.js b/src/http.js index 5fbc234..4988973 100644 --- a/src/http.js +++ b/src/http.js @@ -3,10 +3,17 @@ const oauth = require('@fastify/oauth2'); // const { randomBytes } = require('crypto'); const { short } = require('leeks.js'); const { join } = require('path'); -const { readFiles } = require('node-dir'); +const { readFiles } = require('node-dir'); module.exports = client => { + // cors plugins + fastify.register(require('@fastify/cors'), { + credentials: true, + methods: ['DELETE', 'GET', 'PATCH', 'PUT', 'POST'], + origin: true, + }); + // oauth2 plugin fastify.register(oauth, { callbackUri: `${process.env.HTTP_EXTERNAL}/auth/callback`, @@ -32,14 +39,22 @@ module.exports = client => { signed: false, }, // secret: randomBytes(16).toString('hex'), - secret: process.env.DB_ENCRYPTION_KEY, + secret: process.env.ENCRYPTION_KEY, }); // auth fastify.decorate('authenticate', async (req, res) => { try { const data = await req.jwtVerify(); - if (data.payload.expiresAt < Date.now()) res.redirect('/auth/login'); + // if (data.payload.expiresAt < Date.now()) res.redirect('/auth/login'); + if (data.payload.expiresAt < Date.now()) { + return res.code(401).send({ + error: 'Unauthorised', + message: 'You are not authenticated.', + statusCode: 401, + + }); + } } catch (err) { res.send(err); } @@ -50,14 +65,21 @@ module.exports = client => { const userId = req.user.payload.id; const guildId = req.params.guild; const guild = client.guilds.cache.get(guildId); - const guildMember = await guild.members.fetch(userId); - const isAdmin = guildMember.permissions.has('MANAGE_GUILD'); + if (!guild) { + return res.code(404).send({ + error: 'Not Found', + message: 'The requested resource could not be found.', + statusCode: 404, - if (!isAdmin) { - return res.code(401).send({ - error: 'Unauthorised', - message: 'User is not authorised for this action', - statusCode: 401, + }); + } + const guildMember = await guild.members.fetch(userId); + const isAdmin = guildMember?.permissions.has('MANAGE_GUILD'); + if (!guildMember || !isAdmin) { + return res.code(403).send({ + error: 'Forbidden', + message: 'You are not permitted for this action.', + statusCode: 403, }); } @@ -119,7 +141,7 @@ module.exports = client => { } // start server - fastify.listen(process.env.HTTP_BIND, (err, addr) => { + fastify.listen({ port: process.env.HTTP_BIND }, (err, addr) => { if (err) client.log.error.http(err); else client.log.success.http(`Listening at ${addr}`); }); diff --git a/src/index.js b/src/index.js index a5bd741..3b00fac 100644 --- a/src/index.js +++ b/src/index.js @@ -41,8 +41,8 @@ if (!semver.satisfies(process.versions.node, pkg.engines.node)) { process.exit(1); } -if (process.env.DB_ENCRYPTION_KEY === undefined) { - console.log('\x07' + colours.redBright('Error: The "DB_ENCRYPTION_KEY" environment variable is not set.\nRun "npm run keygen" to generate a key, or set it to "false" to disable encryption (not recommended).')); +if (process.env.ENCRYPTION_KEY === undefined) { + console.log('\x07' + colours.redBright('Error: The "ENCRYPTION_KEY" environment variable is not set.\nRun "npm run keygen" to generate a key, or set it to "false" to disable encryption (not recommended).')); process.exit(1); } diff --git a/src/listeners/client/ready.js b/src/listeners/client/ready.js index b6965b4..5ec8ed9 100644 --- a/src/listeners/client/ready.js +++ b/src/listeners/client/ready.js @@ -11,6 +11,7 @@ module.exports = class extends Listener { } run() { + process.title = this.client.user.tag + ' [Discord Tickets]'; this.client.log.success('Connected to Discord as "%s"', this.client.user.tag); } -}; \ No newline at end of file +}; diff --git a/src/routes/api/admin/guilds/[guild]/categories/index.js b/src/routes/api/admin/guilds/[guild]/categories/index.js index b702ba4..ed0776e 100644 --- a/src/routes/api/admin/guilds/[guild]/categories/index.js +++ b/src/routes/api/admin/guilds/[guild]/categories/index.js @@ -1,6 +1,6 @@ module.exports.get = fastify => ({ handler: async (req, res) => { - /** @type {import('../../../../../../client')} */ + /** @type {import('client')} */ const client = res.context.config.client; const categories = await client.prisma.guild.findUnique({ where: { id: req.params.guild } }).categories(); @@ -12,7 +12,7 @@ module.exports.get = fastify => ({ module.exports.post = fastify => ({ handler: async (req, res) => { - /** @type {import('../../../../../../client')} */ + /** @type {import('client')} */ const client = res.context.config.client; const user = await client.users.fetch(req.user.payload.id); diff --git a/src/routes/api/admin/guilds/[guild]/data.js b/src/routes/api/admin/guilds/[guild]/data.js new file mode 100644 index 0000000..0a3e8c8 --- /dev/null +++ b/src/routes/api/admin/guilds/[guild]/data.js @@ -0,0 +1,13 @@ +module.exports.get = fastify => ({ + handler: async (req, res) => { + /** @type {import('client')} */ + const client = res.context.config.client; + const id = req.params.guild; + const guild = client.guilds.cache.get(id) ?? {}; + const { query } = req.query; + if (!query) return {}; + const data = query.split(/\./g).reduce((acc, part) => acc && acc[part], guild); + return data; + }, + onRequest: [fastify.authenticate, fastify.isAdmin], +}); diff --git a/src/routes/api/admin/guilds/[guild]/index.js b/src/routes/api/admin/guilds/[guild]/index.js index fe54f10..4c51f79 100644 --- a/src/routes/api/admin/guilds/[guild]/index.js +++ b/src/routes/api/admin/guilds/[guild]/index.js @@ -1,40 +1,53 @@ -module.exports.delete = fastify => ({ - handler: async (req, res) => { - /** @type {import('../../../../../client')} */ - const client = res.context.config.client; - - await client.prisma.guild.delete({ where: { id: req.params.guild } }); - const settings = await client.prisma.guild.create({ data: { id: req.params.guild } }); - - res.send(settings); - }, - onRequest: [fastify.authenticate, fastify.isAdmin], -}); +/* eslint-disable no-underscore-dangle */ +const ms = require('ms'); module.exports.get = fastify => ({ handler: async (req, res) => { - /** @type {import('../../../../../client')} */ + /** @type {import("client")} */ const client = res.context.config.client; + const id = req.params.guild; + const cacheKey = `cache/stats/guild:${id}`; + let cached = await client.keyv.get(cacheKey); - const settings = await client.prisma.guild.findUnique({ where: { id: req.params.guild } }) ?? - await client.prisma.guild.create({ data: { id: req.params.guild } }); + if (!cached) { + const guild = client.guilds.cache.get(id); + const settings = await client.prisma.guild.findUnique({ where: { id } }) ?? + await client.prisma.guild.create({ data: { id } }); + const categories = await client.prisma.category.findMany({ + select: { + _count: { select: { tickets: true } }, + id: true, + name: true, + }, + where: { guildId: id }, + }); + const tickets = await client.prisma.ticket.findMany({ + select: { + createdAt: true, + firstResponseAt: true, + }, + where: { guildId: id }, + }); + cached = { + createdAt: settings.createdAt, + id: guild.id, + logo: guild.iconURL(), + name: guild.name, + stats: { + avgResponseTime: ms(tickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / tickets.length), + categories: categories.map(c => ({ + id: c.id, + name: c.name, + tickets: c._count.tickets, + })), + tags: await client.prisma.tag.count({ where: { guildId: id } }), + tickets: tickets.length, + }, + }; + await client.keyv.set(cacheKey, cached, ms('5m')); + } - res.send(settings); - }, - onRequest: [fastify.authenticate, fastify.isAdmin], -}); - -module.exports.patch = fastify => ({ - handler: async (req, res) => { - /** @type {import('../../../../../client')} */ - const client = res.context.config.client; - - const settings = await client.prisma.guild.update({ - data: req.body, - where: { id: req.params.guild }, - }); - - res.send(settings); + return cached; }, onRequest: [fastify.authenticate, fastify.isAdmin], }); \ 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 new file mode 100644 index 0000000..6004ad5 --- /dev/null +++ b/src/routes/api/admin/guilds/[guild]/settings.js @@ -0,0 +1,40 @@ +module.exports.delete = fastify => ({ + handler: async (req, res) => { + /** @type {import('client')} */ + const client = res.context.config.client; + const id = req.params.guild; + await client.prisma.guild.delete({ where: { id } }); + const settings = await client.prisma.guild.create({ data: { id } }); + + return settings; + }, + onRequest: [fastify.authenticate, fastify.isAdmin], +}); + +module.exports.get = fastify => ({ + handler: async (req, res) => { + /** @type {import('client')} */ + const client = res.context.config.client; + const id = req.params.guild; + const settings = await client.prisma.guild.findUnique({ where: { id } }) ?? + await client.prisma.guild.create({ data: { id } }); + + return settings; + }, + onRequest: [fastify.authenticate, fastify.isAdmin], +}); + +module.exports.patch = fastify => ({ + handler: async (req, res) => { + /** @type {import('client')} */ + const client = res.context.config.client; + const id = req.params.guild; + const settings = await client.prisma.guild.update({ + data: req.body, + where: { id }, + }); + + return settings; + }, + onRequest: [fastify.authenticate, fastify.isAdmin], +}); \ No newline at end of file diff --git a/src/routes/api/admin/guilds/index.js b/src/routes/api/admin/guilds/index.js index 54c5dc9..11ecb7e 100644 --- a/src/routes/api/admin/guilds/index.js +++ b/src/routes/api/admin/guilds/index.js @@ -4,6 +4,7 @@ module.exports.get = fastify => ({ const guilds = client.guilds.cache .filter(async guild => { const member = await guild.members.fetch(req.user.payload.id); + if (!member) return false; return member.permissions.has('MANAGE_GUILD'); }) .map(guild => ({ diff --git a/src/routes/api/client.js b/src/routes/api/client.js new file mode 100644 index 0000000..160ef5e --- /dev/null +++ b/src/routes/api/client.js @@ -0,0 +1,39 @@ +const ms = require('ms'); + +module.exports.get = () => ({ + handler: async (req, res) => { + /** @type {import("client")} */ + const client = res.context.config.client; + const cacheKey = 'cache/stats/client'; + let cached = await client.keyv.get(cacheKey); + + if (!cached) { + const tickets = await client.prisma.ticket.findMany({ + select: { + createdAt: true, + firstResponseAt: true, + }, + }); + const users = await client.prisma.user.findMany({ select: { messageCount: true } }); + cached = { + avatar: client.user.avatarURL(), + discriminator: client.user.discriminator, + id: client.user.id, + stats: { + activatedUsers: users.length, + archivedMessages: users.reduce((total, user) => total + user.messageCount, 0), // don't count archivedMessage table rows, they get deleted + avgResponseTime: ms(tickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / tickets.length), + categories: await client.prisma.category.count(), + guilds: client.guilds.cache.size, + members: client.guilds.cache.reduce((t, g) => t + g.memberCount, 0), + tags: await client.prisma.tag.count(), + tickets: tickets.length, + }, + username: client.user.username, + }; + await client.keyv.set(cacheKey, cached, ms('5m')); + } + + return cached; + }, +}); \ No newline at end of file