Browse Source

Add verification using OAuth2

Markus-Rost 4 năm trước cách đây
mục cha
commit
47de869a86
16 tập tin đã thay đổi với 691 bổ sung55 xóa
  1. 1 1
      cmds/help.js
  2. 10 11
      cmds/rcscript.js
  3. 9 10
      cmds/settings.js
  4. 10 11
      cmds/verification.js
  5. 88 2
      cmds/verify.js
  6. 9 2
      cmds/wiki/overview.js
  7. 6 2
      dashboard/index.js
  8. 53 5
      dashboard/oauth.js
  9. 9 3
      dashboard/util.js
  10. 1 1
      functions/helpsetup.js
  11. 243 4
      functions/verify.js
  12. 5 0
      i18n/en.json
  13. 204 2
      interactions/verify.js
  14. 10 0
      main.js
  15. 6 0
      util/functions.js
  16. 27 1
      util/wiki.js

+ 1 - 1
cmds/help.js

@@ -96,7 +96,7 @@ function cmd_help(lang, msg, args, line, wiki) {
 				if ( process.env.READONLY ) cmdlist = msg.author.toString() + ', ' + lang.get('general.readonly') + '\n' + process.env.invite + '\n\n' + cmdlist;
 				cmdlist += formathelp(helplist.admin, msg, lang);
 				cmdlist += '\n\n🔸 ' + lang.get('help.adminfooter');
-				cmdlist += '\n\t\t' + new URL(( msg.channel.isGuild() ? `/guild/${msg.guild.id}/settings` : '/' ), process.env.dashboard).href;
+				if ( process.env.dashboard ) cmdlist += '\n\t\t' + new URL(( msg.channel.isGuild() ? `/guild/${msg.guild.id}/settings` : '/' ), process.env.dashboard).href;
 				msg.sendChannel( cmdlist, {split:{char:'\n🔹',prepend:'🔹',maxLength}} );
 			}
 			else {

+ 10 - 11
cmds/rcscript.js

@@ -50,14 +50,13 @@ function cmd_rcscript(lang, msg, args, line, wiki) {
 			url: new URL(`/guild/${msg.guild.id}/rcscript`, process.env.dashboard).href,
 			disabled: false
 		};
-		var components = [
-			{
-				type: 1,
-				components: [
-					button
-				]
-			}
-		];
+		var components = [];
+		if ( process.env.dashboard ) components.push({
+			type: 1,
+			components: [
+				button
+			]
+		});
 
 		if ( args[0] === 'add' ) {
 			if ( !msg.channel.permissionsFor(msg.client.user).has('MANAGE_WEBHOOKS') ) {
@@ -477,7 +476,7 @@ function cmd_rcscript(lang, msg, args, line, wiki) {
 				return;
 			} ).then( channel => {
 				var text = lang.get('rcscript.current_selected', selected_row.configid);
-				text += `\n<${button.url}>\n`;
+				if ( process.env.dashboard ) text += `\n<${button.url}>\n`;
 				text += '\n' + lang.get('rcscript.channel') + ' <#' + channel + '>\n';
 				text += '\n' + lang.get('rcscript.wiki') + ' <' + selected_row.wiki + '>';
 				text += '\n`' + cmd + ' wiki ' + lang.get('rcscript.new_wiki') + '`\n';
@@ -520,7 +519,7 @@ function cmd_rcscript(lang, msg, args, line, wiki) {
 			var text = '';
 			if ( rows.length ) {
 				text += lang.get('rcscript.current');
-				text += `\n<${button.url}>`;
+				if ( process.env.dashboard ) text += `\n<${button.url}>`;
 				text += rows.map( row => {
 					var cmd = prefix + 'rcscript' + ( only ? '' : ' ' + row.configid );
 					var row_text = '\n';
@@ -547,7 +546,7 @@ function cmd_rcscript(lang, msg, args, line, wiki) {
 			}
 			else {
 				text += lang.get('rcscript.missing');
-				text += `\n<${button.url}>`;
+				if ( process.env.dashboard ) text += `\n<${button.url}>`;
 			}
 			if ( rows.length < limit ) text += '\n\n' + lang.get('rcscript.add_more') + '\n`' + prefix + 'rcscript add ' + lang.get('rcscript.new_wiki') + '`';
 			msg.replyMsg( text, {split:true,components}, true );

+ 9 - 10
cmds/settings.js

@@ -37,18 +37,17 @@ function cmd_settings(lang, msg, args, line, wiki) {
 			url: new URL(`/guild/${msg.guild.id}/settings`, process.env.dashboard).href,
 			disabled: false
 		};
-		var components = [
-			{
-				type: 1,
-				components: [
-					button
-				]
-			}
-		];
+		var components = [];
+		if ( process.env.dashboard ) components.push({
+			type: 1,
+			components: [
+				button
+			]
+		});
 		var text = lang.get('settings.missing', '`' + prefix + 'settings lang`', '`' + prefix + 'settings wiki`');
 		if ( rows.length ) {
 			text = lang.get('settings.current');
-			text += `\n<${button.url}>`;
+			if ( process.env.dashboard ) text += `\n<${button.url}>`;
 			text += '\n' + lang.get('settings.currentlang') + ' `' + allLangs.names[guild.lang] + '` - `' + prefix + 'settings lang`';
 			if ( patreons[msg.guild.id] ) text += '\n' + lang.get('settings.currentprefix') + ' `' + prefix + '` - `' + prefix + 'settings prefix`';
 			text += '\n' + lang.get('settings.currentrole') + ' ' + ( guild.role ? `<@&${guild.role}>` : '@everyone' ) + ' - `' + prefix + 'settings role`';
@@ -75,7 +74,7 @@ function cmd_settings(lang, msg, args, line, wiki) {
 			} ) || guild, {channel: msg.channel.id});
 			text = lang.get('settings.' + prelang + 'current');
 			button.url = new URL(`/guild/${msg.guild.id}/settings/${msg.channel.id}`, process.env.dashboard).href;
-			text += `\n<${button.url}>`;
+			if ( process.env.dashboard ) text += `\n<${button.url}>`;
 			if ( patreons[msg.guild.id] ) {
 				text += '\n' + lang.get('settings.currentlang') + ' `' + allLangs.names[channel.lang] + '` - `' + prefix + 'settings channel lang`';
 				text += '\n' + lang.get('settings.currentrole') + ' ' + ( channel.role ? `<@&${channel.role}>` : '@everyone' ) + ' - `' + prefix + 'settings channel role`';

+ 10 - 11
cmds/verification.js

@@ -37,14 +37,13 @@ function cmd_verification(lang, msg, args, line, wiki) {
 			url: new URL(`/guild/${msg.guild.id}/verification`, process.env.dashboard).href,
 			disabled: false
 		};
-		var components = [
-			{
-				type: 1,
-				components: [
-					button
-				]
-			}
-		];
+		var components = [];
+		if ( process.env.dashboard ) components.push({
+			type: 1,
+			components: [
+				button
+			]
+		});
 		if ( args[0] && args[0].toLowerCase() === 'add' ) {
 			var limit = verificationLimit[( patreons[msg.guild.id] ? 'patreon' : 'default' )];
 			if ( rows.length >= limit ) return msg.replyMsg( lang.get('verification.max_entries'), {}, true );
@@ -99,12 +98,12 @@ function cmd_verification(lang, msg, args, line, wiki) {
 			var text = '';
 			if ( rows.length ) {
 				text += lang.get('verification.current');
-				text += `\n<${button.url}>`;
+				if ( process.env.dashboard ) text += `\n<${button.url}>`;
 				text += rows.map( row => formatVerification(false, true, row) ).join('');
 			}
 			else {
 				text += lang.get('verification.missing');
-				text += `\n<${button.url}>`;
+				if ( process.env.dashboard ) text += `\n<${button.url}>`;
 			}
 			text += '\n\n' + lang.get('verification.add_more') + '\n`' + prefix + 'verification add ' + lang.get('verification.new_role') + '`';
 			return msg.sendChannel( '<@' + msg.author.id + '>, ' + text, {split:true,components}, true );
@@ -264,7 +263,7 @@ function cmd_verification(lang, msg, args, line, wiki) {
 				} ) );
 			}
 		}
-		return msg.sendChannel( '<@' + msg.author.id + '>, ' + lang.get('verification.current_selected', row.configid) + `\n<${button.url}>` + formatVerification(true) +'\n\n' + lang.get('verification.delete_current') + '\n`' + prefix + 'verification ' + row.configid + ' delete`', {split:true,components}, true );
+		return msg.sendChannel( '<@' + msg.author.id + '>, ' + lang.get('verification.current_selected', row.configid) + ( process.env.dashboard ? `\n<${button.url}>` : '' ) + formatVerification(true) +'\n\n' + lang.get('verification.delete_current') + '\n`' + prefix + 'verification ' + row.configid + ' delete`', {split:true,components}, true );
 		
 		function formatVerification(showCommands, hideNotice, {
 			configid,

+ 88 - 2
cmds/verify.js

@@ -1,5 +1,7 @@
+const {randomBytes} = require('crypto');
 var db = require('../util/database.js');
 var verify = require('../functions/verify.js');
+const {oauthVerify, allowDelete, escapeFormatting} = require('../util/functions.js');
 
 /**
  * Processes the "verify" command.
@@ -25,7 +27,52 @@ function cmd_verify(lang, msg, args, line, wiki) {
 			if ( msg.onlyVerifyCommand ) return;
 			return msg.replyMsg( lang.get('verify.missing') + ( msg.isAdmin() ? '\n`' + ( patreons[msg.guild.id] || process.env.prefix ) + 'verification`' : '' ) );
 		}
-	
+		
+		if ( ( wiki.isWikimedia() || wiki.isMiraheze() ) && process.env.dashboard ) {
+			let oauth = '';
+			if ( wiki.isWikimedia() ) oauth = 'wikimedia';
+			if ( wiki.isMiraheze() ) oauth = 'miraheze';
+			if ( oauth && process.env[`oauth-${oauth}`] && process.env[`oauth-${oauth}-secret`] ) {
+				let state = `${oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+				while ( oauthVerify.has(state) ) {
+					state = `${oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+				}
+				oauthVerify.set(state, {
+					state, wiki: oauth,
+					channel: msg.channel,
+					user: msg.author.id
+				});
+				msg.client.shard.send({id: 'verifyUser', state});
+				let oauthURL = `https://meta.${oauth}.org/w/rest.php/oauth2/authorize?response_type=code&redirect_uri=${encodeURIComponent('https://settings.wikibot.de/oauth/mw')}&client_id=${process.env['oauth-' + oauth]}&state=${state}`;
+				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
+								}
+							]
+						}
+					]
+				} ).then( message => {
+					msg.reactEmoji('📩');
+					allowDelete(message, msg.author.id);
+				}, error => {
+					if ( error?.code === 50007 ) { // CANNOT_MESSAGE_USER
+						return msg.replyMsg( lang.get('verify.oauth_private') );
+					}
+					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\/)?)/, '' ) );
@@ -39,7 +86,46 @@ function cmd_verify(lang, msg, args, line, wiki) {
 		}
 		msg.reactEmoji('⏳').then( reaction => {
 			verify(lang, msg.channel, msg.member, username, wiki, rows).then( result => {
-				if ( result.reaction ) msg.reactEmoji(result.reaction);
+				if ( result.oauth ) {
+					let state = `${result.oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+					while ( oauthVerify.has(state) ) {
+						state = `${result.oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+					}
+					oauthVerify.set(state, {
+						state, wiki: result.oauth,
+						channel: msg.channel,
+						user: msg.author.id
+					});
+					msg.client.shard.send({id: 'verifyUser', state});
+					let oauthURL = `https://meta.${result.oauth}.org/w/rest.php/oauth2/authorize?response_type=code&redirect_uri=${encodeURIComponent('https://settings.wikibot.de/oauth/mw')}&client_id=${process.env['oauth-' + result.oauth]}&state=${state}`;
+					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);
+					}, 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 {
 					var options = {embed: result.embed, components: []};
 					if ( result.add_button ) options.components.push({

+ 9 - 2
cmds/wiki/overview.js

@@ -12,7 +12,7 @@ const {toFormatting, toPlaintext, escapeFormatting} = require('../../util/functi
  * @param {String} spoiler - If the response is in a spoiler.
  */
 function gamepedia_overview(lang, msg, wiki, reaction, spoiler) {
-	got.get( wiki + 'api.php?action=query&meta=allmessages|siteinfo&ammessages=custom-GamepediaNotice&amenableparser=true&siprop=general|statistics|languages|rightsinfo' + ( wiki.isFandom() ? '|variables' : '' ) + '&siinlanguagecode=' + lang.lang + '&list=logevents&ledir=newer&lelimit=1&leprop=timestamp&titles=Special:Statistics&format=json' ).then( response => {
+	got.get( wiki + 'api.php?action=query&meta=siteinfo' + ( wiki.isFandom() ? '|allmessages&ammessages=custom-GamepediaNotice|custom-FandomMergeNotice&amenableparser=true' : '' ) + '&siprop=general|statistics|languages|rightsinfo' + ( wiki.isFandom() ? '|variables' : '' ) + '&siinlanguagecode=' + lang.lang + '&list=logevents&ledir=newer&lelimit=1&leprop=timestamp&titles=Special:Statistics&format=json' ).then( response => {
 		var body = response.body;
 		if ( body && body.warnings ) log_warn(body.warnings);
 		if ( response.statusCode !== 200 || !body || body.batchcomplete === undefined || !body.query || !body.query.pages ) {
@@ -106,9 +106,16 @@ function gamepedia_overview(lang, msg, wiki, reaction, spoiler) {
 			var manager = [lang.get('overview.manager'), ''];
 			var founder = [lang.get('overview.founder')];
 			var crossover = [lang.get('overview.crossover')];
-			if ( body.query.allmessages[0]['*'] ) {
+			if ( body.query.allmessages?.[0]?.['*'] ) {
 				crossover[1] = '<https://' + body.query.allmessages[0]['*'] + '.gamepedia.com/>';
 			}
+			if ( body.query.allmessages?.[1]?.['*'] ) {
+				let mergeNotice = body.query.allmessages[1]['*'];
+				if ( !mergeNotice.includes( '|' ) ) {
+					mergeNotice = mergeNotice.split('/');
+					crossover[1] = '<https://' + mergeNotice[0] + '.fandom.com/' + ( mergeNotice[1] ? '/' + mergeNotice[1] : '' ) + '>';
+				}
+			}
 			var description = [lang.get('overview.description')];
 			var image = [lang.get('overview.image')];
 			return got.get( 'https://community.fandom.com/api/v1/Wikis/Details?ids=' + wikiid + '&format=json&cache=' + Date.now() ).then( ovresponse => {

+ 6 - 2
dashboard/index.js

@@ -1,3 +1,5 @@
+global.isDebug = ( process.argv[2] === 'debug' );
+
 const http = require('http');
 const pages = require('./oauth.js');
 const dashboard = require('./guilds.js');
@@ -5,8 +7,6 @@ const {db, sessionData, settingsData} = require('./util.js');
 const Lang = require('./i18n.js');
 const allLangs = Lang.allLangs();
 
-global.isDebug = ( process.argv[2] === 'debug' );
-
 const posts = {
 	settings: require('./settings.js').post,
 	verification: require('./verification.js').post,
@@ -127,6 +127,10 @@ const server = http.createServer( (req, res) => {
 
 	var reqURL = new URL(req.url, process.env.dashboard);
 
+	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);

+ 53 - 5
dashboard/oauth.js

@@ -1,9 +1,9 @@
-const crypto = require('crypto');
+const {randomBytes} = require('crypto');
 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, sendMsg, addWidgets, createNotice, hasPerm} = require('./util.js');
+const {got, oauth, sessionData, settingsData, oauthVerify, sendMsg, addWidgets, createNotice, hasPerm} = require('./util.js');
 
 const file = require('fs').readFileSync('./dashboard/login.html');
 
@@ -49,9 +49,9 @@ function dashboard_login(res, dashboardLang, theme, state, action) {
 	);
 	if ( action === 'logout' ) prompt = 'consent';
 	if ( action === 'loginfail' ) responseCode = 400;
-	state = Date.now().toString(16) + crypto.randomBytes(16).toString("hex");
+	state = Date.now().toString(16) + randomBytes(16).toString('hex');
 	while ( sessionData.has(state) ) {
-		state = Date.now().toString(16) + crypto.randomBytes(16).toString("hex");
+		state = Date.now().toString(16) + randomBytes(16).toString('hex');
 	}
 	let invite = oauth.generateAuthUrl( {
 		scope: ['identify', 'guilds', 'bot', 'applications.commands'],
@@ -314,9 +314,57 @@ 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
+ */
+function mediawiki_oauth(res, searchParams) {
+	if ( !searchParams.get('code') || !oauthVerify.has(searchParams.get('state')) ) {
+		res.writeHead(302, {Location: '/login?action=failed'});
+		return res.end();
+	}
+	var state = searchParams.get('state');
+	var site = state.split('-')[0];
+	got.post( 'https://meta.' + site + '.org/w/rest.php/oauth2/access_token', {
+		form: {
+			grant_type: 'authorization_code',
+			code: searchParams.get('code'),
+			redirect_uri: new URL('https://settings.wikibot.de/oauth/mw', process.env.dashboard).href,
+			client_id: process.env[`oauth-${site}`],
+			client_secret: process.env[`oauth-${site}-secret`]
+		}
+	} ).then( response => {
+		var body = response.body;
+		console.log(response.statusCode,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'});
+			return res.end();
+		}
+		sendMsg( {
+			type: 'verifyUser', state,
+			access_token: body.access_token
+		} ).then( () => {
+			oauthVerify.delete(state);
+			res.writeHead(302, {Location: '/login?action=success'});
+			return res.end();
+		}, error => {
+			console.log( '- Dashboard: Error while sending the mediawiki token: ' + error );
+			res.writeHead(302, {Location: '/login?action=failed'});
+			return res.end();
+		} );
+	}, error => {
+		console.log( '- Dashboard: Error while getting the mediawiki token: ' + error );
+		res.writeHead(302, {Location: '/login?action=failed'});
+		return res.end();
+	} );
+}
+
 module.exports = {
 	login: dashboard_login,
 	oauth: dashboard_oauth,
 	refresh: dashboard_refresh,
-	api: dashboard_api
+	api: dashboard_api,
+	verify: mediawiki_oauth
 };

+ 9 - 3
dashboard/util.js

@@ -2,7 +2,7 @@ const got = require('got').extend( {
 	throwHttpErrors: false,
 	timeout: 5000,
 	headers: {
-		'User-Agent': 'Wiki-Bot/dashboard (Discord; ' + process.env.npm_package_name + ')'
+		'User-Agent': 'Wiki-Bot/' + ( isDebug ? 'testing' : process.env.npm_package_version ) + '/dashboard (Discord; ' + process.env.npm_package_name + ')'
 	},
 	responseType: 'json'
 } );
@@ -109,6 +109,11 @@ const sessionData = new Map();
  */
 const settingsData = new Map();
 
+/**
+ * @type {Set<String>}
+ */
+const oauthVerify = new Set();
+
 /**
  * @type {Map<Number, PromiseConstructor>}
  */
@@ -116,7 +121,8 @@ const messages = new Map();
 var messageId = 1;
 
 process.on( 'message', message => {
-	if ( message.id ) {
+	if ( message?.id === 'verifyUser' ) return oauthVerify.add(message.state);
+	if ( message?.id ) {
 		if ( message.data.error ) messages.get(message.id).reject(message.data.error);
 		else messages.get(message.id).resolve(message.data.response);
 		return messages.delete(message.id);
@@ -375,4 +381,4 @@ function hasPerm(all = 0, ...permission) {
 	} ).every( perm => perm );
 }
 
-module.exports = {got, db, oauth, slashCommands, sessionData, settingsData, sendMsg, addWidgets, createNotice, escapeText, hasPerm};
+module.exports = {got, db, oauth, slashCommands, sessionData, settingsData, oauthVerify, sendMsg, addWidgets, createNotice, escapeText, hasPerm};

+ 1 - 1
functions/helpsetup.js

@@ -5,7 +5,7 @@
  */
 function help_setup(lang, msg) {
 	msg.defaultSettings = false;
-	msg.replyMsg( lang.get('general.default', '`' + process.env.prefix + 'settings`') + '\n' + new URL(`/guild/${msg.guild.id}/settings`, process.env.dashboard).href );
+	msg.replyMsg( lang.get('general.default', '`' + process.env.prefix + 'settings`') + ( process.env.dashboard ? '\n' + new URL(`/guild/${msg.guild.id}/settings`, process.env.dashboard).href : '' ) );
 }
 
 module.exports = help_setup;

+ 243 - 4
functions/verify.js

@@ -1,8 +1,10 @@
 const cheerio = require('cheerio');
 const {MessageEmbed} = require('discord.js');
 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 {escapeFormatting} = require('../util/functions.js');
+const {oauthVerify, escapeFormatting} = require('../util/functions.js');
 const toTitle = require('../util/wiki.js').toTitle;
 
 /**
@@ -14,14 +16,14 @@ const toTitle = require('../util/wiki.js').toTitle;
  * @param {import('../util/wiki.js')} wiki - The wiki for the message.
  * @param {Object[]} rows - The verification settings.
  * @param {String} [old_username] - The username before the search.
- * @returns {Promise<{content:String,embed:MessageEmbed,reaction:String,logging:{channel:String,content:String,embed?:MessageEmbed}}>}
+ * @returns {Promise<{content:String,embed:MessageEmbed,add_button:Boolean,reaction:String,oauth:String,logging:{channel:String,content:String,embed?:MessageEmbed}}>}
  */
 function verify(lang, channel, member, username, wiki, rows, old_username = '') {
 	var embed = new MessageEmbed().setFooter( lang.get('verify.footer') ).setTimestamp();
 	var result = {
 		content: '', embed,
 		add_button: channel.permissionsFor(channel.guild.me).has('EMBED_LINKS'),
-		reaction: '',
+		reaction: '', oauth: '',
 		logging: {
 			channel: '',
 			content: '',
@@ -51,6 +53,15 @@ function verify(lang, channel, member, username, wiki, rows, old_username = '')
 			return;
 		}
 		wiki.updateWiki(body.query.general);
+		if ( ( wiki.isWikimedia() || wiki.isMiraheze() ) && process.env.dashboard ) {
+			let oauth = '';
+			if ( wiki.isWikimedia() ) oauth = 'wikimedia';
+			if ( wiki.isMiraheze() ) oauth = 'miraheze';
+			if ( process.env[`oauth-${oauth}`] && process.env[`oauth-${oauth}-secret`] ) {
+				result.oauth = oauth;
+				return;
+			}
+		}
 		if ( !old_username ) logging(wiki, channel.guild.id, 'verification');
 		var queryuser = body.query.users[0];
 		embed.setAuthor( body.query.general.sitename );
@@ -455,6 +466,234 @@ function verify(lang, channel, member, username, wiki, rows, old_username = '')
 	} );
 }
 
+/**
+ * Oauth wiki user verification.
+ * @param {String} state - Unique state for the authorization.
+ * @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 {Function} settings.edit - The function to edit the message.
+ */
+global.verifyOauthUser = function(state, access_token, settings) {
+	if ( state && access_token && oauthVerify.has(state) ) settings = oauthVerify.get(state);
+	if ( !settings?.channel ) return;
+	var channel = settings.channel;
+	var username = settings.username;
+	if ( !username && !channel.permissionsFor(channel.guild.me).has(['VIEW_CHANNEL', 'SEND_MESSAGES']) ) return;
+	Promise.all([
+		db.query( 'SELECT configid, channel, role, editcount, postcount, usergroup, accountage, rename FROM verification WHERE guild = $1 AND channel LIKE $2 ORDER BY configid ASC', [channel.guild.id, '%|' + channel.id + '|%'] ).then( ({rows}) => {
+			if ( !rows.length ) return Promise.reject();
+			return db.query( 'SELECT wiki, lang FROM discord WHERE guild = $1 AND (channel = $2 OR channel = $3 OR channel IS NULL) ORDER BY channel DESC NULLS LAST LIMIT 1', [channel.guild.id, channel.id, '#' + channel.parentID] ).then( ({rows: [row]}) => {
+				return {
+					rows, wiki: new Wiki(row?.wiki),
+					lang: new Lang(( row?.lang || channel?.guild?.preferredLocale ))
+				};
+			} );
+		} ),
+		channel.guild.members.fetch(settings.user),
+		( !username ? got.get( 'https://meta.' + settings.wiki + '.org/w/rest.php/oauth2/resource/profile', {
+			Authorization: `Bearer ${access_token}`
+		} ).then( response => {
+			var body = response.body;
+			console.log(body)
+			if ( response.statusCode !== 200 || !body?.username ) {
+				console.log( '- ' + response.statusCode + ': Error while getting the mediawiki profile: ' + ( body?.message || body?.error ) );
+				return;
+			}
+			username = body.username;
+		}, error => {
+			console.log( '- Error while getting the mediawiki profile: ' + error );
+		} ) : null )
+	]).then( ([{rows, wiki, lang}, member]) => {
+		if ( !username ) return settings.edit?.();
+		got.get( wiki + 'api.php?action=query&meta=siteinfo|globaluserinfo&siprop=general&guiprop=groups&guiuser=' + encodeURIComponent( username ) + '&list=users&usprop=blockinfo|groups|editcount|registration|gender&ususers=' + encodeURIComponent( username ) + '&format=json' ).then( response => {
+			var body = response.body;
+			if ( body && body.warnings ) log_warn(body.warnings);
+			if ( response.statusCode !== 200 || body?.batchcomplete === undefined || !body?.query?.users?.[0] ) {
+				if ( wiki.noWiki(response.url, response.statusCode) ) {
+					console.log( '- This wiki doesn\'t exist!' );
+				}
+				else {
+					console.log( '- ' + response.statusCode + ': Error while getting the user: ' + body?.error?.info );
+				}
+				return settings.edit?.();
+			}
+			wiki.updateWiki(body.query.general);
+			if ( settings.wiki === 'wikimedia' && !wiki.isWikimedia() ) return settings.edit?.();
+			if ( settings.wiki === 'miraheze' && !wiki.isMiraheze() ) return settings.edit?.();
+			logging(wiki, channel.guild.id, 'verification');
+			var queryuser = body.query.users[0];
+			if ( body.query.users.length !== 1 || queryuser.missing !== undefined || queryuser.invalid !== undefined ) return settings.edit?.();
+			var allowedMentions = {
+				users: [
+					member.id
+				]
+			};
+			var embed = new MessageEmbed().setFooter( lang.get('verify.footer') ).setTimestamp().setAuthor( body.query.general.sitename ).addField( lang.get('verify.discord', queryuser.gender), escapeFormatting(member.user.tag), true ).addField( lang.get('verify.wiki', queryuser.gender), lang.get('verify.oauth_used'), true );
+			var pagelink = wiki.toLink('User:' + username, '', '', true);
+			embed.setTitle( escapeFormatting(username) ).setURL( pagelink );
+			if ( queryuser.blockexpiry ) {
+				embed.setColor('#FF0000').setDescription( lang.get('verify.user_blocked', '[' + escapeFormatting(username) + '](' + pagelink + ')', queryuser.gender) );
+				return ( settings.edit || channel.send )(member.toString() + ', ' + lang.get('verify.user_blocked_reply', escapeFormatting(username), queryuser.gender), {embed, allowedMentions}).catch(log_error);
+			}
+			if ( body.query.globaluserinfo.locked !== undefined ) {
+				embed.setColor('#FF0000').setDescription( lang.get('verify.user_gblocked', '[' + escapeFormatting(username) + '](' + pagelink + ')', queryuser.gender) );
+				return ( settings.edit || channel.send )(member.toString() + ', ' + lang.get('verify.user_gblocked_reply', escapeFormatting(username), queryuser.gender), {embed, allowedMentions}).catch(log_error);
+			}
+			queryuser.groups.push(...body.query.globaluserinfo.groups);
+
+			var roles = [];
+			var missing = [];
+			var verified = false;
+			var rename = false;
+			var accountage = ( Date.now() - new Date(queryuser.registration) ) / 86400000;
+			rows.forEach( row => {
+				var and_or = 'some';
+				if ( row.usergroup.startsWith( 'AND|' ) ) {
+					row.usergroup = row.usergroup.replace( 'AND|', '' );
+					and_or = 'every';
+				}
+				if ( queryuser.editcount >= row.editcount && row.usergroup.split('|')[and_or]( usergroup => queryuser.groups.includes( usergroup ) ) && accountage >= row.accountage && row.role.split('|').some( role => !roles.includes( role ) ) ) {
+					verified = true;
+					if ( row.rename ) rename = true;
+					row.role.split('|').forEach( role => {
+						if ( !roles.includes( role ) ) {
+							if ( channel.guild.roles.cache.has(role) && channel.guild.me.roles.highest.comparePositionTo(role) > 0 ) roles.push(role);
+							else if ( !missing.includes( role ) ) missing.push(role);
+						}
+					} );
+				}
+			} );
+			if ( verified ) {
+				embed.setColor('#00FF00').setDescription( lang.get('verify.user_verified', member.toString(), '[' + escapeFormatting(username) + '](' + pagelink + ')', queryuser.gender) + ( rename ? '\n' + lang.get('verify.user_renamed', queryuser.gender) : '' ) );
+				var text = lang.get('verify.user_verified_reply', escapeFormatting(username), queryuser.gender);
+				var verifynotice = {
+					logchannel: '',
+					onsuccess: ''
+				};
+				var verify_promise = [
+					member.roles.add( roles, lang.get('verify.audit_reason', username) ).catch( error => {
+						log_error(error);
+						embed.setColor('#008800');
+						comment.push(lang.get('verify.failed_roles'));
+					} ),
+					db.query( 'SELECT logchannel, onsuccess FROM verifynotice WHERE guild = $1', [channel.guild.id] ).then( ({rows:[row]}) => {
+						if ( !row ) return;
+						verifynotice.logchannel = row.logchannel;
+						if ( row.onsuccess ) verifynotice.onsuccess = parseNotice(row.onsuccess, {
+							editcount: queryuser.editcount,
+							accountage: Math.trunc(accountage),
+							dateformat: lang.get('dateformat')
+						}).trim();
+					}, dberror => {
+						console.log( '- Error while getting the notices: ' + dberror );
+					} )
+				];
+				if ( rename && member.displayName !== username ) {
+					if ( channel.guild.me.roles.highest.comparePositionTo(member.roles.highest) > 0 ) {
+						verify_promise.push(member.setNickname( username.substring(0, 32), lang.get('verify.audit_reason', username) ).catch( error => {
+							log_error(error);
+							embed.setColor('#008800');
+							comment.push(lang.get('verify.failed_rename', queryuser.gender));
+						} ));
+					}
+					else {
+						embed.setColor('#008800');
+						comment.push(lang.get('verify.failed_rename', queryuser.gender));
+					}
+				}
+				return Promise.all(verify_promise).then( () => {
+					var logchannel = ( verifynotice.logchannel ? channel.guild.channels.cache.get(verifynotice.logchannel) : null );
+					var useLogging = false;
+					var logembed;
+					var logtext = '';
+					if ( logchannel && logchannel.isGuild() && logchannel.permissionsFor(channel.guild.me).has(['VIEW_CHANNEL', 'SEND_MESSAGES']) ) {
+						useLogging = true;
+						if ( logchannel.permissionsFor(channel.guild.me).has('EMBED_LINKS') ) {
+							logembed = new MessageEmbed(embed);
+							if ( roles.length ) logembed.addField( lang.get('verify.qualified'), roles.map( role => '<@&' + role + '>' ).join('\n') );
+							if ( missing.length ) logembed.setColor('#008800').addField( lang.get('verify.qualified_error'), missing.map( role => '<@&' + role + '>' ).join('\n') );
+							if ( comment.length ) logembed.setColor('#008800').addField( lang.get('verify.notice'), comment.join('\n') );
+						}
+						else {
+							logtext = '🔸 ' + lang.get('verify.user_verified', member.toString(), escapeFormatting(username), queryuser.gender);
+							if ( rename ) logtext += '\n' + lang.get('verify.user_renamed', queryuser.gender);
+							logtext += '\n<' + pagelink + '>';
+							if ( roles.length ) logtext += '\n**' + lang.get('verify.qualified') + '** ' + roles.map( role => '<@&' + role + '>' ).join(', ');
+							if ( missing.length ) logtext += '\n**' + lang.get('verify.qualified_error') + '** ' + missing.map( role => '<@&' + role + '>' ).join(', ');
+							if ( comment.length ) logtext += '\n**' + lang.get('verify.notice') + '** ' + comment.join('\n**' + lang.get('verify.notice') + '** ');
+						}
+					}
+					if ( channel.permissionsFor(channel.guild.me).has('EMBED_LINKS') ) {
+						if ( roles.length ) embed.addField( lang.get('verify.qualified'), roles.map( role => '<@&' + role + '>' ).join('\n') );
+						if ( missing.length && !useLogging ) embed.setColor('#008800').addField( lang.get('verify.qualified_error'), missing.map( role => '<@&' + role + '>' ).join('\n') );
+						if ( comment.length && !useLogging ) embed.setColor('#008800').addField( lang.get('verify.notice'), comment.join('\n') );
+						if ( verifynotice.onsuccess ) embed.addField( lang.get('verify.notice'), verifynotice.onsuccess );
+					}
+					else {
+						if ( roles.length ) text += '\n\n' + lang.get('verify.qualified') + ' ' + roles.map( role => '<@&' + role + '>' ).join(', ');
+						if ( missing.length && !useLogging ) text += '\n\n' + lang.get('verify.qualified_error') + ' ' + missing.map( role => '<@&' + role + '>' ).join(', ');
+						if ( comment.length && !useLogging ) text += '\n\n' + comment.join('\n');
+						if ( verifynotice.onsuccess ) text += '\n\n**' + lang.get('verify.notice') + '** ' + verifynotice.onsuccess;
+					}
+					return ( settings.edit || channel.send )(member.toString() + ', ' + text, {embed, allowedMentions}).then( msg => {
+						if ( !useLogging ) return;
+						if ( msg ) {
+							if ( logembed ) logembed.addField(msg.url, '<#' + channel.id + '>');
+							else logtext += '\n<#' + channel.id + '> – <' + msg.url + '>';
+						}
+						logchannel.send(logtext, {
+							embed: logembed,
+							allowedMentions: {parse: []}
+						}).catch(log_error);
+					}, log_error );
+				}, log_error );
+			}
+			
+			embed.setColor('#FFFF00').setDescription( lang.get('verify.user_matches', member.toString(), '[' + escapeFormatting(username) + '](' + pagelink + ')', queryuser.gender) );
+				
+			return db.query( 'SELECT onmatch FROM verifynotice WHERE guild = $1', [channel.guild.id] ).then( ({rows:[row]}) => {
+				if ( !row?.onmatch ) return;
+				var onmatch = parseNotice(row.onmatch, {
+					editcount: queryuser.editcount,
+					accountage: Math.trunc(accountage),
+					dateformat: lang.get('dateformat')
+				});
+				if ( !onmatch.trim() ) return;
+				if ( channel.permissionsFor(channel.guild.me).has('EMBED_LINKS') ) embed.addField( lang.get('verify.notice'), onmatch );
+				else return '\n\n**' + lang.get('verify.notice') + '** ' + onmatch;
+			}, dberror => {
+				console.log( '- Error while getting the notices: ' + dberror );
+			} ).then( (noticeContent = '') => {
+				var components = [
+					{
+						type: 1,
+						components: [
+							{
+								type: 2,
+								style: 1,
+								label: lang.get('verify.button_again'),
+								emoji: {id: null, name: '🔂'},
+								custom_id: 'verify_again',
+								disabled: false
+							}
+						]
+					}
+				];
+				return ( settings.edit || channel.send )(member.toString() + ', ' + lang.get('verify.user_matches_reply', escapeFormatting(username), queryuser.gender) + noticeContent, {embed, allowedMentions, components}).catch(log_error);
+			} );
+		}, error => {
+			console.log( '- Error while getting the user: ' + error );
+			settings.edit?.();
+		} );
+	}, error => {
+		if ( error ) console.log( '- Error while preparing oauth verification: ' + error );
+		settings.edit?.();
+	} );
+}
+
 /**
  * Parse variables in a verification notice.
  * @param {String} [text] The notice to parse.
@@ -468,7 +707,7 @@ function verify(lang, channel, member, username, wiki, rows, old_username = '')
 function parseNotice(text = '', variables = {editcount: 0, postcount: 0, accountage: 0, dateformat: 'en-US'}) {
 	if ( !text.includes( '$' ) ) return ( text.length > 1000 ? text.substring(0, 1000) + '\u2026' : text );
 	text = text.replace( /\$(editcount|postcount|accountage)/g, (variable, key, offset, fulltext) => {
-		var value = variables[key];
+		var value = ( variables[key] ?? 0 );
 		if ( typeof value === 'string' ) return value;
 		if ( /#(?:if)?expr:[^{|}]*$/.test(fulltext.substring(0, offset)) ) return ( value > 1000000000 ? 1000000000 : value );
 		return value.toLocaleString(variables.dateformat);

+ 5 - 0
i18n/en.json

@@ -803,6 +803,11 @@
         "help_subpage": "Please add your Discord tag ($1) to your Discord subpage on the wiki:",
         "missing": "there are no verifications set up for this channel.",
         "notice": "Notice:",
+        "oauth_button": "Authenticate",
+        "oauth_message": "please use [this link]($1) to authenticate your wiki account.",
+        "oauth_message_dm": "Please use this link to authenticate your wiki account for $1.",
+        "oauth_private": "the wiki uses OAuth2 for verification. Please enable DMs from this server or use the `/verify` command so I can send you an authentication link privately.",
+        "oauth_used": "*Verified using OAuth2*",
         "qualified": "Qualified for:",
         "qualified_error": "Qualified for, but can't add:",
         "user_blocked": "**The wiki user $1 is blocked!**",

+ 204 - 2
interactions/verify.js

@@ -1,6 +1,7 @@
+const {randomBytes} = require('crypto');
 var db = require('../util/database.js');
 var verify = require('../functions/verify.js');
-const {sendMessage} = require('../util/functions.js');
+const {oauthVerify, sendMessage} = require('../util/functions.js');
 
 /**
  * Wiki user verification.
@@ -45,12 +46,55 @@ function slash_verify(interaction, lang, wiki, channel) {
 			data: {
 				type: 4,
 				data: {
-					content: reply + lang.get('verify.missing') + ( interaction.member.permissions.has('MANAGE_GUILD') ? '\n' + new URL(`/guild/${interaction.guild_id}/verification`, process.env.dashboard).href : '' ),
+					content: reply + lang.get('verify.missing') + ( interaction.member.permissions.has('MANAGE_GUILD') && process.env.dashboard ? '\n' + new URL(`/guild/${interaction.guild_id}/verification`, process.env.dashboard).href : '' ),
 					allowed_mentions,
 					flags: 64
 				}
 			}
 		} ).catch(log_error);
+
+		if ( ( wiki.isWikimedia() || wiki.isMiraheze() ) && process.env.dashboard ) {
+			let oauth = '';
+			if ( wiki.isWikimedia() ) oauth = 'wikimedia';
+			if ( wiki.isMiraheze() ) oauth = 'miraheze';
+			if ( oauth && process.env[`oauth-${oauth}`] && process.env[`oauth-${oauth}-secret`] ) {
+				let state = `${oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+				while ( oauthVerify.has(state) ) {
+					state = `${oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+				}
+				oauthVerify.set(state, {
+					state, wiki: oauth, channel,
+					user: interaction.user.id
+				});
+				interaction.client.shard.send({id: 'verifyUser', state});
+				let oauthURL = `https://meta.${oauth}.org/w/rest.php/oauth2/authorize?response_type=code&redirect_uri=${encodeURIComponent('https://settings.wikibot.de/oauth/mw')}&client_id=${process.env['oauth-' + oauth]}&state=${state}`;
+				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);
+			}
+		}
 		
 		var username = ( interaction.data.options?.[0]?.value || '' ).replace( /^\s*<@!?(\d+)>\s*$/, (mention, id) => {
 			if ( id === interaction.user.id ) {
@@ -91,6 +135,42 @@ function slash_verify(interaction, lang, wiki, channel) {
 		} ).then( () => {
 			return channel.guild.members.fetch(interaction.user.id).then( member => {
 				return verify(lang, channel, member, username, wiki, rows).then( result => {
+					if ( result.oauth ) {
+						let state = `${result.oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+						while ( oauthVerify.has(state) ) {
+							state = `${result.oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+						}
+						oauthVerify.set(state, {
+							state, wiki: result.oauth, channel,
+							user: interaction.user.id
+						});
+						interaction.client.shard.send({id: 'verifyUser', state});
+						let oauthURL = `https://meta.${result.oauth}.org/w/rest.php/oauth2/authorize?response_type=code&redirect_uri=${encodeURIComponent('https://settings.wikibot.de/oauth/mw')}&client_id=${process.env['oauth-' + result.oauth]}&state=${state}`;
+						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: {
+									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);
+						}, log_error );
+					}
 					var message = {
 						content: reply + result.content,
 						embeds: [result.embed],
@@ -204,6 +284,87 @@ function slash_verify(interaction, lang, wiki, channel) {
 			users: [interaction.user.id]
 		};
 		interaction.message.allowed_mentions = allowed_mentions;
+
+		if ( interaction?.message?.embeds?.[0]?.fields?.[1]?.value === lang.get('verify.oauth_used') && interaction?.message?.embeds?.[0]?.url?.startsWith( wiki.origin ) ) {
+			interaction.message.components[0].components[0].disabled = true;
+			return interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
+				data: {
+					type: 7,
+					data: interaction.message
+				}
+			} ).then( () => {
+				return global.verifyOauthUser('', '', {
+					channel, username, user: interaction.user.id,
+					edit: function(content, options) {
+						if ( !content && !options ) {
+							interaction.message.components = [];
+							return sendMessage(interaction, interaction.message, channel, false);
+						}
+						var message = {
+							content, allowed_mentions,
+							embeds: ( options.embed ? [options.embed] : [] ),
+							components: ( options.components ? options.components : [] )
+						};
+						sendMessage(interaction, message, channel, false);
+						return interaction.client.api.webhooks(interaction.application_id, interaction.token).post( {
+							data: {
+								content, allowed_mentions,
+								embeds: ( options.embed ? [options.embed] : [] ),
+								components: [],
+								flags: 64
+							}
+						} ).catch(log_error);
+					}
+				});
+			}, log_error );
+		}
+		if ( ( wiki.isWikimedia() || wiki.isMiraheze() ) && process.env.dashboard ) {
+			let oauth = '';
+			if ( wiki.isWikimedia() ) oauth = 'wikimedia';
+			if ( wiki.isMiraheze() ) oauth = 'miraheze';
+			if ( oauth && process.env[`oauth-${oauth}`] && process.env[`oauth-${oauth}-secret`] ) {
+				let state = `${oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+				while ( oauthVerify.has(state) ) {
+					state = `${oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+				}
+				oauthVerify.set(state, {
+					state, wiki: oauth, channel,
+					user: interaction.user.id
+				});
+				interaction.client.shard.send({id: 'verifyUser', state});
+				let oauthURL = `https://meta.${oauth}.org/w/rest.php/oauth2/authorize?response_type=code&redirect_uri=${encodeURIComponent('https://settings.wikibot.de/oauth/mw')}&client_id=${process.env['oauth-' + oauth]}&state=${state}`;
+				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);
+			}
+		}
+
 		interaction.message.components[0].components[0].disabled = true;
 		return interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
 			data: {
@@ -214,6 +375,47 @@ function slash_verify(interaction, lang, wiki, channel) {
 			return channel.guild.members.fetch(interaction.user.id).then( member => {
 				console.log( interaction.guild_id + ': Button: ' + interaction.data.custom_id + ' ' + username );
 				return verify(lang, channel, member, username, wiki, rows).then( result => {
+					if ( result.oauth ) {
+						let state = `${result.oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+						while ( oauthVerify.has(state) ) {
+							state = `${result.oauth}-${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+						}
+						oauthVerify.set(state, {
+							state, wiki: result.oauth, channel,
+							user: interaction.user.id
+						});
+						interaction.client.shard.send({id: 'verifyUser', state});
+						let oauthURL = `https://meta.${result.oauth}.org/w/rest.php/oauth2/authorize?response_type=code&redirect_uri=${encodeURIComponent('https://settings.wikibot.de/oauth/mw')}&client_id=${process.env['oauth-' + result.oauth]}&state=${state}`;
+						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,
 						embeds: [result.embed],

+ 10 - 0
main.js

@@ -36,6 +36,9 @@ manager.on( 'shardCreate', shard => {
 	} );
 	
 	shard.on( 'message', message => {
+		if ( message?.id === 'verifyUser' && server ) {
+			return server.send( message );
+		}
 		if ( message === 'SIGKILL' ) {
 			console.log( '\n- Killing all shards!\n\n' );
 			manager.shards.filter( shard => shard.process && !shard.process.killed ).forEach( shard => shard.kill() );
@@ -256,6 +259,13 @@ if ( process.env.dashboard ) {
 						return dashboard.send( {id: message.id, data} );
 					} );
 					break;
+				case 'verifyUser':
+					return manager.broadcastEval(`global.verifyOauthUser('${message.data.state}', '${message.data.access_token}')`, message.data.state.split('-')[1][0]).catch( error => {
+						data.error = error.toString();
+					} ).finally( () => {
+						return dashboard.send( {id: message.id, data} );
+					} );
+					break;
 				default:
 					console.log( '- [Dashboard]: Unknown message received!', message.data );
 					data.error = 'Unknown message type: ' + message.data.type;

+ 6 - 0
util/functions.js

@@ -10,6 +10,11 @@ const got = require('got').extend( {
 
 const slashCommands = require('../interactions/commands.json');
 
+/**
+ * @type {Map<String, {state: String, wiki: String, channel: import('discord.js').TextChannel, user: String}>}
+ */
+const oauthVerify = new Map();
+
 /**
  * Parse infobox content
  * @param {Object} infobox - The content of the infobox.
@@ -457,6 +462,7 @@ function sendMessage(interaction, message, channel, letDelete = true) {
 module.exports = {
 	got,
 	slashCommands,
+	oauthVerify,
 	parse_infobox,
 	toFormatting,
 	toMarkdown,

+ 27 - 1
util/wiki.js

@@ -1,6 +1,20 @@
 const util = require('util');
 const {defaultSettings, wikiProjects} = require('./default.json');
 
+const wikimediaSites = [
+	'wikipedia.org',
+	'mediawiki.org',
+	'wikimedia.org',
+	'wiktionary.org',
+	'wikibooks.org',
+	'wikisource.org',
+	'wikidata.org',
+	'wikiversity.org',
+	'wikiquote.org',
+	'wikinews.org',
+	'wikivoyage.org'
+];
+
 /**
  * A wiki.
  * @class Wiki
@@ -26,8 +40,9 @@ class Wiki extends URL {
 		}
 		this.articlepath = articlepath;
 		this.mainpage = '';
-		this.centralauth = 'local';
 		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' );
 	}
 
 	/**
@@ -70,6 +85,7 @@ class Wiki extends URL {
 		this.centralauth = centralidlookupprovider;
 		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('.') );
 		return this;
 	}
 
@@ -99,6 +115,14 @@ class Wiki extends URL {
 		return this.miraheze;
 	}
 
+	/**
+	 * Check for a WikiMedia wiki.
+	 * @returns {Boolean}
+	 */
+	isWikimedia() {
+		return this.wikimedia;
+	}
+
 	/**
 	 * Check for CentralAuth.
 	 * @returns {Boolean}
@@ -136,6 +160,8 @@ class Wiki extends URL {
 		if ( !querystring.toString().length ) title = ( title || this.mainpage );
 		title = title.replace( / /g, '_' ).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' ) ) {