Browse Source

Add support for custom oauth sites

Markus-Rost 4 năm trước cách đây
mục cha
commit
910742d7a5
8 tập tin đã thay đổi với 74 bổ sung62 xóa
  1. 6 6
      .env.example
  2. 13 13
      cmds/verify.js
  3. 3 3
      dashboard/oauth.js
  4. 4 4
      functions/phabricator.js
  5. 9 9
      functions/verify.js
  6. 26 26
      interactions/verify.js
  7. 1 1
      main.js
  8. 12 0
      util/wiki.js

+ 6 - 6
.env.example

@@ -21,17 +21,17 @@ invite="https://discord.gg/v77RTk5"
 # Link to the patreon page for the bot
 patreon="https://www.patreon.com/WikiBot"
 # Optional: API token for phabricator.wikimedia.org
-phabricator-wikimedia=""
+phabricator_wikimedia=""
 # Optional: API token for phabricator.miraheze.org
-phabricator-miraheze=""
+phabricator_miraheze=""
 # Optional: Client ID for Wikimedia OAuth2 consumer
-oauth-wikimedia=""
+oauth_wikimedia=""
 # Optional: Client secret for Wikimedia OAuth2 consumer
-oauth-wikimedia-secret=""
+oauth_wikimedia_secret=""
 # Optional: Client ID for Miraheze OAuth2 consumer
-oauth-miraheze=""
+oauth_miraheze=""
 # Optional: Client secret for Miraheze OAuth2 consumer
-oauth-miraheze-secret=""
+oauth_miraheze_secret=""
 # Optional: Path to a log file for usage statistics
 usagelog=""
 

+ 13 - 13
cmds/verify.js

