Browse Source

Start working on dashboard

Markus-Rost 4 years ago
parent
commit
25550f1115
8 changed files with 300 additions and 8 deletions
  1. 6 0
      .env.example
  2. 2 2
      README.md
  3. 0 0
      dashboard/index.css
  4. 0 0
      dashboard/index.html
  5. 259 0
      dashboard/index.js
  6. 26 5
      main.js
  7. 5 0
      package-lock.json
  8. 2 1
      package.json

+ 6 - 0
.env.example

@@ -4,6 +4,12 @@
 
 # Your bot token to login your bot
 token="<Discord bot token>"
+# Your bots user ID for the dashboard
+bot="461189216198590464"
+# Your bots client secret for the dashboard
+secret="<Discord client secret>"
+# Your bots return uri for the dashboard
+dashboard="https://settings.wikibot.de/oauth"
 # Your user ID for owner only commands
 owner="243478237710123009"
 # Command prefix for the bot

+ 2 - 2
README.md

@@ -1,4 +1,4 @@
-# Wiki-Bot[<img src="https://weblate.frisk.space/widgets/wiki-bot/-/svg-badge.svg" alt="Translation status" align="right" />](#translations)[<img src="https://github.com/Markus-Rost/discord-wiki-bot/workflows/Node.js CI/badge.svg" alt="Node.js CI" align="right" />](https://github.com/Markus-Rost/discord-wiki-bot/actions)
+# Wiki-Bot[<img src="https://translate.wikibot.de/widgets/wiki-bot/-/svg-badge.svg" alt="Translation status" align="right" />](#translations)[<img src="https://github.com/Markus-Rost/discord-wiki-bot/workflows/Node.js CI/badge.svg" alt="Node.js CI" align="right" />](https://github.com/Markus-Rost/discord-wiki-bot/actions)
 [<img src="https://commons.gamepedia.com/media/9/93/Cursebot.png" alt="Wiki-Bot" align="right" />](https://discord.com/oauth2/authorize?client_id=461189216198590464&permissions=939912256&scope=bot)
 
 **Wiki-Bot** is a bot for [Discord](https://discord.com/) with the purpose to easily link and search [MediaWiki](https://www.mediawiki.org/wiki/MediaWiki) sites like [Gamepedia](https://www.gamepedia.com/) and [Fandom](https://www.fandom.com/) wikis. **Wiki-Bot** shows short descriptions and additional info about pages and is able to resolve redirects and follow interwiki links.
@@ -97,7 +97,7 @@ Requirements to add a recent changes webhook:
 Use `!wiki voice` to get the format for the role name.
 
 ## Translations
-[<img src="https://weblate.frisk.space/widgets/wiki-bot/-/multi-auto.svg" alt="Translation status" width="100%" />](https://weblate.frisk.space/engage/wiki-bot/?utm_source=widget)
+[<img src="https://translate.wikibot.de/widgets/wiki-bot/-/multi-auto.svg" alt="Translation status" width="100%" />](https://translate.wikibot.de/engage/wiki-bot/?utm_source=widget)
 
 ## Bot Lists
 [![Wiki-Bot](https://bots.ondiscord.xyz/bots/461189216198590464/embed?theme=dark&showGuilds=true)](https://bots.ondiscord.xyz/bots/461189216198590464)

+ 0 - 0
dashboard/index.css


+ 0 - 0
dashboard/index.html


+ 259 - 0
dashboard/index.js

@@ -0,0 +1,259 @@
+const http = require('http');
+const crypto = require('crypto');
+const db = require('./util/database.js');
+const DiscordOauth2 = require('discord-oauth2');
+const oauth = new DiscordOauth2( {
+	clientId: process.env.bot,
+	clientSecret: process.env.secret,
+	redirectUri: process.env.dashboard
+} );
+
+var messageId = 1;
+var messages = new Map();
+
+process.on( 'message', message => {
+	messages.get(message.id).resolve(message.data);
+	messages.delete(message.id);
+} );
+
+function sendMsg(message) {
+	var id = messageId++;
+	var promise = new Promise( (resolve, reject) => {
+		messages.set(id, {resolve, reject});
+		process.send( {id, data: message} );
+	} );
+	return promise;
+}
+
+/**
+ * @typedef Settings
+ * @property {String} state
+ * @property {String} access_token
+ * @property {User} user
+ * @property {Map<String, Guild>} guilds
+ */
+
+/**
+ * @typedef User
+ * @property {String} id
+ * @property {String} username
+ * @property {String} discriminator
+ * @property {String} avatar
+ * @property {String} locale
+ */
+
+/**
+ * @typedef Guild
+ * @property {String} id
+ * @property {String} name
+ * @property {String} icon
+ * @property {String} permissions
+ */
+
+/**
+ * @type {Map<String, Settings>}
+ */
+var settingsData = new Map();
+
+const server = http.createServer((req, res) => {
+	if ( req.method !== 'GET' ) {
+		res.writeHead(418, {'Content-Type': 'text/html'});
+		res.write( '<img width="400" src="https://http.cat/418"><br><strong>' + http.STATUS_CODES[418] + '</strong>' );
+		return res.end();
+	}
+	res.setHeader('Content-Type', 'text/html');
+	res.setHeader('Content-Language', ['en']);
+	var reqURL = new URL(req.url, process.env.dashboard);
+	if ( reqURL.pathname === '/login' ) {
+		let responseCode = 200;
+		let notice = '';
+		if ( reqURL.searchParams.get('action') === 'failed' ) {
+			responseCode = 400;
+			notice = '<img width="400" src="https://http.cat/' + responseCode + '"><br><strong>Login failed, please try again!</strong><br><br>';
+		}
+		if ( reqURL.searchParams.get('action') === 'missing' ) {
+			responseCode = 404;
+			notice = '<img width="400" src="https://http.cat/' + responseCode + '"><br><strong>404, could not find the page!</strong><br><br>';
+		}
+		let state = crypto.randomBytes(16).toString("hex");
+		while ( settingsData.has(state) ) {
+			state = crypto.randomBytes(16).toString("hex");
+		}
+		let url = oauth.generateAuthUrl( {
+			scope: ['identify', 'guilds'],
+			promt: 'none', state
+		} );
+		res.writeHead(responseCode, {
+			'Set-Cookie': [`wikibot="${state}"`, 'HttpOnly', 'SameSite=Strict']
+		});
+		res.write( notice + `<a href="${url}">Login</a>` );
+		return res.end();
+	}
+	var state = req.headers?.cookie?.split('; ')?.filter( cookie => {
+		return cookie.split('=')[0] === 'wikibot';
+	} )?.map( cookie => cookie.replace( /^wikibot="(\w+)"$/, '$1' ) )?.join();
+	if ( reqURL.pathname === '/logout' ) {
+		settingsData.delete(state);
+		res.writeHead(302, {
+			Location: '/?action=logout',
+			'Set-Cookie': [`wikibot="${state}"`, 'Max-Age=0', 'HttpOnly', 'SameSite=Strict']
+		});
+		return res.end();
+	}
+	if ( reqURL.pathname === '/oauth' ) {
+		if ( state !== reqURL.searchParams.get('state') || !reqURL.searchParams.get('code') ) {
+			res.writeHead(302, {
+				Location: '/login?action=failed'
+			});
+			return res.end();
+		}
+		return oauth.tokenRequest( {
+			scope: ['identify', 'guilds'],
+			code: reqURL.searchParams.get('code'),
+			grantType: 'authorization_code'
+		} ).then( ({access_token}) => {
+			return Promise.all([
+				oauth.getUser(access_token),
+				oauth.getUserGuilds(access_token)
+			]).then( ([user, guilds]) => {
+				settingsData.set(state, {
+					state, access_token,
+					user: {
+						id: user.id,
+						username: user.username,
+						discriminator: user.discriminator,
+						avatar: 'https://cdn.discordapp.com/' + ( user.avatar ? 
+							`embed/avatars/${user.discriminator % 5}.png` : 
+							`avatars/${user.id}/${user.avatar}.webp` ),
+						locale: user.locale
+					},
+					guilds: new Map(guilds.filter( guild => {
+						return ( guild.owner || hasPerm(guild.permissions, 'MANAGE_GUILD') );
+					} ).map( guild => [guild.id, {
+						id: guild.id,
+						name: guild.name,
+						icon: `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp`,
+						permissions: guild.permissions
+					}] ))
+				});
+				res.writeHead(302, {
+					Location: '/'
+				});
+				return res.end();
+			}, error => {
+				console.log( '- Dashboard: Error while getting user and guilds: ' + error );
+				res.writeHead(302, {
+					Location: '/login?action=failed'
+				});
+				return res.end();
+			} );
+		}, error => {
+			console.log( '- Dashboard: Error while getting the token: ' + error );
+			res.writeHead(302, {
+				Location: '/login?action=failed'
+			});
+			return res.end();
+		} );
+	}
+	if ( reqURL.pathname === '/refresh' ) {
+		if ( !settingsData.has(state) ) {
+			res.writeHead(302, {
+				Location: '/login?action=failed'
+			});
+			return res.end();
+		}
+		let settings = settingsData.get(state)
+		return oauth.getUserGuilds(settings.access_token).then( guilds => {
+			settings.guilds = new Map(guilds.filter( guild => {
+				return ( guild.owner || hasPerm(guild.permissions, 'MANAGE_GUILD') );
+			} ).map( guild => [guild.id, {
+				id: guild.id,
+				name: guild.name,
+				icon: guild.icon,
+				permissions: guild.permissions
+			}] ));
+			res.writeHead(302, {
+				Location: ( reqURL.searchParams.get('returnTo') || '/' )
+			});
+			return res.end();
+		}, error => {
+			console.log( '- Dashboard: Error while refreshing guilds: ' + error );
+			res.writeHead(302, {
+				Location: '/login?action=failed'
+			});
+			return res.end();
+		} );
+	}
+	if ( reqURL.pathname === '/' ) {
+		if ( !settingsData.has(state) ) {
+			let notice = '';
+			if ( reqURL.searchParams.get('action') === 'logout' ) {
+				notice = '<strong>Successfully logged out!</strong><br><br>';
+			}
+			res.write( notice + '<a href="/login">Login</a>' );
+			return res.end();
+		}
+		let notice = 'Guilds:';
+		settingsData.get(state)?.guilds.forEach( guild => {
+			notice += '\n\n' + guild.name;
+		} );
+		res.write( '<a href="/refresh">Refresh guild list.</a><pre>' + notice.replace( /</g, '&lt;' ) + '</pre>' );
+		return res.end();
+	}
+	if ( /^\/guild\/\d+$/.test(reqURL.pathname) && settingsData.get(state)?.guilds?.has(reqURL.pathname.replace( '/guild/', '' )) ) {
+		res.write( settingsData.get(state).guilds.get(reqURL.pathname.replace( '/guild/', '' )).name.replace( /</g, '&lt;' ) );
+		return res.end();
+	}
+	if ( reqURL.pathname === '/guild' || reqURL.pathname.startsWith( '/guild/' ) ) {
+		res.writeHead(302, {
+			Location: '/'
+		});
+		return res.end();
+	}
+	res.writeHead(302, {'Location': '/login?action=missing'});
+	return res.end();
+});
+
+server.listen(8080, 'localhost', () => {
+	console.log( '- Dashboard: Server running at http://localhost:8080/' );
+});
+
+const permissions = {
+	ADMINISTRATOR: 1 << 3,
+	MANAGE_CHANNELS: 1 << 4,
+	MANAGE_GUILD: 1 << 5,
+	MANAGE_MESSAGES: 1 << 13,
+	MENTION_EVERYONE: 1 << 17,
+	MANAGE_NICKNAMES: 1 << 27,
+	MANAGE_ROLES: 1 << 28,
+	MANAGE_WEBHOOKS: 1 << 29,
+	MANAGE_EMOJIS: 1 << 30
+}
+
+/**
+ * Check if a permission is included in the BitField
+ * @param {String|Number} all - BitField of multiple permissions
+ * @param {String} permission - Name of the permission to check for
+ * @param {Boolean} [admin] - If administrator permission can overwrite
+ * @returns {Boolean}
+ */
+function hasPerm(all, permission, admin = true) {
+	var bit = permissions[permission];
+	var adminOverwrite = ( admin && (all & permissions.ADMINISTRATOR) === permissions.ADMINISTRATOR );
+	return ( adminOverwrite || (all & bit) === bit )
+}
+
+
+/**
+ * End the process gracefully.
+ * @param {NodeJS.Signals} signal - The signal received.
+ */
+async function graceful(signal) {
+	console.log( '- Dashboard: ' + signal + ': Closing the dashboard...' );
+	server.close( () => {
+		console.log( '- Dashboard: ' + signal + ': Closed the dashboard server.' );
+	} );
+}
+
+process.once( 'SIGINT', graceful );
+process.once( 'SIGTERM', graceful );

+ 26 - 5
main.js

@@ -1,4 +1,5 @@
 require('dotenv').config();
+const child_process = require('child_process');
 
 const isDebug = ( process.argv[2] === 'debug' );
 const got = require('got').extend( {
@@ -34,12 +35,12 @@ manager.on( 'shardCreate', shard => {
 			console.log( '\n- Killing all shards!\n\n' );
 			manager.shards.forEach( shard => shard.kill() );
 		}
-		if ( message === 'postStats' ) postStats();
+		if ( message === 'postStats' && process.env.botlist ) postStats();
 	} );
 	
 	shard.on( 'death', message => {
 		if ( manager.respawn === false ) diedShards++;
-		if ( ![null, 0].includes( message.exitCode ) ) {
+		if ( message.exitCode ) {
 			if ( !shard.ready ) {
 				manager.respawn = false;
 				console.log( `\n\n- Shard[${shard.id}]: Died due to fatal error, disable respawn!\n\n` );
@@ -50,18 +51,37 @@ manager.on( 'shardCreate', shard => {
 } );
 
 manager.spawn().then( shards => {
-	if ( !isDebug ) {
+	if ( !isDebug && process.env.botlist ) {
 		var botList = JSON.parse(process.env.botlist);
 		for ( let [key, value] of Object.entries(botList) ) {
 			if ( !value ) delete botList[key];
 		}
-		setInterval( postStats, 10800000, botList, shards.size ).unref();
+		if ( Object.keys(botlist).length ) {
+			setInterval( postStats, 10800000, botList, shards.size ).unref();
+		}
 	}
 }, error => {
 	console.error( '- Error while spawning the shards: ' + error );
 	manager.respawnAll();
 } );
 
+if ( process.env.dashboard ) {
+	const dashboard = child_process.fork('./dashboard/index.js', ( isDebug ? ['debug'] : [] ));
+	dashboard.on( 'exit', (code) => {
+		if ( code ) console.log( '- [Dashboard]: Process exited!', code );
+	} );
+	dashboard.on( 'error', error => {
+		console.log( '- [Dashboard]: Error received!', error );
+	} );
+	dashboard.on( 'message', message => {
+		if ( message.id ) {
+			message.data = message.data;
+			dashboard.send( message );
+		}
+		console.log( '- Dashboard: Message received!', message );
+	} );
+}
+
 /**
  * Post bot statistics to bot lists.
  * @param {Object} botList - The list of bot lists to post to.
@@ -98,7 +118,7 @@ function postStats(botList = JSON.parse(process.env.botlist), shardCount = manag
 
 /**
  * End the process gracefully.
- * @param {String} signal - The signal received.
+ * @param {NodeJS.Signals} signal - The signal received.
  */
 async function graceful(signal) {
 	console.log( '- ' + signal + ': Disabling respawn...' );
@@ -118,5 +138,6 @@ if ( isDebug && process.argv[3]?.startsWith( '--timeout:' ) ) {
 	setTimeout( () => {
 		console.log( `\n- Running for ${timeout} seconds, closing process!\n` );
 		manager.shards.forEach( shard => shard.kill() );
+		if ( process.env.dashboard ) dashboard.kill('SIGTERM');
 	}, timeout  * 1000 ).unref();
 }

+ 5 - 0
package-lock.json

@@ -386,6 +386,11 @@
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
       "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
     },
+    "discord-oauth2": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/discord-oauth2/-/discord-oauth2-2.3.0.tgz",
+      "integrity": "sha512-dsNf4x99aa8BELWERwKtYG36RD6mEHwWR/nExP3sRGi5VOsEIZu0vhGj81lvwNLw0kMypF5rJH7oZJElngnnbg=="
+    },
     "discord.js": {
       "version": "12.3.1",
       "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.3.1.tgz",

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "discord-wiki-bot",
-  "version": "2.2.0",
+  "version": "2.3.0",
   "description": "Wiki-Bot is a bot with the purpose to easily search for and link to wiki pages. Wiki-Bot shows short descriptions and additional info about pages and is able to resolve redirects and follow interwiki links.",
   "main": "main.js",
   "scripts": {
@@ -15,6 +15,7 @@
   },
   "dependencies": {
     "cheerio": "^1.0.0-rc.3",
+    "discord-oauth2": "^2.3.0",
     "discord.js": "^12.3.1",
     "dotenv": "^8.2.0",
     "full-icu": "^1.3.1",