Sfoglia il codice sorgente

Save OAuth2 connections

Markus-Rost 3 anni fa
parent
commit
2c79648df1
13 ha cambiato i file con 962 aggiunte e 281 eliminazioni
  1. 1 0
      PRIVACY.md
  2. 148 62
      cmds/verify.js
  3. 43 5
      dashboard/guilds.js
  4. 37 0
      dashboard/i18n/en.json
  5. 51 15
      dashboard/index.js
  6. 53 15
      dashboard/oauth.js
  7. 24 1
      dashboard/src/index.css
  8. 129 0
      dashboard/user.js
  9. 60 4
      dashboard/util.js
  10. 39 4
      database.js
  11. 25 9
      functions/verify.js
  12. 344 159
      interactions/verify.js
  13. 8 7
      util/wiki.js

+ 1 - 0
PRIVACY.md

@@ -6,6 +6,7 @@ The bot does not collect, save or share any private data.
 
 ## Other data
 The bot needs to collect and save some data in order to function properly:
+* **Wiki Accounts**: Wiki accounts connected using [OAuth2](https://www.mediawiki.org/wiki/Extension:OAuth) are saved for easier verification. Using the [Dashboard](https://settings.wikibot.de/user), users can disconnect their accounts again or disable saving the connection completely.
 * **Settings**: Modified guild settings are saved as long as the bot is a member of that guild and deleted shortly after the bot leaves the guild.
 * **Supporters**: The bot maintains a list of translators and [Patreon supporters](https://www.patreon.com/WikiBot) to provide some extra functionality for them.
 * **Commands**: Commands are logged together with the guild id or user id for up to one week for debugging purposes.

+ 148 - 62
cmds/verify.js

@@ -2,7 +2,7 @@ const {randomBytes} = require('crypto');
 const {MessageEmbed} = require('discord.js');
 var db = require('../util/database.js');
 var verify = require('../functions/verify.js');
-const {oauthVerify, allowDelete, escapeFormatting} = require('../util/functions.js');
+const {got, oauthVerify, allowDelete, escapeFormatting} = require('../util/functions.js');
 
 /**
  * Processes the "verify" command.
@@ -34,79 +34,63 @@ function cmd_verify(lang, msg, args, line, wiki) {
 			if ( wiki.isWikimedia() ) oauth.push('wikimedia');
 			if ( wiki.isMiraheze() ) oauth.push('miraheze');
 			if ( process.env['oauth_' + ( oauth[1] || oauth[0] )] && process.env['oauth_' + ( oauth[1] || oauth[0] ) + '_secret'] ) {
-				let state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
-				while ( oauthVerify.has(state) ) {
-					state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
-				}
-				oauthVerify.set(state, {
-					state, wiki: wiki.href,
-					channel: msg.channel,
-					user: msg.author.id
-				});
-				msg.client.shard.send({id: 'verifyUser', state});
-				let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
-					response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
-					client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
-				}).toString();
-				return msg.member.send( lang.get('verify.oauth_message_dm', escapeFormatting(msg.guild.name)) + '\n<' + oauthURL + '>', {
-					components: [
-						{
-							type: 1,
-							components: [
-								{
-									type: 2,
-									style: 5,
-									label: lang.get('verify.oauth_button'),
-									emoji: {id: null, name: '🔗'},
-									url: oauthURL,
-									disabled: false
-								}
-							]
+				return db.query( 'SELECT token FROM oauthusers WHERE userid = $1 AND site = $2', [msg.author.id, ( oauth[1] || oauth[0] )] ).then( ({rows: [row]}) => {
+					if ( row?.token ) return got.post( wiki + 'rest.php/oauth2/access_token', {
+						form: {
+							grant_type: 'refresh_token', refresh_token: row.token,
+							redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+							client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )],
+							client_secret: process.env['oauth_' + ( oauth[1] || oauth[0] ) + '_secret']
 						}
-					]
-				} ).then( message => {
-					msg.reactEmoji('📩');
-					allowDelete(message, msg.author.id);
-					msg.delete({timeout: 60000, reason: lang.get('verify.footer')}).catch(log_error);
-				}, error => {
-					if ( error?.code === 50007 ) { // CANNOT_MESSAGE_USER
-						return msg.replyMsg( lang.get('verify.oauth_private') );
+					} ).then( response => {
+						var body = response.body;
+						if ( response.statusCode !== 200 || !body?.access_token ) {
+							console.log( '- ' + response.statusCode + ': Error while refreshing the mediawiki token: ' + ( body?.message || body?.error ) );
+							return Promise.reject(row);
+						}
+						if ( body?.refresh_token ) db.query( 'UPDATE oauthusers SET token = $1 WHERE userid = $2 AND site = $3', [body.refresh_token, msg.author.id, ( oauth[1] || oauth[0] )] ).then( () => {
+							console.log( '- Dashboard: OAuth2 token for ' + msg.author.id + ' successfully updated.' );
+						}, dberror => {
+							console.log( '- Dashboard: Error while updating the OAuth2 token for ' + msg.author.id + ': ' + dberror );
+						} );
+						return global.verifyOauthUser('', body.access_token, {
+							wiki: wiki.href,
+							channel: msg.channel,
+							user: msg.author.id,
+							sourceMessage: msg
+						});
+					}, error => {
+						console.log( '- Error while refreshing the mediawiki token: ' + error );
+						return Promise.reject(row);
+					} );
+					return Promise.reject(row);
+				}, dberror => {
+					console.log( '- Error while getting the OAuth2 token: ' + dberror );
+					return Promise.reject();
+				} ).catch( row => {
+					if ( row ) {
+						if ( !row?.hasOwnProperty?.('token') ) console.log( '- Error while checking the OAuth2 refresh token: ' + row );
+						else if ( row.token ) db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [msg.author.id, ( oauth[1] || oauth[0] )] ).then( () => {
+							console.log( '- Dashboard: OAuth2 token for ' + msg.author.id + ' successfully deleted.' );
+						}, dberror => {
+							console.log( '- Dashboard: Error while deleting the OAuth2 token for ' + msg.author.id + ': ' + dberror );
+						} );
 					}
-					log_error(error);
-					msg.reactEmoji('error');
-				} );
-			}
-		}
-		
-		var username = args.join(' ').replace( /_/g, ' ' ).trim().replace( /^<\s*(.*)\s*>$/, '$1' ).replace( /^@/, '' ).split('#')[0].substring(0, 250).trim();
-		if ( /^(?:https?:)?\/\/([a-z\d-]{1,50})\.(?:gamepedia\.com\/|(?:fandom\.com|wikia\.org)\/(?:[a-z-]{1,8}\/)?(?:wiki\/)?)/.test(username) ) {
-			username = decodeURIComponent( username.replace( /^(?:https?:)?\/\/([a-z\d-]{1,50})\.(?:gamepedia\.com\/|(?:fandom\.com|wikia\.org)\/(?:[a-z-]{1,8}\/)?(?:wiki\/)?)/, '' ) );
-		}
-		if ( wiki.isGamepedia() ) username = username.replace( /^userprofile\s*:\s*/i, '' );
-		
-		if ( !username.trim() ) {
-			args[0] = line.split(' ')[0];
-			if ( args[0] === 'verification' ) args[0] = ( lang.localNames.verify || 'verify' );
-			return this.help(lang, msg, args, line, wiki);
-		}
-		msg.reactEmoji('⏳').then( reaction => {
-			verify(lang, msg.channel, msg.member, username, wiki, rows).then( result => {
-				if ( result.oauth.length ) {
-					let state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
+					let state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 					while ( oauthVerify.has(state) ) {
-						state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
+						state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 					}
 					oauthVerify.set(state, {
 						state, wiki: wiki.href,
 						channel: msg.channel,
 						user: msg.author.id
 					});
-					msg.client.shard.send({id: 'verifyUser', state});
+					msg.client.shard.send({id: 'verifyUser', state, user: ( row?.token === null ? '' : msg.author.id )});
 					let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
 						response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
-						client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )], state
+						client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
 					}).toString();
-					msg.member.send( lang.get('verify.oauth_message_dm', escapeFormatting(msg.guild.name)) + '\n<' + oauthURL + '>', {
+					return msg.member.send( lang.get('verify.oauth_message_dm', escapeFormatting(msg.guild.name)) + '\n<' + oauthURL + '>', {
 						components: [
 							{
 								type: 1,
@@ -133,6 +117,108 @@ function cmd_verify(lang, msg, args, line, wiki) {
 						log_error(error);
 						msg.reactEmoji('error');
 					} );
+				} );
+			}
+		}
+		
+		var username = args.join(' ').replace( /_/g, ' ' ).trim().replace( /^<\s*(.*)\s*>$/, '$1' ).replace( /^@/, '' ).split('#')[0].substring(0, 250).trim();
+		if ( /^(?:https?:)?\/\/([a-z\d-]{1,50})\.(?:gamepedia\.com\/|(?:fandom\.com|wikia\.org)\/(?:[a-z-]{1,8}\/)?(?:wiki\/)?)/.test(username) ) {
+			username = decodeURIComponent( username.replace( /^(?:https?:)?\/\/([a-z\d-]{1,50})\.(?:gamepedia\.com\/|(?:fandom\.com|wikia\.org)\/(?:[a-z-]{1,8}\/)?(?:wiki\/)?)/, '' ) );
+		}
+		if ( wiki.isGamepedia() ) username = username.replace( /^userprofile\s*:\s*/i, '' );
+		
+		if ( !username.trim() ) {
+			args[0] = line.split(' ')[0];
+			if ( args[0] === 'verification' ) args[0] = ( lang.localNames.verify || 'verify' );
+			return this.help(lang, msg, args, line, wiki);
+		}
+		msg.reactEmoji('⏳').then( reaction => {
+			verify(lang, msg.channel, msg.member, username, wiki, rows).then( result => {
+				if ( result.oauth.length ) {
+					return db.query( 'SELECT token FROM oauthusers WHERE userid = $1 AND site = $2', [msg.author.id, ( result.oauth[1] || result.oauth[0] )] ).then( ({rows: [row]}) => {
+						if ( row?.token ) return got.post( wiki + 'rest.php/oauth2/access_token', {
+							form: {
+								grant_type: 'refresh_token', refresh_token: row.token,
+								redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+								client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )],
+								client_secret: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] ) + '_secret']
+							}
+						} ).then( response => {
+							var body = response.body;
+							if ( response.statusCode !== 200 || !body?.access_token ) {
+								console.log( '- ' + response.statusCode + ': Error while refreshing the mediawiki token: ' + ( body?.message || body?.error ) );
+								return Promise.reject(row);
+							}
+							if ( body?.refresh_token ) db.query( 'UPDATE oauthusers SET token = $1 WHERE userid = $2 AND site = $3', [body.refresh_token, msg.author.id, ( result.oauth[1] || result.oauth[0] )] ).then( () => {
+								console.log( '- Dashboard: OAuth2 token for ' + msg.author.id + ' successfully updated.' );
+							}, dberror => {
+								console.log( '- Dashboard: Error while updating the OAuth2 token for ' + msg.author.id + ': ' + dberror );
+							} );
+							return global.verifyOauthUser('', body.access_token, {
+								wiki: wiki.href,
+								channel: msg.channel,
+								user: msg.author.id,
+								sourceMessage: msg
+							});
+						}, error => {
+							console.log( '- Error while refreshing the mediawiki token: ' + error );
+							return Promise.reject(row);
+						} );
+						return Promise.reject(row);
+					}, dberror => {
+						console.log( '- Error while getting the OAuth2 token: ' + dberror );
+						return Promise.reject();
+					} ).catch( row => {
+						if ( row ) {
+							if ( !row?.hasOwnProperty?.('token') ) console.log( '- Error while checking the OAuth2 refresh token: ' + row );
+							else if ( row.token ) db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [msg.author.id, ( result.oauth[1] || result.oauth[0] )] ).then( () => {
+								console.log( '- Dashboard: OAuth2 token for ' + msg.author.id + ' successfully deleted.' );
+							}, dberror => {
+								console.log( '- Dashboard: Error while deleting the OAuth2 token for ' + msg.author.id + ': ' + dberror );
+							} );
+						}
+						let state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
+						while ( oauthVerify.has(state) ) {
+							state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
+						}
+						oauthVerify.set(state, {
+							state, wiki: wiki.href,
+							channel: msg.channel,
+							user: msg.author.id
+						});
+						msg.client.shard.send({id: 'verifyUser', state, user: ( row?.token === null ? '' : msg.author.id )});
+						let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
+							response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+							client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )], state
+						}).toString();
+						msg.member.send( lang.get('verify.oauth_message_dm', escapeFormatting(msg.guild.name)) + '\n<' + oauthURL + '>', {
+							components: [
+								{
+									type: 1,
+									components: [
+										{
+											type: 2,
+											style: 5,
+											label: lang.get('verify.oauth_button'),
+											emoji: {id: null, name: '🔗'},
+											url: oauthURL,
+											disabled: false
+										}
+									]
+								}
+							]
+						} ).then( message => {
+							msg.reactEmoji('📩');
+							allowDelete(message, msg.author.id);
+							msg.delete({timeout: 60000, reason: lang.get('verify.footer')}).catch(log_error);
+						}, error => {
+							if ( error?.code === 50007 ) { // CANNOT_MESSAGE_USER
+								return msg.replyMsg( lang.get('verify.oauth_private') );
+							}
+							log_error(error);
+							msg.reactEmoji('error');
+						} );
+					} );
 				}
 				else if ( result.reaction ) msg.reactEmoji(result.reaction);
 				else {

+ 43 - 5
dashboard/guilds.js

@@ -2,9 +2,10 @@ const cheerio = require('cheerio');
 const {defaultPermissions} = require('../util/default.json');
 const Lang = require('./i18n.js');
 const allLangs = Lang.allLangs().names;
-const {oauth, settingsData, addWidgets, createNotice} = require('./util.js');
+const {oauth, enabledOAuth2, settingsData, addWidgets, createNotice} = require('./util.js');
 
 const forms = {
+	user: require('./user.js').get,
 	settings: require('./settings.js').get,
 	verification: require('./verification.js').get,
 	rcscript: require('./rcscript.js').get,
@@ -24,7 +25,7 @@ const file = require('fs').readFileSync('./dashboard/index.html');
  * @param {String[]} [actionArgs] - The arguments for the action
  */
 function dashboard_guilds(res, dashboardLang, theme, userSession, reqURL, action, actionArgs) {
-	reqURL.pathname = reqURL.pathname.replace( /^(\/(?:guild\/\d+(?:\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?)?)?)(?:\/.*)?$/, '$1' );
+	reqURL.pathname = reqURL.pathname.replace( /^(\/(?:user|guild\/\d+(?:\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?)?)?)(?:\/.*)?$/, '$1' );
 	var args = reqURL.pathname.split('/');
 	var settings = settingsData.get(userSession.user_id);
 	if ( reqURL.searchParams.get('owner') && process.env.owner.split('|').includes(userSession.user_id) ) {
@@ -56,7 +57,15 @@ function dashboard_guilds(res, dashboardLang, theme, userSession, reqURL, action
 	$('#support span').text(dashboardLang.get('general.support'));
 	$('#logout').attr('alt', dashboardLang.get('general.logout'));
 	if ( process.env.READONLY ) createNotice($, 'readonly', dashboardLang);
-	if ( action ) createNotice($, action, dashboardLang, actionArgs);
+	if ( action ) {
+		if ( action === 'oauthother' && !actionArgs ) actionArgs = [
+			oauth.generateAuthUrl( {
+				scope: ['identify', 'guilds'],
+				prompt: 'consent', state: userSession.state
+			} )
+		];
+		createNotice($, action, dashboardLang, actionArgs);
+	}
 	$('head').append(
 		$('<script>').text(`history.replaceState(null, null, '${reqURL.pathname}');`)
 	);
@@ -98,6 +107,27 @@ function dashboard_guilds(res, dashboardLang, theme, userSession, reqURL, action
 		} );
 	}
 
+	if ( args[1] === 'user' && enabledOAuth2.length ) {
+		$('head title').text(`${settings.user.username} #${settings.user.discriminator} – ` + $('head title').text());
+		$('#channellist').empty();
+		$('#channellist').append(
+			$('<a class="channel channel-header">').attr('href', '/').append(
+				$('<img>').attr('src', '/src/settings.svg'),
+				$('<div>').text(dashboardLang.get('selector.title'))
+			).attr('title', dashboardLang.get('selector.title')),
+			$('<a class="channel channel-header selected">').attr('href', '/user').append(
+				$('<img>').attr('src', '/src/settings.svg'),
+				$('<div>').text(dashboardLang.get('selector.user'))
+			).attr('title', dashboardLang.get('selector.user')),
+			...enabledOAuth2.map( oauthSite => {
+				return $('<a class="channel">').attr('href', '#oauth-' + oauthSite.id).append(
+					$('<img>').attr('src', '/src/channel.svg'),
+					$('<div>').text(oauthSite.name)
+				).attr('title', oauthSite.name);
+			} )
+		)
+		return forms.user(res, $, settings.user, dashboardLang);
+	}
 	let id = args[2];
 	if ( id ) $(`.guild#${id}`).addClass('selected');
 	if ( settings.guilds.isMember.has(id) ) {
@@ -164,6 +194,10 @@ function dashboard_guilds(res, dashboardLang, theme, userSession, reqURL, action
 	else {
 		$('head title').text(dashboardLang.get('selector.title') + ' – ' + $('head title').text());
 		$('#channellist').empty();
+		$('<a class="channel channel-header selected">').attr('href', '/').append(
+			$('<img>').attr('src', '/src/settings.svg'),
+			$('<div>').text(dashboardLang.get('selector.title'))
+		).attr('title', dashboardLang.get('selector.title')).appendTo('#channellist');
 		$('<p>').html(dashboardLang.get('selector.desc', true, $('<code>'))).appendTo('#text .description');
 		if ( settings.guilds.isMember.size ) {
 			$('<h2 id="with-wikibot">').text(dashboardLang.get('selector.with')).appendTo('#text');
@@ -202,8 +236,8 @@ function dashboard_guilds(res, dashboardLang, theme, userSession, reqURL, action
 				scope: ['identify', 'guilds'],
 				prompt: 'consent', state: userSession.state
 			} );
-			$('<a class="channel channel-header">').attr('href', url).append(
-				$('<img>').attr('src', '/src/settings.svg'),
+			$('<a class="channel">').attr('href', url).append(
+				$('<img>').attr('src', '/src/channel.svg'),
 				$('<div>').text(dashboardLang.get('selector.switch'))
 			).attr('title', dashboardLang.get('selector.switch')).appendTo('#channellist');
 			$('#text .description').append(
@@ -213,6 +247,10 @@ function dashboard_guilds(res, dashboardLang, theme, userSession, reqURL, action
 				)
 			);
 		}
+		if ( enabledOAuth2.length ) $('<a class="channel channel-header">').attr('href', '/user').append(
+			$('<img>').attr('src', '/src/settings.svg'),
+			$('<div>').text(dashboardLang.get('selector.user'))
+		).attr('title', dashboardLang.get('selector.user')).appendTo('#channellist');
 		addWidgets($, dashboardLang);
 	}
 	let body = $.html();

+ 37 - 0
dashboard/i18n/en.json

@@ -102,6 +102,27 @@
             "text": "The restrictions for the $1 command can't be changed without verifications being set up.",
             "title": "Verifications are not set up!"
         },
+        "oauth": {
+            "text": "Your wiki account has been successfully connected with Wiki-Bot.",
+            "title": "Account successfully connected!"
+        },
+        "oauthfail": {
+            "text": "Your wiki account could not be connected, please try again.",
+            "title": "Connection failed!"
+        },
+        "oauthlogin": {
+            "text": "Please log in if you don't want Wiki-Bot to remember your wiki account.",
+            "title": "Your connected wiki account has been saved!"
+        },
+        "oauthother": {
+            "note": "Switch accounts.",
+            "text": "Please note that your wiki account got connected with a different Discord account than the one you are currently logged in as.",
+            "title": "Account successfully connected!"
+        },
+        "oauthverify": {
+            "text": "Your wiki account has been successfully verified.",
+            "title": "Account successfully verified!"
+        },
         "readonly": {
             "text": "You can currently only view your settings, but not change them.",
             "title": "Read-only database!"
@@ -144,6 +165,21 @@
             "title": "Wiki is blocked!"
         }
     },
+    "oauth": {
+        "desc": "These are your OAuth2 settings for connecting wiki accounts:",
+        "failed": "Failed to load the OAuth2 settings!",
+        "form": {
+            "connect": "Connect account.",
+            "connected": "Connected",
+            "current": "Current Status:",
+            "default": "Wiki Account Connections",
+            "disable": "Don't remember my account!",
+            "disabled": "Disabled",
+            "disconnect": "Disconnect account.",
+            "enable": "Remember my account.",
+            "unconnected": "Unconnected"
+        }
+    },
     "rcscript": {
         "desc": "These are the recent changes webhooks for $1:",
         "explanation": "<h2>Recent Changes Webhook</h2>\n<p>Wiki-Bot is able to run a recent changes webhook based on <a href=\"https://gitlab.com/piotrex43/RcGcDw\" target=\"_blank\">RcGcDw</a>. The recent changes can be displayed in compact text messages with inline links or embed messages with edit tags and category changes.</p>\n<p>Requirements to add a recent changes webhook:</p>\n<ul>\n<li>The wiki needs to run on <a href=\"https://www.mediawiki.org/wiki/MediaWiki_1.30\" target=\"_blank\">MediaWiki 1.30</a> or higher.</li>\n<li>The system message <code class=\"user-select\">MediaWiki:Custom-RcGcDw</code> needs to be set to the Discord server id <code class=\"user-select\" id=\"server-id\"></code>.</li>\n</ul>",
@@ -175,6 +211,7 @@
         "none": "You currently don't have the [Manage Server]($1) permission on any servers, are you logged into the correct account?",
         "switch": "Switch Accounts",
         "title": "Server Selector",
+        "user": "Wiki Accounts",
         "with": "Servers with Wiki-Bot",
         "without": "Servers without Wiki-Bot"
     },

+ 51 - 15
dashboard/index.js

@@ -8,6 +8,7 @@ const Lang = require('./i18n.js');
 const allLangs = Lang.allLangs();
 
 const posts = {
+	user: require('./user.js').post,
 	settings: require('./settings.js').post,
 	verification: require('./verification.js').post,
 	rcscript: require('./rcscript.js').post,
@@ -53,15 +54,15 @@ const files = new Map([
 
 const server = http.createServer( (req, res) => {
 	res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
-	if ( req.method === 'POST' && req.headers['content-type'] === 'application/x-www-form-urlencoded' && req.url.startsWith( '/guild/' ) ) {
+	if ( req.method === 'POST' && req.headers['content-type'] === 'application/x-www-form-urlencoded' && ( req.url.startsWith( '/guild/' ) || req.url === '/user' ) ) {
 		let args = req.url.split('/');
 		let state = req.headers.cookie?.split('; ')?.filter( cookie => {
 			return cookie.split('=')[0] === 'wikibot' && /^"([\da-f]+(?:-\d+)*)"$/.test(( cookie.split('=')[1] || '' ));
 		} )?.map( cookie => cookie.replace( /^wikibot="([\da-f]+(?:-\d+)*)"$/, '$1' ) )?.join();
 
-		if ( args.length === 5 && ['settings', 'verification', 'rcscript', 'slash'].includes( args[3] )
-		&& /^(?:default|new|notice|\d+)$/.test(args[4]) && sessionData.has(state) && settingsData.has(sessionData.get(state).user_id)
-		&& settingsData.get(sessionData.get(state).user_id).guilds.isMember.has(args[2]) ) {
+		if ( state && sessionData.has(state) && settingsData.has(sessionData.get(state).user_id) &&
+		( ( args.length === 5 && ['settings', 'verification', 'rcscript', 'slash'].includes( args[3] ) && /^(?:default|new|notice|\d+)$/.test(args[4])
+		&& settingsData.get(sessionData.get(state).user_id).guilds.isMember.has(args[2]) ) || req.url === '/user' ) ) {
 			if ( process.env.READONLY ) return save_response(`${req.url}?save=failed`);
 			let body = [];
 			req.on( 'data', chunk => {
@@ -85,7 +86,14 @@ const server = http.createServer( (req, res) => {
 					}
 				} );
 				if ( isDebug ) console.log( '- Dashboard:', req.url, settings, sessionData.get(state).user_id );
-				return posts[args[3]](save_response, settingsData.get(sessionData.get(state).user_id), args[2], args[4], settings);
+				if ( req.url === '/user' ) {
+					let setting = Object.keys(settings);
+					if ( setting.length === 1 && setting[0].startsWith( 'oauth_' ) && setting[0].split('_').length >= 3 ) {
+						setting = setting[0].split('_');
+						return posts.user(save_response, sessionData.get(state).user_id, setting[1], setting.slice(2).join('_'));
+					}
+				}
+				else return posts[args[3]](save_response, settingsData.get(sessionData.get(state).user_id), args[2], args[4], settings);
 			} );
 
 			/**
@@ -94,6 +102,10 @@ const server = http.createServer( (req, res) => {
 			 * @param {String[]} [actionArgs]
 			 */
 			function save_response(resURL = '/', action, ...actionArgs) {
+				if ( action === 'REDIRECT' && resURL.startsWith( 'https://' ) ) {
+					res.writeHead(303, {Location: resURL});
+					return res.end();
+				}
 				var themeCookie = ( req.headers?.cookie?.split('; ')?.find( cookie => {
 					return cookie.split('=')[0] === 'theme' && /^"(?:light|dark)"$/.test(( cookie.split('=')[1] || '' ));
 				} ) || 'dark' ).replace( /^theme="(light|dark)"$/, '$1' );
@@ -132,10 +144,6 @@ const server = http.createServer( (req, res) => {
 		return res.end();
 	}
 
-	if ( reqURL.pathname === '/oauth/mw' ) {
-		return pages.verify(res, reqURL.searchParams);
-	}
-
 	if ( reqURL.pathname === '/favicon.ico' ) reqURL.pathname = '/src/icon.png';
 	if ( files.has(reqURL.pathname) ) {
 		let file = files.get(reqURL.pathname);
@@ -165,8 +173,8 @@ const server = http.createServer( (req, res) => {
 	res.setHeader('Content-Language', [dashboardLang.lang]);
 
 	var lastGuild = req.headers?.cookie?.split('; ')?.filter( cookie => {
-		return cookie.split('=')[0] === 'guild' && /^"\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?"$/.test(( cookie.split('=')[1] || '' ));
-	} )?.map( cookie => cookie.replace( /^guild="(\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?)"$/, '$1' ) )?.join();
+		return cookie.split('=')[0] === 'guild' && /^"(?:user|\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?)"$/.test(( cookie.split('=')[1] || '' ));
+	} )?.map( cookie => cookie.replace( /^guild="(user|\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?)"$/, '$1' ) )?.join();
 	if ( lastGuild ) res.setHeader('Set-Cookie', ['guild=""; SameSite=Lax; Path=/; Max-Age=0']);
 
 	var state = req.headers.cookie?.split('; ')?.filter( cookie => {
@@ -188,14 +196,27 @@ const server = http.createServer( (req, res) => {
 		return pages.login(res, dashboardLang, themeCookie, state, 'logout');
 	}
 
+	if ( reqURL.pathname === '/oauth/mw' ) {
+		return pages.verify(res, reqURL.searchParams, sessionData.get(state)?.user_id);
+	}
+
 	if ( !state ) {
+		let action = '';
+		if ( reqURL.pathname !== '/' ) action = 'unauthorized';
 		if ( reqURL.pathname.startsWith( '/guild/' ) ) {
 			let pathGuild = reqURL.pathname.split('/').slice(2, 5).join('/');
 			if ( /^\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?$/.test(pathGuild) ) {
 				res.setHeader('Set-Cookie', [`guild="${pathGuild}"; SameSite=Lax; Path=/`]);
 			}
 		}
-		return pages.login(res, dashboardLang, themeCookie, state, ( reqURL.pathname === '/' ? '' : 'unauthorized' ));
+		else if ( reqURL.pathname === '/user' ) {
+			if ( reqURL.searchParams.get('oauth') === 'success' ) action = 'oauth';
+			if ( reqURL.searchParams.get('oauth') === 'failed' ) action = 'oauthfail';
+			if ( reqURL.searchParams.get('oauth') === 'verified' ) action = 'oauthverify';
+			if ( reqURL.searchParams.get('oauth') === 'other' ) action = 'oauth';
+			res.setHeader('Set-Cookie', ['guild="user"; SameSite=Lax; Path=/']);
+		}
+		return pages.login(res, dashboardLang, themeCookie, state, action);
 	}
 
 	if ( reqURL.pathname === '/oauth' ) {
@@ -203,18 +224,27 @@ const server = http.createServer( (req, res) => {
 	}
 
 	if ( !sessionData.has(state) || !settingsData.has(sessionData.get(state).user_id) ) {
+		let action = '';
+		if ( reqURL.pathname !== '/' ) action = 'unauthorized';
 		if ( reqURL.pathname.startsWith( '/guild/' ) ) {
 			let pathGuild = reqURL.pathname.split('/').slice(2, 5).join('/');
 			if ( /^\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?$/.test(pathGuild) ) {
 				res.setHeader('Set-Cookie', [`guild="${pathGuild}"; SameSite=Lax; Path=/`]);
 			}
 		}
-		return pages.login(res, dashboardLang, themeCookie, state, ( reqURL.pathname === '/' ? '' : 'unauthorized' ));
+		else if ( reqURL.pathname === '/user' ) {
+			if ( reqURL.searchParams.get('oauth') === 'success' ) action = 'oauth';
+			if ( reqURL.searchParams.get('oauth') === 'failed' ) action = 'oauthfail';
+			if ( reqURL.searchParams.get('oauth') === 'verified' ) action = 'oauthverify';
+			if ( reqURL.searchParams.get('oauth') === 'other' ) action = 'oauth';
+			res.setHeader('Set-Cookie', ['guild="user"; SameSite=Lax; Path=/']);
+		}
+		return pages.login(res, dashboardLang, themeCookie, state, action);
 	}
 
 	if ( reqURL.pathname === '/refresh' ) {
 		let returnLocation = reqURL.searchParams.get('return');
-		if ( !/^\/guild\/\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?$/.test(returnLocation) ) {
+		if ( !/^\/(?:user|guild\/\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?)$/.test(returnLocation) ) {
 			returnLocation = '/';
 		}
 		return pages.refresh(res, sessionData.get(state), returnLocation);
@@ -228,7 +258,13 @@ const server = http.createServer( (req, res) => {
 	let action = '';
 	if ( reqURL.searchParams.get('refresh') === 'success' ) action = 'refresh';
 	if ( reqURL.searchParams.get('refresh') === 'failed' ) action = 'refreshfail';
-	if ( reqURL.searchParams.get('slash') === 'noverify' ) action = 'noverify';
+	if ( reqURL.searchParams.get('slash') === 'noverify' && reqURL.pathname.split('/')[3] === 'slash' ) action = 'noverify';
+	if ( reqURL.pathname === '/user' ) {
+		if ( reqURL.searchParams.get('oauth') === 'success' ) action = 'oauth';
+		if ( reqURL.searchParams.get('oauth') === 'failed' ) action = 'oauthfail';
+		if ( reqURL.searchParams.get('oauth') === 'verified' ) action = 'oauthverify';
+		if ( reqURL.searchParams.get('oauth') === 'other' ) action = 'oauthother';
+	}
 	return dashboard(res, dashboardLang, themeCookie, sessionData.get(state), reqURL, action);
 } );
 

+ 53 - 15
dashboard/oauth.js

@@ -3,7 +3,7 @@ const cheerio = require('cheerio');
 const {defaultPermissions} = require('../util/default.json');
 const Wiki = require('../util/wiki.js');
 const allLangs = require('./i18n.js').allLangs().names;
-const {got, oauth, sessionData, settingsData, oauthVerify, sendMsg, addWidgets, createNotice, hasPerm} = require('./util.js');
+const {got, db, oauth, enabledOAuth2, sessionData, settingsData, oauthVerify, sendMsg, addWidgets, createNotice, hasPerm} = require('./util.js');
 
 const file = require('fs').readFileSync('./dashboard/login.html');
 
@@ -44,9 +44,11 @@ function dashboard_login(res, dashboardLang, theme, state, action) {
 	let prompt = 'none';
 	if ( process.env.READONLY ) createNotice($, 'readonly', dashboardLang);
 	if ( action ) createNotice($, action, dashboardLang);
-	if ( action === 'unauthorized' ) $('head').append(
-		$('<script>').text('history.replaceState(null, null, "/login");')
-	);
+	if ( action === 'unauthorized' ) $('<script>').text('history.replaceState(null, null, "/login");').appendTo('head');
+	else if ( action.startsWith( 'oauth' ) ) {
+		if ( action === 'oauth' ) createNotice($, 'oauthlogin', dashboardLang);
+		$('<script>').text('history.replaceState(null, null, "/user");').appendTo('head');
+	}
 	if ( action === 'logout' ) prompt = 'consent';
 	if ( action === 'loginfail' ) responseCode = 400;
 	state = Date.now().toString(16) + randomBytes(16).toString('hex');
@@ -155,8 +157,13 @@ function dashboard_oauth(res, state, searchParams, lastGuild) {
 				if ( searchParams.has('guild_id') && !lastGuild.startsWith( searchParams.get('guild_id') + '/' ) ) {
 					lastGuild = searchParams.get('guild_id') + '/settings';
 				}
+				let returnLocation = '/';
+				if ( lastGuild ) {
+					if ( lastGuild === 'user' ) returnLocation += lastGuild;
+					else if ( /^\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?$/.test(lastGuild) ) returnLocation += 'guild/' + lastGuild;
+				}
 				res.writeHead(302, {
-					Location: ( lastGuild && /^\d+\/(?:settings|verification|rcscript|slash)(?:\/(?:\d+|new|notice))?$/.test(lastGuild) ? `/guild/${lastGuild}` : '/' ),
+					Location: returnLocation,
 					'Set-Cookie': [`wikibot="${userSession.state}"; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`]
 				});
 				return res.end();
@@ -318,44 +325,75 @@ function dashboard_api(res, input) {
  * Load oauth data of a wiki user
  * @param {import('http').ServerResponse} res - The server response
  * @param {URLSearchParams} searchParams - The url parameters
+ * @param {String} [user_id] - The current user
  */
-function mediawiki_oauth(res, searchParams) {
-	if ( !searchParams.get('code') || !oauthVerify.has(searchParams.get('state')) ) {
-		res.writeHead(302, {Location: '/login?action=failed'});
+function mediawiki_oauth(res, searchParams, user_id) {
+	if ( !searchParams.get('code') || !searchParams.get('state') ) {
+		res.writeHead(302, {Location: '/user?oauth=failed'});
 		return res.end();
 	}
 	var state = searchParams.get('state');
 	var site = state.split(' ');
-	got.post( 'https://' + site[0] + '/rest.php/oauth2/access_token', {
+	var oauthSite = enabledOAuth2.find( oauthSite => ( site[2] || site[0] ) === oauthSite.id );
+	if ( !oauthSite || ( !oauthVerify.has(state) && !user_id ) ) {
+		res.writeHead(302, {Location: '/user?oauth=failed'});
+		return res.end();
+	}
+	var url = oauthSite.url;
+	if ( oauthVerify.has(state) && site[2] === oauthSite.id ) url = 'https://' + site[0] + '/';
+	got.post( url + 'rest.php/oauth2/access_token', {
 		form: {
 			grant_type: 'authorization_code',
 			code: searchParams.get('code'),
 			redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
-			client_id: process.env['oauth_' + ( site[2] || site[0] )],
-			client_secret: process.env['oauth_' + ( site[2] || site[0] ) + '_secret']
+			client_id: process.env['oauth_' + oauthSite.id],
+			client_secret: process.env['oauth_' + oauthSite.id + '_secret']
 		}
 	} ).then( response => {
 		var body = response.body;
 		if ( response.statusCode !== 200 || !body?.access_token ) {
 			console.log( '- Dashboard: ' + response.statusCode + ': Error while getting the mediawiki token: ' + ( body?.message || body?.error ) );
-			res.writeHead(302, {Location: '/login?action=failed'});
+			res.writeHead(302, {Location: '/user?oauth=failed'});
 			return res.end();
 		}
+		if ( !oauthVerify.has(state) ) {
+			if ( !body?.refresh_token || !user_id ) {
+				res.writeHead(302, {Location: '/user?oauth=failed'});
+				return res.end();
+			}
+			return db.query( 'INSERT INTO oauthusers(userid, site, token) VALUES($1, $2, $3)', [user_id, oauthSite.id, body.refresh_token] ).then( () => {
+				console.log( '- Dashboard: OAuth2 token for ' + user_id + ' successfully saved.' );
+				res.writeHead(302, {Location: '/user?oauth=success'});
+				return res.end();
+			}, dberror => {
+				console.log( '- Dashboard: Error while saving the OAuth2 token for ' + user_id + ': ' + dberror );
+				res.writeHead(302, {Location: '/user?oauth=failed'});
+				return res.end();
+			} );
+		}
 		sendMsg( {
 			type: 'verifyUser', state,
 			access_token: body.access_token
 		} ).then( () => {
+			let userid = oauthVerify.get(state);
+			if ( userid && body?.refresh_token ) db.query( 'INSERT INTO oauthusers(userid, site, token) VALUES($1, $2, $3)', [userid, oauthSite.id, body.refresh_token] ).then( () => {
+				console.log( '- Dashboard: OAuth2 token for ' + userid + ' successfully saved.' );
+			}, dberror => {
+				console.log( '- Dashboard: Error while saving the OAuth2 token for ' + userid + ': ' + dberror );
+			} );
 			oauthVerify.delete(state);
-			res.writeHead(302, {Location: 'https://' + site[0] + '/index.php?title=Special:MyPage'});
+			if ( !userid ) res.writeHead(302, {Location: '/user?oauth=verified'});
+			else if ( user_id && userid !== user_id ) res.writeHead(302, {Location: '/user?oauth=other'});
+			else res.writeHead(302, {Location: '/user?oauth=success'});
 			return res.end();
 		}, error => {
 			console.log( '- Dashboard: Error while sending the mediawiki token: ' + error );
-			res.writeHead(302, {Location: '/login?action=failed'});
+			res.writeHead(302, {Location: '/user?oauth=failed'});
 			return res.end();
 		} );
 	}, error => {
 		console.log( '- Dashboard: Error while getting the mediawiki token: ' + error );
-		res.writeHead(302, {Location: '/login?action=failed'});
+		res.writeHead(302, {Location: '/user?oauth=failed'});
 		return res.end();
 	} );
 }

+ 24 - 1
dashboard/src/index.css

@@ -62,7 +62,7 @@ a[alt]:hover:after {
 	content: "";
 	display: block;
 	height: 48px;
-	margin: -48px 0 0;
+	margin-top: -48px;
 }
 #navbar a {
 	display: flex;
@@ -609,6 +609,29 @@ button.addmore:not([hidden]) {
 .wb-settings-optgroup {
 	font-weight: bold;
 }
+.wb-oauth-site {
+	margin-bottom: 30px;
+}
+.wb-oauth-site legend {
+	background-color: unset;
+	color: unset;
+}
+.wb-oauth-connected {
+	font-weight: bold;
+	color: green;
+}
+.wb-oauth-unconnected {
+	font-weight: bold;
+	color: red;
+}
+.wb-oauth-disabled {
+	font-weight: bold;
+	color: darkred;
+}
+.wb-oauth-enabled {
+	font-weight: bold;
+	color: darkgreen;
+}
 .wb-settings-error {
 	color: red;
 }

+ 129 - 0
dashboard/user.js

@@ -0,0 +1,129 @@
+const {db, enabledOAuth2, oauthVerify} = require('./util.js');
+
+/**
+ * Let a user change settings
+ * @param {import('http').ServerResponse} res - The server response
+ * @param {import('cheerio')} $ - The response body
+ * @param {import('./util.js').User} user - The current user
+ * @param {import('./i18n.js')} dashboardLang - The user language
+ */
+function dashboard_user(res, $, user, dashboardLang) {
+	db.query( 'SELECT site, token FROM oauthusers WHERE userid = $1', [user.id] ).then( ({rows}) => {
+		$('<p>').text(dashboardLang.get('oauth.desc')).appendTo('#text .description');
+		$('<form id="wb-settings" method="post" enctype="application/x-www-form-urlencoded">').append(
+			$('<h2>').text(dashboardLang.get('oauth.form.default')),
+			...enabledOAuth2.map( oauthSite => {
+				let row = rows.find( row => row.site === oauthSite.id );
+				let buttons = $('<div>');
+				if ( row ) {
+					if ( row.token === null ) buttons.append(
+						$('<span>').append(
+							$('<input type="submit">').addClass('wb-oauth-enabled').attr('name', 'oauth_enable_' + oauthSite.id).val(dashboardLang.get('oauth.form.enable'))
+						),
+						$('<span>').append(
+							$('<input type="submit">').addClass('wb-oauth-connected').attr('name', 'oauth_connect_' + oauthSite.id).val(dashboardLang.get('oauth.form.connect'))
+						)
+					);
+					else buttons.append(
+						$('<span>').append(
+							$('<input type="submit">').addClass('wb-oauth-disabled').attr('name', 'oauth_disable_' + oauthSite.id).val(dashboardLang.get('oauth.form.disable'))
+						),
+						$('<span>').append(
+							$('<input type="submit">').addClass('wb-oauth-unconnected').attr('name', 'oauth_disconnect_' + oauthSite.id).val(dashboardLang.get('oauth.form.disconnect'))
+						)
+					);
+				}
+				else buttons.append(
+					$('<span>').append(
+						$('<input type="submit">').addClass('wb-oauth-disabled').attr('name', 'oauth_disable_' + oauthSite.id).val(dashboardLang.get('oauth.form.disable'))
+					),
+					$('<span>').append(
+						$('<input type="submit">').addClass('wb-oauth-connected').attr('name', 'oauth_connect_' + oauthSite.id).val(dashboardLang.get('oauth.form.connect'))
+					)
+				);
+				return $('<div>').addClass('wb-oauth-site').attr('id', 'oauth-' + oauthSite.id).append(
+					$('<fieldset>').append(
+						$('<legend>').append(
+							$('<a target="_blank">').attr('href', oauthSite.url).text(oauthSite.name)
+						),
+						$('<div>').append(
+							$('<span>').text(dashboardLang.get('oauth.form.current')),
+							( row ? ( row.token === null ?
+								$('<span>').addClass('wb-oauth-disabled').text(dashboardLang.get('oauth.form.disabled'))
+							:
+								$('<span>').addClass('wb-oauth-connected').text(dashboardLang.get('oauth.form.connected'))
+							) :
+								$('<span>').addClass('wb-oauth-unconnected').text(dashboardLang.get('oauth.form.unconnected'))
+							)
+						),
+						buttons
+					)
+				)
+			} )
+		).attr('action', '/user').appendTo('#text');
+	}, dberror => {
+		console.log( '- Dashboard: Error while getting the OAuth2 info: ' + dberror );
+		createNotice($, 'error', dashboardLang);
+		$('<p>').text(dashboardLang.get('oauth.failed')).appendTo('#text .description');
+	} ).then( () => {
+		let body = $.html();
+		res.writeHead(200, {'Content-Length': Buffer.byteLength(body)});
+		res.write( body );
+		return res.end();
+	} );
+}
+
+/**
+ * Change settings
+ * @param {Function} res - The server response
+ * @param {String} user_id - The current user
+ * @param {String} type - The setting to change
+ * @param {String} oauth_id - The OAuth2 site to change
+ */
+function update_user(res, user_id, type, oauth_id) {
+	if ( !['connect', 'disconnect', 'disable', 'enable'].includes( type ) || !enabledOAuth2.some( oauthSite => oauthSite.id === oauth_id ) ) {
+		return res('/user', 'savefail');
+	}
+	if ( type === 'disconnect' || type === 'enable' ) return db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [user_id, oauth_id] ).then( () => {
+		if ( type === 'disconnect' ) console.log( '- Dashboard: Successfully disconnected ' + user_id + ' from ' + oauth_id + '.' );
+		else console.log( '- Dashboard: Successfully enabled ' + oauth_id + ' for ' + user_id + '.' );
+		return res('/user', 'save');
+	}, dberror => {
+		if ( type === 'disconnect' ) console.log( '- Dashboard: Error while disconnecting ' + user_id + ' from ' + oauth_id + ': ' + dberror );
+		else console.log( '- Dashboard: Error while enabling ' + oauth_id + ' for ' + user_id + ': ' + dberror );
+		return res('/user', 'savefail');
+	} );
+	return db.query( 'SELECT FROM oauthusers WHERE userid = $1 AND site = $2', [user_id, oauth_id] ).then( ({rows:[row]}) => {
+		if ( type === 'disable' ) {
+			let sql = 'INSERT INTO oauthusers(userid, site, token) VALUES($1, $2, $3)';
+			if ( row ) sql = 'UPDATE oauthusers SET token = $3 WHERE userid = $1 AND site = $2';
+			return db.query( sql, [user_id, oauth_id, null] ).then( () => {
+				console.log( '- Dashboard: Successfully disabled ' + oauth_id + ' for ' + user_id + '.' );
+				return res('/user', 'save');
+			}, dberror => {
+				console.log( '- Dashboard: Error while disabling ' + oauth_id + ' for ' + user_id + ': ' + dberror );
+				return res('/user', 'savefail');
+			} );
+		}
+		if ( type !== 'connect' ) return res('/user', 'savefail');
+		var oauthSite = enabledOAuth2.find( oauthSite => oauthSite.id === oauth_id );
+		if ( row ) db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [user_id, oauth_id] ).then( () => {
+			console.log( '- Dashboard: Successfully disconnected ' + user_id + ' from ' + oauth_id + ' for reconnection.' );
+		}, dberror => {
+			console.log( '- Dashboard: Error while disconnecting ' + user_id + ' from ' + oauth_id + ' for reconnection: ' + dberror );
+		} );
+		let oauthURL = oauthSite.url + 'rest.php/oauth2/authorize?' + new URLSearchParams({
+			response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+			client_id: process.env['oauth_' + oauthSite.id], state: oauthSite.id
+		}).toString();
+		return res(oauthURL, 'REDIRECT');
+	}, dberror => {
+		console.log( '- Dashboard: Error while getting the OAuth2 info on ' + oauth_id + ' for ' + user_id + ': ' + dberror );
+		return res('/user', 'savefail');
+	} );
+}
+
+module.exports = {
+	get: dashboard_user,
+	post: update_user
+};

+ 60 - 4
dashboard/util.js

@@ -18,6 +18,36 @@ const oauth = new DiscordOauth2( {
 	redirectUri: process.env.dashboard
 } );
 
+const {oauthSites} = require('../util/wiki.js');
+
+const enabledOAuth2 = [
+	...oauthSites.filter( oauthSite => {
+		let site = new URL(oauthSite);
+		site = site.hostname + site.pathname.slice(0, -1);
+		return ( process.env[`oauth_${site}`] && process.env[`oauth_${site}_secret`] );
+	} ).map( oauthSite => {
+		let site = new URL(oauthSite);
+		return {
+			id: site.hostname + site.pathname.slice(0, -1),
+			name: oauthSite, url: oauthSite,
+		};
+	} )
+];
+if ( process.env.oauth_miraheze && process.env.oauth_miraheze_secret ) {
+	enabledOAuth2.unshift({
+		id: 'miraheze',
+		name: 'Miraheze',
+		url: 'https://meta.miraheze.org/w/',
+	});
+}
+if ( process.env.oauth_wikimedia && process.env.oauth_wikimedia_secret ) {
+	enabledOAuth2.unshift({
+		id: 'wikimedia',
+		name: 'Wikimedia (Wikipedia)',
+		url: 'https://meta.wikimedia.org/w/',
+	});
+}
+
 const slashCommands = require('../interactions/commands.json');
 
 got.get( `https://discord.com/api/v8/applications/${process.env.bot}/commands`, {
@@ -110,9 +140,9 @@ const sessionData = new Map();
 const settingsData = new Map();
 
 /**
- * @type {Set<String>}
+ * @type {Map<String, String>}
  */
-const oauthVerify = new Set();
+const oauthVerify = new Map();
 
 /**
  * @type {Map<Number, PromiseConstructor>}
@@ -121,7 +151,7 @@ const messages = new Map();
 var messageId = 1;
 
 process.on( 'message', message => {
-	if ( message?.id === 'verifyUser' ) return oauthVerify.add(message.state);
+	if ( message?.id === 'verifyUser' ) return oauthVerify.set(message.state, message.user);
 	if ( message?.id ) {
 		if ( message.data.error ) messages.get(message.id).reject(message.data.error);
 		else messages.get(message.id).resolve(message.data.response);
@@ -271,6 +301,32 @@ function createNotice($, notice, dashboardLang, args = []) {
 			text.text(dashboardLang.get('notice.mwversion.text', false, args[0], args[1]));
 			note = $('<a target="_blank">').text('https://www.mediawiki.org/wiki/MediaWiki_1.30').attr('href', 'https://www.mediawiki.org/wiki/MediaWiki_1.30');
 			break;
+		case 'oauth':
+			type = 'success';
+			title.text(dashboardLang.get('notice.oauth.title'));
+			text.text(dashboardLang.get('notice.oauth.text'));
+			break;
+		case 'oauthfail':
+			type = 'error';
+			title.text(dashboardLang.get('notice.oauthfail.title'));
+			text.text(dashboardLang.get('notice.oauthfail.text'));
+			break;
+		case 'oauthverify':
+			type = 'success';
+			title.text(dashboardLang.get('notice.oauthverify.title'));
+			text.text(dashboardLang.get('notice.oauthverify.text'));
+			break;
+		case 'oauthother':
+			type = 'info';
+			title.text(dashboardLang.get('notice.oauthother.title'));
+			text.text(dashboardLang.get('notice.oauthother.text'));
+			note = $('<a>').text(dashboardLang.get('notice.oauthother.note')).attr('href', args[0]);
+			break;
+		case 'oauthlogin':
+			type = 'info';
+			title.text(dashboardLang.get('notice.oauthlogin.title'));
+			text.text(dashboardLang.get('notice.oauthlogin.text'));
+			break;
 		case 'nochange':
 			type = 'info';
 			title.text(dashboardLang.get('notice.nochange.title'));
@@ -381,4 +437,4 @@ function hasPerm(all = 0, ...permission) {
 	} );
 }
 
-module.exports = {got, db, oauth, slashCommands, sessionData, settingsData, oauthVerify, sendMsg, addWidgets, createNotice, escapeText, hasPerm};
+module.exports = {got, db, oauth, enabledOAuth2, slashCommands, sessionData, settingsData, oauthVerify, sendMsg, addWidgets, createNotice, escapeText, hasPerm};

+ 39 - 4
database.js

@@ -99,6 +99,21 @@ CREATE INDEX idx_verifynotice_guild ON verifynotice (
     guild
 );
 
+CREATE TABLE oauthusers (
+    userid TEXT NOT NULL,
+    site   TEXT NOT NULL,
+    token  TEXT,
+    UNIQUE (
+        userid,
+        site
+    )
+);
+
+CREATE INDEX idx_oauthusers_userid ON oauthusers (
+    userid,
+    site
+);
+
 CREATE TABLE rcgcdw (
     guild    TEXT    NOT NULL
                      REFERENCES discord (main) ON DELETE CASCADE,
@@ -142,14 +157,14 @@ CREATE INDEX idx_blocklist_wiki ON blocklist (
 );
 
 COMMIT TRANSACTION;
-ALTER DATABASE "${process.env.PGDATABASE}" SET my.version TO 1;
+ALTER DATABASE "${process.env.PGDATABASE}" SET my.version TO 4;
 `,`
 BEGIN TRANSACTION;
 
 CREATE TABLE verifynotice (
-    guild      TEXT    UNIQUE
-                       NOT NULL
-                       REFERENCES discord (main) ON DELETE CASCADE,
+    guild      TEXT UNIQUE
+                    NOT NULL
+                    REFERENCES discord (main) ON DELETE CASCADE,
     logchannel TEXT,
     onsuccess  TEXT,
     onmatch    TEXT
@@ -169,6 +184,26 @@ ADD COLUMN flags INTEGER NOT NULL DEFAULT 0;
 
 COMMIT TRANSACTION;
 ALTER DATABASE "${process.env.PGDATABASE}" SET my.version TO 3;
+`,`
+BEGIN TRANSACTION;
+
+CREATE TABLE oauthusers (
+    userid TEXT NOT NULL,
+    site   TEXT NOT NULL,
+    token  TEXT,
+    UNIQUE (
+        userid,
+        site
+    )
+);
+
+CREATE INDEX idx_oauthusers_userid ON oauthusers (
+    userid,
+    site
+);
+
+COMMIT TRANSACTION;
+ALTER DATABASE "${process.env.PGDATABASE}" SET my.version TO 4;
 `];
 
 module.exports = db.connect().then( () => {

+ 25 - 9
functions/verify.js

@@ -4,7 +4,7 @@ var db = require('../util/database.js');
 const Lang = require('../util/i18n.js');
 const Wiki = require('../util/wiki.js');
 const logging = require('../util/logging.js');
-const {got, oauthVerify, escapeFormatting} = require('../util/functions.js');
+const {got, oauthVerify, allowDelete, escapeFormatting} = require('../util/functions.js');
 const toTitle = require('../util/wiki.js').toTitle;
 
 /**
@@ -592,10 +592,12 @@ function verify(lang, channel, member, username, wiki, rows, old_username = '')
  * @param {String} access_token - Access token.
  * @param {Object} [settings] - Settings to skip oauth.
  * @param {import('discord.js').TextChannel} settings.channel - The channel.
- * @param {String} settings.username - The username.
  * @param {String} settings.user - The user id.
+ * @param {String} [settings.wiki] - The OAuth2 wiki.
+ * @param {String} [settings.username] - The username.
  * @param {String} [settings.token] - The webhook token.
- * @param {Function} settings.send - The function to edit the message.
+ * @param {Function} [settings.send] - The function to edit the message.
+ * @param {import('discord.js').Message} [settings.sourceMessage] - The source message with the command.
  */
 global.verifyOauthUser = function(state, access_token, settings) {
 	if ( state && access_token && oauthVerify.has(state) ) {
@@ -896,14 +898,20 @@ global.verifyOauthUser = function(state, access_token, settings) {
 				}).catch(log_error);
 			}, log_error );
 
+			/**
+			 * Send the message responding to the OAuth2 verification.
+			 * @param {String} content - The message content.
+			 * @param {import('discord.js').MessageOptions} options - The message options.
+			 * @returns {Promise<import('discord.js').Message?>}
+			 */
 			function sendMessage(content, options) {
-				var msg;
+				var msg = Promise.resolve();
 				if ( settings.send ) msg = settings.send(member.toString() + ', ' + content, options);
 				else if ( settings.token ) {
 					msg = channel.client.api.webhooks(channel.client.user.id, settings.token).post( {
 						data: {
 							content: member.toString() + ', ' + content,
-							allowed_mentions: options.allowed_mentions,
+							allowed_mentions: options.allowedMentions,
 							embeds: ( options.embed ? [options.embed] : [] ),
 							components: ( options.components || [] ),
 							flags: ( (verifynotice.flags & 1 << 0) === 1 << 0 ? 64 : 0 )
@@ -922,14 +930,18 @@ global.verifyOauthUser = function(state, access_token, settings) {
 							} );
 							member.send(channel.toString() + '; ' + content, Object.assign({}, options, {embed: dmEmbed})).then( message => {
 								allowDelete(message, member.id);
+								if ( settings.sourceMessage ) {
+									settings.sourceMessage.reactEmoji('📩');
+									settings.sourceMessage.delete({timeout: 60000, reason: lang.get('verify.footer')}).catch(log_error);
+								}
 							}, error => {
 								if ( error?.code === 50007 ) { // CANNOT_MESSAGE_USER
-									return channel.send(member.toString() + ', ' + content, options);
+									return channel.send(member.toString() + ', ' + content, options).catch(log_error);
 								}
 								log_error(error);
 							} );
 						}
-						else return channel.send(member.toString() + ', ' + content, options);
+						else return channel.send(member.toString() + ', ' + content, options).catch(log_error);
 					} );
 				}
 				else if ( (verifynotice.flags & 1 << 0) === 1 << 0 ) {
@@ -942,14 +954,18 @@ global.verifyOauthUser = function(state, access_token, settings) {
 					} );
 					member.send(channel.toString() + '; ' + content, Object.assign({}, options, {embed: dmEmbed})).then( message => {
 						allowDelete(message, member.id);
+						if ( settings.sourceMessage ) {
+							settings.sourceMessage.reactEmoji('📩');
+							settings.sourceMessage.delete({timeout: 60000, reason: lang.get('verify.footer')}).catch(log_error);
+						}
 					}, error => {
 						if ( error?.code === 50007 ) { // CANNOT_MESSAGE_USER
-							return channel.send(member.toString() + ', ' + content, options);
+							return channel.send(member.toString() + ', ' + content, options).catch(log_error);
 						}
 						log_error(error);
 					} );
 				}
-				else msg = channel.send(member.toString() + ', ' + content, options);
+				else msg = channel.send(member.toString() + ', ' + content, options).catch(log_error);
 				return msg;
 			}
 		}, error => {

+ 344 - 159
interactions/verify.js

@@ -1,7 +1,7 @@
 const {randomBytes} = require('crypto');
 var db = require('../util/database.js');
 var verify = require('../functions/verify.js');
-const {oauthVerify, sendMessage} = require('../util/functions.js');
+const {got, oauthVerify, sendMessage} = require('../util/functions.js');
 
 /**
  * Wiki user verification.
@@ -57,45 +57,97 @@ function slash_verify(interaction, lang, wiki, channel) {
 			if ( wiki.isWikimedia() ) oauth.push('wikimedia');
 			if ( wiki.isMiraheze() ) oauth.push('miraheze');
 			if ( process.env['oauth_' + ( oauth[1] || oauth[0] )] && process.env['oauth_' + ( oauth[1] || oauth[0] ) + '_secret'] ) {
-				let state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
-				while ( oauthVerify.has(state) ) {
-					state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
-				}
-				oauthVerify.set(state, {
-					state, wiki: wiki.href, channel,
-					user: interaction.user.id,
-					token: interaction.token
-				});
-				interaction.client.shard.send({id: 'verifyUser', state});
-				let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
-					response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
-					client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
-				}).toString();
-				return interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
-					data: {
-						type: 4,
-						data: {
-							content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
-							allowed_mentions,
-							components: [
-								{
-									type: 1,
-									components: [
-										{
-											type: 2,
-											style: 5,
-											label: lang.get('verify.oauth_button'),
-											emoji: {id: null, name: '🔗'},
-											url: oauthURL,
-											disabled: false
-										}
-									]
-								}
-							],
-							flags: 64
+				return db.query( 'SELECT token FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( oauth[1] || oauth[0] )] ).then( ({rows: [row]}) => {
+					if ( row?.token ) return got.post( wiki + 'rest.php/oauth2/access_token', {
+						form: {
+							grant_type: 'refresh_token', refresh_token: row.token,
+							redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+							client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )],
+							client_secret: process.env['oauth_' + ( oauth[1] || oauth[0] ) + '_secret']
+						}
+					} ).then( response => {
+						var body = response.body;
+						if ( response.statusCode !== 200 || !body?.access_token ) {
+							console.log( '- ' + response.statusCode + ': Error while refreshing the mediawiki token: ' + ( body?.message || body?.error ) );
+							return Promise.reject(row);
 						}
+						if ( body?.refresh_token ) db.query( 'UPDATE oauthusers SET token = $1 WHERE userid = $2 AND site = $3', [body.refresh_token, interaction.user.id, ( oauth[1] || oauth[0] )] ).then( () => {
+							console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully updated.' );
+						}, dberror => {
+							console.log( '- Dashboard: Error while updating the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+						} );
+						return interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
+							data: {
+								type: 5,
+								data: {
+									allowed_mentions,
+									flags: ( (rows[0].flags & 1 << 0) === 1 << 0 ? 64 : 0 )
+								}
+							}
+						} ).then( () => {
+							return global.verifyOauthUser('', body.access_token, {
+								wiki: wiki.href, channel,
+								user: interaction.user.id,
+								token: interaction.token
+							});
+						}, log_error );
+					}, error => {
+						console.log( '- Error while refreshing the mediawiki token: ' + error );
+						return Promise.reject(row);
+					} );
+					return Promise.reject(row);
+				}, dberror => {
+					console.log( '- Error while getting the OAuth2 token: ' + dberror );
+					return Promise.reject();
+				} ).catch( row => {
+					if ( row ) {
+						if ( !row?.hasOwnProperty?.('token') ) console.log( '- Error while checking the OAuth2 refresh token: ' + row );
+						else if ( row.token ) db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( oauth[1] || oauth[0] )] ).then( () => {
+							console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully deleted.' );
+						}, dberror => {
+							console.log( '- Dashboard: Error while deleting the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+						} );
+					}
+					let state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
+					while ( oauthVerify.has(state) ) {
+						state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 					}
-				} ).catch(log_error);
+					oauthVerify.set(state, {
+						state, wiki: wiki.href, channel,
+						user: interaction.user.id,
+						token: interaction.token
+					});
+					interaction.client.shard.send({id: 'verifyUser', state, user: ( row?.token === null ? '' : interaction.user.id )});
+					let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
+						response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+						client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
+					}).toString();
+					return interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
+						data: {
+							type: 4,
+							data: {
+								content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
+								allowed_mentions,
+								components: [
+									{
+										type: 1,
+										components: [
+											{
+												type: 2,
+												style: 5,
+												label: lang.get('verify.oauth_button'),
+												emoji: {id: null, name: '🔗'},
+												url: oauthURL,
+												disabled: false
+											}
+										]
+									}
+								],
+								flags: 64
+							}
+						}
+					} ).catch(log_error);
+				} );
 			}
 		}
 		
@@ -139,46 +191,88 @@ function slash_verify(interaction, lang, wiki, channel) {
 			return channel.guild.members.fetch(interaction.user.id).then( member => {
 				return verify(lang, channel, member, username, wiki, rows).then( result => {
 					if ( result.oauth.length ) {
-						let state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
-						while ( oauthVerify.has(state) ) {
-							state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
-						}
-						oauthVerify.set(state, {
-							state, wiki: wiki.href, channel,
-							user: interaction.user.id,
-							token: interaction.token
-						});
-						interaction.client.shard.send({id: 'verifyUser', state});
-						let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
-							response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
-							client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )], state
-						}).toString();
-						let message = {
-							content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
-							allowed_mentions,
-							components: [
-								{
-									type: 1,
-									components: [
-										{
-											type: 2,
-											style: 5,
-											label: lang.get('verify.oauth_button'),
-											emoji: {id: null, name: '🔗'},
-											url: oauthURL,
-											disabled: false
-										}
-									]
+						return db.query( 'SELECT token FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( result.oauth[1] || result.oauth[0] )] ).then( ({rows: [row]}) => {
+							if ( row?.token ) return got.post( wiki + 'rest.php/oauth2/access_token', {
+								form: {
+									grant_type: 'refresh_token', refresh_token: row.token,
+									redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+									client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )],
+									client_secret: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] ) + '_secret']
 								}
-							]
-						}
-						if ( result.send_private ) return sendMessage(interaction, message, channel, false);
-						message.flags = 64;
-						return interaction.client.api.webhooks(interaction.application_id, interaction.token).messages('@original').delete().then( () => {
-							return interaction.client.api.webhooks(interaction.application_id, interaction.token).post( {
-								data: message
-							} ).catch(log_error);
-						}, log_error );
+							} ).then( response => {
+								var body = response.body;
+								if ( response.statusCode !== 200 || !body?.access_token ) {
+									console.log( '- ' + response.statusCode + ': Error while refreshing the mediawiki token: ' + ( body?.message || body?.error ) );
+									return Promise.reject(row);
+								}
+								if ( body?.refresh_token ) db.query( 'UPDATE oauthusers SET token = $1 WHERE userid = $2 AND site = $3', [body.refresh_token, interaction.user.id, ( result.oauth[1] || result.oauth[0] )] ).then( () => {
+									console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully updated.' );
+								}, dberror => {
+									console.log( '- Dashboard: Error while updating the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+								} );
+								return global.verifyOauthUser('', body.access_token, {
+									wiki: wiki.href, channel,
+									user: interaction.user.id,
+									token: interaction.token
+								});
+							}, error => {
+								console.log( '- Error while refreshing the mediawiki token: ' + error );
+								return Promise.reject(row);
+							} );
+							return Promise.reject(row);
+						}, dberror => {
+							console.log( '- Error while getting the OAuth2 token: ' + dberror );
+							return Promise.reject();
+						} ).catch( row => {
+							if ( row ) {
+								if ( !row?.hasOwnProperty?.('token') ) console.log( '- Error while checking the OAuth2 refresh token: ' + row );
+								else if ( row.token ) db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( result.oauth[1] || result.oauth[0] )] ).then( () => {
+									console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully deleted.' );
+								}, dberror => {
+									console.log( '- Dashboard: Error while deleting the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+								} );
+							}
+							let state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
+							while ( oauthVerify.has(state) ) {
+								state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
+							}
+							oauthVerify.set(state, {
+								state, wiki: wiki.href, channel,
+								user: interaction.user.id,
+								token: interaction.token
+							});
+							interaction.client.shard.send({id: 'verifyUser', state, user: ( row?.token === null ? '' : interaction.user.id )});
+							let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
+								response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+								client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )], state
+							}).toString();
+							let message = {
+								content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
+								allowed_mentions,
+								components: [
+									{
+										type: 1,
+										components: [
+											{
+												type: 2,
+												style: 5,
+												label: lang.get('verify.oauth_button'),
+												emoji: {id: null, name: '🔗'},
+												url: oauthURL,
+												disabled: false
+											}
+										]
+									}
+								]
+							}
+							if ( result.send_private ) return sendMessage(interaction, message, channel, false);
+							message.flags = 64;
+							return interaction.client.api.webhooks(interaction.application_id, interaction.token).messages('@original').delete().then( () => {
+								return interaction.client.api.webhooks(interaction.application_id, interaction.token).post( {
+									data: message
+								} ).catch(log_error);
+							}, log_error );
+						} );
 					}
 					var message = {
 						content: reply + result.content,
@@ -335,49 +429,98 @@ function slash_verify(interaction, lang, wiki, channel) {
 			if ( wiki.isMiraheze() ) oauth.push('miraheze');
 			if ( process.env['oauth_' + ( oauth[1] || oauth[0] )] && process.env['oauth_' + ( oauth[1] || oauth[0] ) + '_secret'] ) {
 				console.log( interaction.guild_id + ': Button: ' + interaction.data.custom_id + ': OAuth2' );
-				let state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
-				while ( oauthVerify.has(state) ) {
-					state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
-				}
-				oauthVerify.set(state, {
-					state, wiki: wiki.href, channel,
-					user: interaction.user.id,
-					token: interaction.token
-				});
-				interaction.client.shard.send({id: 'verifyUser', state});
-				let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
-					response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
-					client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
-				}).toString();
-				interaction.message.components = [];
-				interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
-					data: {
-						type: 7,
-						data: interaction.message
-					}
-				} ).catch(log_error);
-				return interaction.client.api.webhooks(interaction.application_id, interaction.token).post( {
-					data: {
-						content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
-						allowed_mentions,
-						components: [
-							{
-								type: 1,
-								components: [
-									{
-										type: 2,
-										style: 5,
-										label: lang.get('verify.oauth_button'),
-										emoji: {id: null, name: '🔗'},
-										url: oauthURL,
-										disabled: false
-									}
-								]
+				return db.query( 'SELECT token FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( oauth[1] || oauth[0] )] ).then( ({rows: [row]}) => {
+					if ( row?.token ) return got.post( wiki + 'rest.php/oauth2/access_token', {
+						form: {
+							grant_type: 'refresh_token', refresh_token: row.token,
+							redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+							client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )],
+							client_secret: process.env['oauth_' + ( oauth[1] || oauth[0] ) + '_secret']
+						}
+					} ).then( response => {
+						var body = response.body;
+						if ( response.statusCode !== 200 || !body?.access_token ) {
+							console.log( '- ' + response.statusCode + ': Error while refreshing the mediawiki token: ' + ( body?.message || body?.error ) );
+							return Promise.reject(row);
+						}
+						if ( body?.refresh_token ) db.query( 'UPDATE oauthusers SET token = $1 WHERE userid = $2 AND site = $3', [body.refresh_token, interaction.user.id, ( oauth[1] || oauth[0] )] ).then( () => {
+							console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully updated.' );
+						}, dberror => {
+							console.log( '- Dashboard: Error while updating the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+						} );
+						return interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
+							data: {
+								type: 7,
+								data: interaction.message
 							}
-						],
-						flags: 64
+						} ).then( () => {
+							return global.verifyOauthUser('', body.access_token, {
+								wiki: wiki.href, channel,
+								user: interaction.user.id,
+								token: interaction.token
+							});
+						}, log_error );
+					}, error => {
+						console.log( '- Error while refreshing the mediawiki token: ' + error );
+						return Promise.reject(row);
+					} );
+					return Promise.reject(row);
+				}, dberror => {
+					console.log( '- Error while getting the OAuth2 token: ' + dberror );
+					return Promise.reject();
+				} ).catch( row => {
+					if ( row ) {
+						if ( !row?.hasOwnProperty?.('token') ) console.log( '- Error while checking the OAuth2 refresh token: ' + row );
+						else if ( row.token ) db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( oauth[1] || oauth[0] )] ).then( () => {
+							console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully deleted.' );
+						}, dberror => {
+							console.log( '- Dashboard: Error while deleting the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+						} );
+					}
+					let state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
+					while ( oauthVerify.has(state) ) {
+						state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 					}
-				} ).catch(log_error);
+					oauthVerify.set(state, {
+						state, wiki: wiki.href, channel,
+						user: interaction.user.id,
+						token: interaction.token
+					});
+					interaction.client.shard.send({id: 'verifyUser', state, user: ( row?.token === null ? '' : interaction.user.id )});
+					let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
+						response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+						client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
+					}).toString();
+					interaction.message.components = [];
+					interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
+						data: {
+							type: 7,
+							data: interaction.message
+						}
+					} ).catch(log_error);
+					return interaction.client.api.webhooks(interaction.application_id, interaction.token).post( {
+						data: {
+							content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
+							allowed_mentions,
+							components: [
+								{
+									type: 1,
+									components: [
+										{
+											type: 2,
+											style: 5,
+											label: lang.get('verify.oauth_button'),
+											emoji: {id: null, name: '🔗'},
+											url: oauthURL,
+											disabled: false
+										}
+									]
+								}
+							],
+							flags: 64
+						}
+					} ).catch(log_error);
+				} );
 			}
 		}
 
@@ -392,49 +535,91 @@ function slash_verify(interaction, lang, wiki, channel) {
 				console.log( interaction.guild_id + ': Button: ' + interaction.data.custom_id + ' ' + username );
 				return verify(lang, channel, member, username, wiki, rows).then( result => {
 					if ( result.oauth.length ) {
-						let state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
-						while ( oauthVerify.has(state) ) {
-							state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
-						}
-						oauthVerify.set(state, {
-							state, wiki: wiki.href, channel,
-							user: interaction.user.id,
-							token: interaction.token
-						});
-						interaction.client.shard.send({id: 'verifyUser', state});
-						let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
-							response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
-							client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )], state
-						}).toString();
-						interaction.message.components = [];
-						interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
-							data: {
-								type: 7,
-								data: interaction.message
+						return db.query( 'SELECT token FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( result.oauth[1] || result.oauth[0] )] ).then( ({rows: [row]}) => {
+							if ( row?.token ) return got.post( wiki + 'rest.php/oauth2/access_token', {
+								form: {
+									grant_type: 'refresh_token', refresh_token: row.token,
+									redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+									client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )],
+									client_secret: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] ) + '_secret']
+								}
+							} ).then( response => {
+								var body = response.body;
+								if ( response.statusCode !== 200 || !body?.access_token ) {
+									console.log( '- ' + response.statusCode + ': Error while refreshing the mediawiki token: ' + ( body?.message || body?.error ) );
+									return Promise.reject(row);
+								}
+								if ( body?.refresh_token ) db.query( 'UPDATE oauthusers SET token = $1 WHERE userid = $2 AND site = $3', [body.refresh_token, interaction.user.id, ( result.oauth[1] || result.oauth[0] )] ).then( () => {
+									console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully updated.' );
+								}, dberror => {
+									console.log( '- Dashboard: Error while updating the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+								} );
+								return global.verifyOauthUser('', body.access_token, {
+									wiki: wiki.href, channel,
+									user: interaction.user.id,
+									token: interaction.token
+								});
+							}, error => {
+								console.log( '- Error while refreshing the mediawiki token: ' + error );
+								return Promise.reject(row);
+							} );
+							return Promise.reject(row);
+						}, dberror => {
+							console.log( '- Error while getting the OAuth2 token: ' + dberror );
+							return Promise.reject();
+						} ).catch( row => {
+							if ( row ) {
+								if ( !row?.hasOwnProperty?.('token') ) console.log( '- Error while checking the OAuth2 refresh token: ' + row );
+								else if ( row.token ) db.query( 'DELETE FROM oauthusers WHERE userid = $1 AND site = $2', [interaction.user.id, ( result.oauth[1] || result.oauth[0] )] ).then( () => {
+									console.log( '- Dashboard: OAuth2 token for ' + interaction.user.id + ' successfully deleted.' );
+								}, dberror => {
+									console.log( '- Dashboard: Error while deleting the OAuth2 token for ' + interaction.user.id + ': ' + dberror );
+								} );
 							}
-						} ).catch(log_error);
-						return interaction.client.api.webhooks(interaction.application_id, interaction.token).post( {
-							data: {
-								content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
-								allowed_mentions,
-								components: [
-									{
-										type: 1,
-										components: [
-											{
-												type: 2,
-												style: 5,
-												label: lang.get('verify.oauth_button'),
-												emoji: {id: null, name: '🔗'},
-												url: oauthURL,
-												disabled: false
-											}
-										]
-									}
-								],
-								flags: 64
+							let state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
+							while ( oauthVerify.has(state) ) {
+								state = `${result.oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( result.oauth[1] ? ` ${result.oauth[1]}` : '' );
 							}
-						} ).catch(log_error);
+							oauthVerify.set(state, {
+								state, wiki: wiki.href, channel,
+								user: interaction.user.id,
+								token: interaction.token
+							});
+							interaction.client.shard.send({id: 'verifyUser', state, user: ( row?.token === null ? '' : interaction.user.id )});
+							let oauthURL = wiki + 'rest.php/oauth2/authorize?' + new URLSearchParams({
+								response_type: 'code', redirect_uri: new URL('/oauth/mw', process.env.dashboard).href,
+								client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )], state
+							}).toString();
+							interaction.message.components = [];
+							interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
+								data: {
+									type: 7,
+									data: interaction.message
+								}
+							} ).catch(log_error);
+							return interaction.client.api.webhooks(interaction.application_id, interaction.token).post( {
+								data: {
+									content: reply + lang.get('verify.oauth_message', '<' + oauthURL + '>'),
+									allowed_mentions,
+									components: [
+										{
+											type: 1,
+											components: [
+												{
+													type: 2,
+													style: 5,
+													label: lang.get('verify.oauth_button'),
+													emoji: {id: null, name: '🔗'},
+													url: oauthURL,
+													disabled: false
+												}
+											]
+										}
+									],
+									flags: 64
+								}
+							} ).catch(log_error);
+						} );
 					}
 					var message = {
 						content: reply + result.content,

+ 8 - 7
util/wiki.js

@@ -15,8 +15,6 @@ const wikimediaSites = [
 	'wikivoyage.org'
 ];
 
-const oauthSites = [];
-
 const urlSpaceReplacement = {
 	'https://www.wikihow.com/': '-',
 	'https://wikihow.com/': '-'
@@ -36,7 +34,7 @@ class Wiki extends URL {
 	constructor(wiki = defaultSettings.wiki, base = defaultSettings.wiki) {
 		super(wiki, base);
 		this.protocol = 'https';
-		let articlepath = '/index.php?title=$1';
+		let articlepath = this.pathname + 'index.php?title=$1';
 		if ( this.isFandom() ) articlepath = this.pathname + 'wiki/$1';
 		this.gamepedia = this.hostname.endsWith( '.gamepedia.com' );
 		if ( this.isGamepedia() ) articlepath = '/$1';
@@ -50,7 +48,7 @@ class Wiki extends URL {
 		this.miraheze = this.hostname.endsWith( '.miraheze.org' );
 		this.wikimedia = wikimediaSites.includes( this.hostname.split('.').slice(-2).join('.') );
 		this.centralauth = ( ( this.isWikimedia() || this.isMiraheze() ) ? 'CentralAuth' : 'local' );
-		this.oauth2 = oauthSites.includes( this.href );
+		this.oauth2 = Wiki.oauthSites.includes( this.href );
 		this.spaceReplacement = ( urlSpaceReplacement.hasOwnProperty(this.href) ? urlSpaceReplacement[this.href] : '_' );
 	}
 
@@ -95,7 +93,7 @@ class Wiki extends URL {
 		this.miraheze = /^(?:https?:)?\/\/static\.miraheze\.org\//.test(logo);
 		this.gamepedia = ( gamepedia === 'true' ? true : this.hostname.endsWith( '.gamepedia.com' ) );
 		this.wikimedia = wikimediaSites.includes( this.hostname.split('.').slice(-2).join('.') );
-		this.oauth2 = oauthSites.includes( this.href );
+		this.oauth2 = Wiki.oauthSites.includes( this.href );
 		this.spaceReplacement = ( urlSpaceReplacement.hasOwnProperty(this.href) ? urlSpaceReplacement[this.href] : this.spaceReplacement );
 		return this;
 	}
@@ -179,8 +177,6 @@ class Wiki extends URL {
 		if ( !querystring.toString().length ) title = ( title || this.mainpage );
 		title = title.replace( / /g, this.spaceReplacement ).replace( /%/g, '%2525' );
 		let link = new URL(this.articleURL);
-		link.username = '';
-		link.password = '';
 		link.pathname = link.pathname.replace( '$1', title.replace( /\\/g, '%5C' ) );
 		link.searchParams.forEach( (value, name, searchParams) => {
 			if ( value.includes( '$1' ) ) {
@@ -259,6 +255,9 @@ class Wiki extends URL {
 		return null;
 	}
 
+	/** @type {String[]} - Sites that support verification using OAuth2. */
+	static oauthSites = [];
+
 	[util.inspect.custom](depth, opts) {
 		if ( typeof depth === 'number' && depth < 0 ) return this;
 		const wiki = {
@@ -297,6 +296,8 @@ class articleURL extends URL {
 	constructor(articlepath = '/index.php?title=$1', wiki) {
 		super(articlepath, wiki);
 		this.protocol = 'https';
+		this.username = '';
+		this.password = '';
 		this.mainpage = '';
 		this.spaceReplacement = ( wiki?.spaceReplacement || '_' );
 	}