@@ -28,24 +28,24 @@ function cmd_verify(lang, msg, args, line, wiki) {
 			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} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+		if ( wiki.hasOAuth2() && process.env.dashboard ) {
+			let oauth = [wiki.hostname + wiki.pathname.slice(0, -1)];
+			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} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+					state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 				}
 				oauthVerify.set(state, {
-					state, wiki: wiki.hostname,
+					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}`], state
+					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: [
@@ -90,19 +90,19 @@ 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.oauth ) {
-					let state = `${result.oauth} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+					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} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+						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.hostname,
+						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-${result.oauth}`], state
+						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: [

+ 3 - 3
dashboard/oauth.js

@@ -326,13 +326,13 @@ function mediawiki_oauth(res, searchParams) {
 	}
 	var state = searchParams.get('state');
 	var site = state.split(' ');
-	got.post( 'https://' + site[1] + '/w/rest.php/oauth2/access_token', {
+	got.post( 'https://' + site[0] + '/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[0]}`],
-			client_secret: process.env[`oauth-${site[0]}-secret`]
+			client_id: process.env['oauth_' + ( site[2] || site[0] )],
+			client_secret: process.env['oauth_' + ( site[2] || site[0] ) + '_secret']
 		}
 	} ).then( response => {
 		var body = response.body;

+ 4 - 4
functions/phabricator.js

@@ -13,7 +13,7 @@ const {escapeFormatting, limitLength} = require('../util/functions.js');
  */
 function phabricator_task(lang, msg, wiki, link, reaction, spoiler = '') {
 	var regex = /^(?:https?:)?\/\/phabricator\.(wikimedia|miraheze)\.org\/T(\d+)(?:#|$)/.exec(link.href);
-	if ( !regex || !process.env['phabricator-' + regex[1]] ) {
+	if ( !regex || !process.env['phabricator_' + regex[1]] ) {
 		logging(wiki, msg.guild?.id, 'interwiki');
 		msg.sendChannel( spoiler + ' ' + link + ' ' + spoiler );
 		if ( reaction ) reaction.removeEmoji();
@@ -21,7 +21,7 @@ function phabricator_task(lang, msg, wiki, link, reaction, spoiler = '') {
 	}
 	var site = 'https://phabricator.' + regex[1] + '.org/';
 	logging(site, msg.guild?.id, 'phabricator', regex[1]);
-	got.get( site + 'api/maniphest.search?api.token=' + process.env['phabricator-' + regex[1]] + '&attachments[projects]=1&constraints[ids][0]=' + regex[2] ).then( response => {
+	got.get( site + 'api/maniphest.search?api.token=' + process.env['phabricator_' + regex[1]] + '&attachments[projects]=1&constraints[ids][0]=' + regex[2] ).then( response => {
 		var body = response.body;
 		if ( response.statusCode !== 200 || !body?.result?.data || body.error_code ) {
 			console.log( '- ' + response.statusCode + ': Error while getting the Phabricator task: ' + body?.error_info );
@@ -53,7 +53,7 @@ function phabricator_task(lang, msg, wiki, link, reaction, spoiler = '') {
 		embed.setDescription( description );
 
 		Promise.all([
-			( task.attachments.projects.projectPHIDs.length ? got.get( site + 'api/phid.lookup?api.token=' + process.env['phabricator-' + regex[1]] + '&' + task.attachments.projects.projectPHIDs.map( (project, i) => 'names[' + i + ']=' + project ).join('&') ).then( presponse => {
+			( task.attachments.projects.projectPHIDs.length ? got.get( site + 'api/phid.lookup?api.token=' + process.env['phabricator_' + regex[1]] + '&' + task.attachments.projects.projectPHIDs.map( (project, i) => 'names[' + i + ']=' + project ).join('&') ).then( presponse => {
 				var pbody = presponse.body;
 				if ( presponse.statusCode !== 200 || !pbody?.result || pbody.error_code ) {
 					console.log( '- ' + presponse.statusCode + ': Error while getting the projects: ' + pbody?.error_info );
@@ -69,7 +69,7 @@ function phabricator_task(lang, msg, wiki, link, reaction, spoiler = '') {
 			}, error => {
 				console.log( '- Error while getting the projects: ' + error );
 			} ) : undefined ),
-			( /^#\d+$/.test( link.hash ) ? got.get( site + 'api/transaction.search?api.token=' + process.env['phabricator-' + regex[1]] + '&objectIdentifier=' + task.phid ).then( tresponse => {
+			( /^#\d+$/.test( link.hash ) ? got.get( site + 'api/transaction.search?api.token=' + process.env['phabricator_' + regex[1]] + '&objectIdentifier=' + task.phid ).then( tresponse => {
 				var tbody = tresponse.body;
 				if ( tresponse.statusCode !== 200 || !tbody?.result?.data || tbody.error_code ) {
 					console.log( '- ' + tresponse.statusCode + ': Error while getting the task transactions: ' + tbody?.error_info );

+ 9 - 9
functions/verify.js

@@ -16,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,add_button:Boolean,reaction:String,oauth: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: '', oauth: '',
+		reaction: '', oauth: [],
 		logging: {
 			channel: '',
 			content: '',
@@ -53,11 +53,11 @@ 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`] ) {
+		if ( wiki.hasOAuth2() && process.env.dashboard ) {
+			let oauth = [wiki.hostname + wiki.pathname.slice(0, -1)];
+			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'] ) {
 				result.oauth = oauth;
 				return;
 			}
@@ -493,7 +493,7 @@ global.verifyOauthUser = function(state, access_token, settings) {
 			} );
 		} ),
 		channel.guild.members.fetch(settings.user),
-		( !username ? got.get( 'https://' + settings.wiki + '/w/rest.php/oauth2/resource/profile', {
+		( !username ? got.get( settings.wiki + 'rest.php/oauth2/resource/profile', {
 			Authorization: `Bearer ${access_token}`
 		} ).then( response => {
 			var body = response.body;
@@ -508,7 +508,7 @@ global.verifyOauthUser = function(state, access_token, settings) {
 			console.log( '- Error while getting the mediawiki profile: ' + error );
 		} ) : null )
 	]).then( ([{rows, wiki, lang}, member]) => {
-		if ( !username || ( settings.wiki && settings.wiki !== wiki.hostname ) ) return settings.edit?.();
+		if ( !username || ( settings.wiki && settings.wiki !== wiki.href ) ) 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);

+ 26 - 26
interactions/verify.js

@@ -52,23 +52,23 @@ function slash_verify(interaction, lang, wiki, channel) {
 			}
 		} ).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} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+		if ( wiki.hasOAuth2() && process.env.dashboard ) {
+			let oauth = [wiki.hostname + wiki.pathname.slice(0, -1)];
+			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} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+					state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 				}
 				oauthVerify.set(state, {
-					state, wiki: wiki.hostname, channel,
+					state, wiki: wiki.href, channel,
 					user: interaction.user.id
 				});
 				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}`], state
+					client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
 				}).toString();
 				return interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
 					data: {
@@ -138,18 +138,18 @@ 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 ) {
-						let state = `${result.oauth} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+						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} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+							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.hostname, channel,
+							state, wiki: wiki.href, channel,
 							user: interaction.user.id
 						});
 						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}`], state
+							client_id: process.env['oauth_' + ( result.oauth[1] || result.oauth[0] )], state
 						}).toString();
 						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( {
@@ -324,24 +324,24 @@ function slash_verify(interaction, lang, wiki, channel) {
 				});
 			}, 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`] ) {
+		if ( wiki.hasOAuth2() && process.env.dashboard ) {
+			let oauth = [wiki.hostname + wiki.pathname.slice(0, -1)];
+			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'] ) {
 				console.log( interaction.guild_id + ': Button: ' + interaction.data.custom_id + ': OAuth2' );
-				let state = `${oauth} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+				let state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 				while ( oauthVerify.has(state) ) {
-					state = `${oauth} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+					state = `${oauth[0]} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex') + ( oauth[1] ? ` ${oauth[1]}` : '' );
 				}
 				oauthVerify.set(state, {
-					state, wiki: wiki.hostname, channel,
+					state, wiki: wiki.href, channel,
 					user: interaction.user.id
 				});
 				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}`], state
+					client_id: process.env['oauth_' + ( oauth[1] || oauth[0] )], state
 				}).toString();
 				interaction.message.components = [];
 				interaction.client.api.interactions(interaction.id, interaction.token).callback.post( {
@@ -386,18 +386,18 @@ 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 ) {
-						let state = `${result.oauth} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+						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} ${wiki.hostname} ${global.shardId}` + Date.now().toString(16) + randomBytes(16).toString('hex');
+							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.hostname, channel,
+							state, wiki: wiki.href, channel,
 							user: interaction.user.id
 						});
 						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}`], state
+							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( {

+ 1 - 1
main.js

@@ -260,7 +260,7 @@ if ( process.env.dashboard ) {
 					} );
 					break;
 				case 'verifyUser':
-					return manager.broadcastEval(`global.verifyOauthUser('${message.data.state}', '${message.data.access_token}')`, message.data.state.split('-')[1][0]).catch( error => {
+					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} );

+ 12 - 0
util/wiki.js

@@ -15,6 +15,8 @@ const wikimediaSites = [
 	'wikivoyage.org'
 ];
 
+const oauthSites = [];
+
 /**
  * A wiki.
  * @class Wiki
@@ -43,6 +45,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 );
 	}
 
 	/**
@@ -86,6 +89,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 );
 		return this;
 	}
 
@@ -131,6 +135,14 @@ class Wiki extends URL {
 		return this.centralauth === 'CentralAuth';
 	}
 
+	/**
+	 * Check for OAuth2.
+	 * @returns {Boolean}
+	 */
+	hasOAuth2() {
+		return ( this.isWikimedia() || this.isMiraheze() || this.oauth2 );
+	}
+
 	/**
 	 * Check if a wiki is missing.
 	 * @param {String} [message] - Error message or response url.