diff --git a/db/migrations/20201127100038_add-bot-fields.js b/db/migrations/20201127100038_add-bot-fields.js new file mode 100644 index 0000000..eb3c283 --- /dev/null +++ b/db/migrations/20201127100038_add-bot-fields.js @@ -0,0 +1,13 @@ +exports.up = async function (knex) { + await knex.schema.table('lists', function (t) { + t.text('add_bot'); + t.text('add_bot_key'); + }); +}; + +exports.down = async function (knex) { + await knex.schema.table('lists', function (t) { + t.dropColumn('add_bot'); + t.dropColumn('add_bot_key'); + }); +}; diff --git a/package.json b/package.json index 488ed6d..a7b7c51 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "bulma-tooltip": "^2.0.2", "chai": "^4.2.0", "chai-http": "^4.3.0", - "codemirror": "^5.49.2", + "codemirror": "^5.62.0", "cookie-parser": "^1.4.4", "cookie-session": "^1.3.3", "cors": "^2.8.5", @@ -41,6 +41,7 @@ "express": "^4.17.1", "express-minify": "^1.0.0", "i18n": "^0.8.3", + "joi": "^17.4.0", "kill-port": "^1.6.0", "knex": "^0.19.4", "lighthouse": "^5.2.0", diff --git a/src/Assets/js/bot/add.js b/src/Assets/js/bot/add.js new file mode 100644 index 0000000..fffc108 --- /dev/null +++ b/src/Assets/js/bot/add.js @@ -0,0 +1,25 @@ +function isInt(value) { + return !isNaN(value) && + parseInt(Number(value)) == value && + !isNaN(parseInt(value, 10)); +} + +function isSnowflake(value) { + return isInt(value) && value.length >= 16; +} + +document.getElementById('add').addEventListener('submit', function (e) { + var value = document.getElementById('botid'); + if (!value) { + var inputs = document.querySelectorAll('input[type=checkbox][id^=list-]:checked'); + if (inputs.length == 0) { + document.getElementById('error').innerText = 'You must submit to at least one list!'; + return e.preventDefault(); + } + } + if (!value.value) { return e.preventDefault(); } + if (!isSnowflake(value.value)) { + document.getElementById('error').innerText = 'Please enter a valid snowflake ID to add'; + return e.preventDefault(); + } +}); diff --git a/src/Assets/js/joiSchema.js b/src/Assets/js/joiSchema.js new file mode 100644 index 0000000..cb7d0ad --- /dev/null +++ b/src/Assets/js/joiSchema.js @@ -0,0 +1,61 @@ +const Joi = require('joi'); + +const schema = Joi.object({ + name: Joi.string() + .min(1) + .max(25) + .required(), + + prefix: Joi.string() + .min(1) + .max(25) + .required(), + + library: Joi.string() + .valid('discljord', 'aegis.cpp', 'discordcr', 'Discord.Net', 'DSharpPlus', 'dscord', 'DiscordGo', + 'DisGord', 'catnip', 'Discord4J', 'Javacord', 'JDA', 'discord.js', 'eris', 'Discord.jl', 'Discordia', + 'Discordnim', 'RestCord', 'discord.py', 'disco', 'discordrb', 'discord-rs', 'Serenity', 'AckCord', 'Sword', 'disco') + .required(), + + short_desc: Joi.string() + .min(1) + .max(100) + .required(), + + long_desc_plain: Joi.string() + .min(1) + .max(5000) + .required(), + + long_desc_rich: Joi.string() + .min(1) + .max(5000) + .required(), + + invite_url: Joi.string() + .min(1) + .required(), + + website_url: Joi.string() + .allow('') + .optional(), + + support_url: Joi.string() + .allow('') + .optional(), + + donate_url: Joi.string() + .allow('') + .optional(), + + github_url: Joi.string() + .allow('') + .optional(), + + nsfw: Joi.boolean(), + + slash_commands: Joi.boolean() + +}); + +exports.schema = schema; diff --git a/src/Dynamic/Includes/forms/addBot.pug b/src/Dynamic/Includes/forms/addBot.pug new file mode 100644 index 0000000..8358f07 --- /dev/null +++ b/src/Dynamic/Includes/forms/addBot.pug @@ -0,0 +1,133 @@ +form#add(method='POST' action='/bot/add/' + bot.id) + .columns + .column.is-half-desktop.is-full-mobile + .card.is-box-shadow + .card-content + .field + div.media + div.media-left + img(class='image lazyload' src='https://cdn.discordapp.com/avatars/' + bot.id + '/' + bot.avatar + '.png', alt=bot.username + ' Icon') + div.media-content + label.label(for='name') Bot Name * + p (Max Length: 25) + .control + input.input(maxlength='25' type='text' id='name' name='name' value=bot.username, required) + label.label(for='id') Bot ID + .control + input.input(type='text' id='id' name='id' value=bot.id, disabled) + label.label(for='owner_id') Owner ID + .control + input.input(type='text' id='owner_id' name='owner_id' value=user.id, disabled) + label.label(for='nsfw') NSFW + p Please tick if the bot (or avatar) is NSFW + div(tabindex='0' role='button' class='checkbox always disallowed') + div(class='checkbox-inner') + input(type='checkbox' id='nsfw' name='nsfw' checked=false) + span + label.label(for='slash_commands') Slash Commands + p Please tick if the bot requires + code applications.commands + div(tabindex='0' role='button' class='checkbox always disallowed') + div(class='checkbox-inner') + input(type='checkbox' id='slash_commands' name='slash_commands' checked=false) + span + label.label(for='prefix') Prefix * + p (Max Length: 25) + .control + input.input(maxlength='25' type='text' id='prefix' name='prefix' required) + label.label(for='library') Library * + .control + .select + select(name='library' id='library', required) + option(value='' selected disabled hidden) Select a library + option(value='' disabled) Clojure + option(value='discljord') discljord + option(value='' disabled) C++ + option(value='aegis.cpp') aegis.cpp + option(value='' disabled) Crystal + option(value='discordcr') discordcr + option(value='' disabled) C# + option(value='Discord.Net') Discord.Net + option(value='DSharpPlus') DSharpPlus + option(value='' disabled) D + option(value='dscord') dscord + option(value='' disabled) Go + option(value='DiscordGo') DiscordGo + option(value='DisGord') DisGord + option(value='' disabled) Java + option(value='catnip') catnip + option(value='Discord4J') Discord4J + option(value='Javacord') Javacord + option(value='JDA') JDA + option(value='' disabled) JavaScript + option(value='discord.js') discord.js + option(value='Eris') Eris + option(value='' disabled) Julia + option(value='Discord.jl') Discord.jl + option(value='' disabled) Lua + option(value='Discordia') Discordia + option(value='' disabled) Nim + option(value='discordnim') discordnim + option(value='' disabled) PHP + option(value='RestCord') RestCord + option(value='' disabled) Python + option(value='discord.py') discord.py + option(value='disco') disco + option(value='' disabled) Ruby + option(value='discordrb') discordrb + option(value='' disabled) Rust + option(value='discord-rs') discord-rs + option(value='Serenity') Serenity + option(value='' disabled) Scala + option(value='AckCord') AckCord + option(value='' disabled) Swift + option(value='Sword') Sword + option(value='' disabled) TypeScript + option(value='Discordeno') disco + label.label(for='short_desc') Short Description * + p (Max Length: 100) + .control + input.input(maxlength='100' type='text' id='short_desc' name='short_desc' required) + label.label(for='long_dec_plain') Long Description Plain * + p (Max Length: 5000) + p This long description field will be used on bot lists that do not support Markdown or HTML in their long descriptions. + .control + textarea.textarea#long_desc_plain(maxlength='5000' type='text' name='long_desc_plain' required) + label.label(for='long_desc_rich') Long Description Rich * + p (Max Length: 5000) + p This long description field will be used on bot lists that support Markdown or HTML in their long descriptions. + .control + textarea.textarea#long_desc_rich(maxlength='5000' type='text' name='long_desc_rich' required) + label.label(for='invite_url') Bot Invite URL * + .control + input.input(type='link' pattern='https?://.+' id='invite_url' name='invite_url' value="https://discord.com/oauth2/authorize?client_id=" + bot.id + "&scope=bot" required) + label.label(for='website_url') Website URL + .control + input.input(type='link' pattern='https?://.+' id='website_url' name='website_url') + label.label(for='support_url') Support (Server) URL + .control + input.input(type='link' pattern='https?://.+' id='support_url' name='support_url') + label.label(for='donate_url') Donate URL + .control + input.input(type='link' pattern='https?://.+' id='donate_url' name='donate_url') + label.label(for='github_url') GitHub URL + .control + input.input(type='link' pattern='https?://.+' id='github_url' name='github_url') + + .column.is-half-desktop.is-full-mobile.top + .card.is-box-shadow + .card-content + .hero + .hero-body.hero-slim + .has-text-centered + h1.title.is-size-6#error + h1.title.is-size-4 Supported Lists + for list in supportedLists + .columns(class='has-margin-top') + .checkbox.always(tabindex='0' role='button' class='interactive plus') + div(class='checkbox-inner') + input(type='checkbox', id=`list-${list.name}`, name=list.name checked=true) + span + span #{list.name} + br + input.button.is-brand(type='submit' value='Add bot to all supported bot lists' disabled=supportedLists.length == 0) diff --git a/src/Dynamic/Includes/forms/list.pug b/src/Dynamic/Includes/forms/list.pug index 47982a0..bdf724e 100644 --- a/src/Dynamic/Includes/forms/list.pug +++ b/src/Dynamic/Includes/forms/list.pug @@ -99,6 +99,14 @@ form(method='POST') label.label(for='api_shards') API Post - Shards Array Field Name .control input.input#api_shards(type='text', name='api_shards', value=data.api_shards) + .field + label.label(for='add_bot') API Post - Add Bot URL + .control + input.input#add_bot(type='text', name='add_bot', value=data.add_bot) + .field + label.label(for='add_bot_key') API Post - Add Bot Key + .control + input.input#add_bot_key(type='text', name='add_bot_key', value=data.add_bot_key) .column .field @@ -125,4 +133,4 @@ form(method='POST') include ../checkboxes .control(style='margin-top: 10px') button.button.is-brand Submit - + \ No newline at end of file diff --git a/src/Dynamic/Includes/navbar.pug b/src/Dynamic/Includes/navbar.pug index b1c7e96..97b1368 100644 --- a/src/Dynamic/Includes/navbar.pug +++ b/src/Dynamic/Includes/navbar.pug @@ -29,11 +29,11 @@ form#navbar_lists_search input#navbar_lists_query.navbar-item.input(type='text', placeholder='Name or URL') a.navbar-item(onclick='navbar_lists_go()') Search Lists - //.navbar-item.has-dropdown.is-hoverable - // a.navbar-link Bots - // .navbar-dropdown.is-boxed.is-box-shadow - // a.navbar-item Bot Lookup - // a.navbar-item Add New Bot + .navbar-item.has-dropdown.is-hoverable + a.navbar-link Bots + .navbar-dropdown.is-boxed.is-box-shadow + a.navbar-item(href='/bot/add') Add New Bot + //a.navbar-item Bot Lookup .navbar-item.has-dropdown.is-hoverable a.navbar-link API .navbar-dropdown.is-boxed.is-box-shadow @@ -53,6 +53,3 @@ else a.navbar-item(href='/auth') Sign in with Discord // a.navbar-item Languages (Coming soon... :) ) - - - diff --git a/src/Dynamic/Includes/sidebar.pug b/src/Dynamic/Includes/sidebar.pug index e09e328..8deea31 100644 --- a/src/Dynamic/Includes/sidebar.pug +++ b/src/Dynamic/Includes/sidebar.pug @@ -29,6 +29,11 @@ aside.menu.is-box-shadow(role='navigation', aria-label='main navigation') form#navbar_lists_search input#navbar_lists_query.navbar-item.input(type='text', placeholder='Name or URL') a(onclick='navbar_lists_go()') Search Lists + li + p.menu-label Bots + ul.menu-list + li + a(href='/bot/add') Add Bot li p.menu-label API @@ -71,6 +76,3 @@ aside.menu.is-box-shadow(role='navigation', aria-label='main navigation') else li a(href='/auth') Sign in with Discord - - - diff --git a/src/Dynamic/bot/add.pug b/src/Dynamic/bot/add.pug new file mode 100644 index 0000000..e973150 --- /dev/null +++ b/src/Dynamic/bot/add.pug @@ -0,0 +1,60 @@ +extends ../layout +block content + section.section + .container + - if(joi_error) + .card.is-box-shadow + .card-content + p#error + p Validation Error: #{details} + - else + - if(!error && bot) + include ../Includes/forms/addBot + - else + .hero + .hero-body.hero-slim + .has-text-centered + h1.title.is-size-4 Add a New Bot + h5.title.is-size-5 Add a new bot to all lists that support BotBlock + h6.title.is-size-6 Submit a bot to all lists that accept BotBlock with standard information for all listings. + + - if(after) + .columns + .column.is-half-desktop.is-full-mobile + .card.is-box-shadow + .has-text-centered + .card-content + p.has-text-white + | Submission Status + br + for result in results + if !result.error + p.has-text-success #{result.name} + else + p.has-text-danger #{result.name} + if result.message + p Message: #{result.message} + else + p Message: No message + hr + - else + .columns + .column.is-half-desktop.is-full-mobile + form#add(method="POST") + .card.is-box-shadow + .card-content + p#error + - if(error) + p This bot was not found on Discord + .field + label.label(for='botid') Bot Client ID to add to bot lists + .control + input.input#botid(name='botid' type='text') + h1.is-brand Please make sure to join the bot list servers before adding your bot as many require bot owners to be in the server. + input.button.is-brand(type='submit' value='Begin adding your bot') + + +block footer + script(defer src='/assets/js/checkboxes/interactive.js') + script(src='/assets/js/bot/add.js') + \ No newline at end of file diff --git a/src/Routes/API.js b/src/Routes/API.js index 80f500b..6685040 100644 --- a/src/Routes/API.js +++ b/src/Routes/API.js @@ -175,6 +175,8 @@ class APIRoute extends BaseRoute { if (!lists) return res.status(200).json({}); const data = {}; for (let i = 0; i < lists.length; i++) { + delete lists[i].add_bot; + delete lists[i].add_bot_key; // If filtering: Only present API values, drop if all values are null or defunct if (req.query.filter === 'true') { if (lists[i].defunct) continue; @@ -200,6 +202,8 @@ class APIRoute extends BaseRoute { try { const data = await getList(this.db, req.params.id); if (!data) return res.status(404).json({ error: true, status: 404, message: 'List not found' }); + delete data.add_bot; + delete data.add_bot_key; res.status(200).json({ ...data }); } catch (e) { handleError(this.db, req, res, e.stack, true); diff --git a/src/Routes/Bot.js b/src/Routes/Bot.js new file mode 100644 index 0000000..4659d84 --- /dev/null +++ b/src/Routes/Bot.js @@ -0,0 +1,103 @@ +const BaseRoute = require('../Structure/BaseRoute'); +const JoiSchema = require('../Assets/js/joiSchema'); +const axios = require('axios'); +const handleError = require('../Util/handleError'); + +class BotRoute extends BaseRoute { + constructor(client, db) { + super('/bot'); + this.router = require('express').Router(); + this.client = client; + this.db = db; + this.routes(); + } + + async postBot(req, res) { + const responses = []; + const target_lists = []; + await this.db.select().from('lists').whereNot({ add_bot: null, add_bot_key: null }).then(lists => { + for (const list of lists) { + if (req.body[list.name] === 'on') { + target_lists.push(list); + } + delete req.body[list.name]; + } + }).catch((e) => { + handleError(this.db, req, res, e.stack); + }); + + const { error } = await JoiSchema.schema.validate(req.body); + if (error !== undefined) { + return res.render('bot/add', { joi_error: true, details: error.details[0].message }); + } + + const [bot, user] = await Promise.all([this.client.getUser(req.params.id), this.client.getUser(req.session.user.id)]); + + user.locale = req.session.user.locale; + user.mfa_enabled = req.session.user.mfa_enabled; + user.premium_type = req.session.user.premium_type; + req.body.id = bot.id; + req.body.owner_id = user.id; + req.body.owner_oauth = req.session.user.access_token; + req.body.bot_details = bot; + req.body.owner_details = user; + + for (const target_list of target_lists) { + return axios.post(target_list.add_bot, req.body, { + headers: { Authorization: target_list.add_bot_key } + }).then(resp => { + responses.push({ ...resp.data, error: false, name: target_list.name }); + return responses; + }).catch(err => { + responses.push({ ...err.response.data, error: true, name: target_list.name }); + return responses; + }); + } + } + + routes() { + this.router.get('/add', this.requiresAuth.bind(this), (req, res) => { + res.render('bot/add'); + }); + + + this.router.post('/add', this.requiresAuth.bind(this), async (req, res) => { + try { + const data = await this.client.getUser(req.body.botid); + if (!data['bot']) { res.render('bot/add', { error: true }); } + this.db.select().from('lists').whereNot({ add_bot: null, add_bot_key: null }).then(supportedLists => { + res.render('bot/add', { error: false, bot: data, supportedLists: supportedLists }); + }).catch((e) => { + handleError(this.db, req, res, e.stack); + }); + } catch { + res.render('bot/add', { error: true }); + } + }); + + + this.router.post('/add/:id', this.requiresAuth.bind(this), async (req, res) => { + if (req.body.nsfw == 'on') { + req.body.nsfw = true; + } else { + req.body.nsfw = false; + } + + if (req.body.slash_commands == 'on') { + req.body.slash_commands = true; + } else { + req.body.slash_commands = false; + } + + const results = await this.postBot(req, res); + + res.render('bot/add', { after: true, results: results }); + }); + } + + get getRouter() { + return this.router; + } +} + +module.exports = BotRoute; diff --git a/src/Structure/Discord/Client.js b/src/Structure/Discord/Client.js index f80292d..e5c6a01 100644 --- a/src/Structure/Discord/Client.js +++ b/src/Structure/Discord/Client.js @@ -78,6 +78,7 @@ class Client { if (!config.discord.notifications) return; if (!oldEdit || !newEdit) return; let changes = []; + const hideKeys = ['add_bot', 'add_bot_key']; // Keys to hide from edit log for (const [key, value] of Object.entries(oldEdit)) { let oldValue; let newValue; @@ -89,6 +90,12 @@ class Client { if (newEdit[key] === '' || newEdit[key] === null) newValue = 'None'; else newValue = newEdit[key]; + // Set values to `hidden`, this way we still log the change + if (hideKeys.includes(key)) { + oldValue = 'Hidden'; + newValue = 'Hidden'; + } + changes.push({ key, oldValue, newValue }); } if (changes.length === 0 && addedFeatures.length === 0 && removedFeatures.length === 0) return;