/**
* Perform a REST API request to reveal the IP address(es) used for given revIds, logIds,
* and aflIds performed by temporary accounts. If no ids are specified, this will return
* the last IP address used by a temporary account.
*
* @param {string} target
* @param {Object} revIds
* @param {Object} logIds
* @param {Object} aflIds
* @param {string} [wikiUrl] The URL pattern for an external wiki in the format
* returned by PHP's Site::getPath, or undefined if querying the local wiki
* @param {boolean} [retryOnTokenMismatch]
* @return {Promise}
*/
function performRevealRequest( target, revIds, logIds, aflIds, wikiUrl, retryOnTokenMismatch ) {
if ( retryOnTokenMismatch === undefined ) {
// Default value for the argument is true.
retryOnTokenMismatch = true;
}
const deferred = $.Deferred();
/**
* Takes an object with array of integers and turns it into an array of strings.
*
* This is used when building requests for fetching data by log IDs, since
* these IDs are integers, but the backend API expects strings.
*
* @param {{allIds: number[] | null} | null} ids Values to cast into strings
* @return {string[]}
*/
const makeStringIDs = ( ids ) => {
if ( !ids || !ids.allIds || !Array.isArray( ids.allIds ) ) {
return [];
}
return ids.allIds.map( ( id ) => id.toString() );
};
const request = {
[ target ]: {
revIds: makeStringIDs( revIds ),
logIds: makeStringIDs( logIds ),
lastUsedIp: true
}
};
if ( mw.config.get( 'wgCheckUserAbuseFilterExtensionLoaded' ) ) {
request[ target ].abuseLogIds = makeStringIDs( aflIds );
}
performBatchRevealRequestInternal( request, wikiUrl, retryOnTokenMismatch )
.then( ( data ) => {
let key;
if ( isAbuseFilterLogLookup( aflIds ) ) {
key = 'abuseLogIps';
} else if ( isRevisionLookup( revIds ) ) {
key = 'revIps';
} else if ( isLogLookup( logIds ) ) {
key = 'logIps';
} else {
key = 'lastUsedIp';
}
// Adjust the response format to what's expected by the caller
if ( !Object.prototype.hasOwnProperty.call( data, target ) ||
!Object.prototype.hasOwnProperty.call( data[ target ], key ) ) {
throw new Error( 'Malformed response' );
}
// Request was made without any IDs, so return the last used IP
if ( key === 'lastUsedIp' ) {
return deferred.resolve( {
ips: [ data[ target ].lastUsedIp ],
autoReveal: data.autoReveal
} );
}
deferred.resolve( {
ips: data[ target ][ key ],
autoReveal: data.autoReveal
} );
} ).catch( ( err ) => {
deferred.reject( err, {} );
} );
return deferred.promise();
}
/**
* Perform a REST API request to reveal all the IP addresses used by a temporary account.
*
* @param {string} target
* @param {boolean} [retryOnTokenMismatch]
* @return {Promise}
*/
function performFullRevealRequest( target, retryOnTokenMismatch ) {
const restApi = new mw.Rest();
const api = new mw.Api();
const deferred = $.Deferred();
if ( retryOnTokenMismatch === undefined ) {
// Default value for the argument is true.
retryOnTokenMismatch = true;
}
api.getToken( 'csrf' ).then( ( token ) => {
restApi.post(
'/checkuser/v0/temporaryaccount/' + target,
{ token: token } )
.then(
( data ) => {
deferred.resolve( data );
},
( err, errObject ) => {
if ( retryOnTokenMismatch && isBadTokenError( errObject ) ) {
// The CSRF token has expired. Retry the POST with a new token.
api.badToken( 'csrf' );
performFullRevealRequest( target, false ).then(
( data ) => {
deferred.resolve( data );
},
( secondRequestErr, secondRequestErrObject ) => {
deferred.reject( secondRequestErr, secondRequestErrObject );
}
);
} else {
deferred.reject( err, errObject );
}
}
);
} );
return deferred.promise();
}
/**
* @typedef {Object} RevealRequest
* @property {string[]} revIds
* @property {string[]} logIds
* @property {boolean} lastUsedIp
*/
/** @typedef {Map<string, RevealRequest>} BatchRevealRequest */
/** @type {Object<string, Promise>} */
const requests = {};
/**
* Reveal multiple IP addresses in a single request.
*
* @param {BatchRevealRequest} request
* @param {string} [wikiUrl] The URL pattern for an external wiki in the format
* returned by PHP's Site::getPath, or undefined if querying the local wiki
* @param {boolean} [retryOnTokenMismatch]
* @return {Promise}
*/
function performBatchRevealRequest( request, wikiUrl, retryOnTokenMismatch ) {
if ( retryOnTokenMismatch === undefined ) {
// Default value for the argument is true.
retryOnTokenMismatch = true;
}
// De-duplicate requests to the same wiki using the same request parameters.
const serialized = JSON.stringify( { url: wikiUrl, request: request } );
if ( Object.prototype.hasOwnProperty.call( requests, serialized ) ) {
return requests[ serialized ];
}
const requestPromise = performBatchRevealRequestInternal(
request,
wikiUrl,
retryOnTokenMismatch
)
.then( ( response ) => {
delete requests[ serialized ];
return response;
} )
.catch( ( err ) => {
delete requests[ serialized ];
return err;
} );
requests[ serialized ] = requestPromise;
return requestPromise;
}
/**
* @param {BatchRevealRequest} request
* @param {string|undefined} wikiUrl The URL pattern for an external wiki in the format
* returned by PHP's Site::getPath, or undefined if querying the local wiki
* @param {boolean} retryOnTokenMismatch
* @return {Promise}
*/
function performBatchRevealRequestInternal( request, wikiUrl, retryOnTokenMismatch ) {
const deferred = $.Deferred();
let api, restApi;
if ( wikiUrl ) {
api = new mw.ForeignApi( wikiUrl.replace( '$1', 'api.php' ) );
restApi = new mw.ForeignRest( wikiUrl.replace( '$1', 'rest.php' ), api );
} else {
api = new mw.Api();
restApi = new mw.Rest();
}
api.getToken( 'csrf' ).then( ( token ) => {
restApi.post( '/checkuser/v0/batch-temporaryaccount', { token: token, users: request } ).then(
( data ) => {
deferred.resolve( data );
},
( err, errObject ) => {
if ( retryOnTokenMismatch && isBadTokenError( errObject ) ) {
// The CSRF token has expired. Retry the POST with a new token.
api.badToken( 'csrf' );
performBatchRevealRequestInternal( request, wikiUrl, false ).then(
( data ) => {
deferred.resolve( data );
},
( secondRequestErr, secondRequestErrObject ) => {
deferred.reject( secondRequestErr, secondRequestErrObject );
}
);
} else {
deferred.reject( err, errObject );
}
}
);
} ).catch( ( err, errObject ) => {
deferred.reject( err, errObject );
} );
return deferred.promise();
}
/**
* Determine whether to look up IPs for revision IDs.
*
* @param {Object} revIds
* @return {boolean} There are revision IDs
*/
function isRevisionLookup( revIds ) {
return !!( revIds && revIds.allIds && revIds.allIds.length );
}
/**
* Determine whether to look up IPs for log IDs.
*
* @param {Object} logIds
* @return {boolean} There are log IDs
*/
function isLogLookup( logIds ) {
return !!( logIds && logIds.allIds && logIds.allIds.length );
}
/**
* Determine whether to look up IPs for AbuseFilter log IDs.
*
* @param {Object} aflIds
* @return {boolean} There are revision IDs
*/
function isAbuseFilterLogLookup( aflIds ) {
return !!( aflIds && aflIds.allIds && aflIds.allIds.length );
}
/**
* Checks if an error response is caused by providing a bad CSRF token.
*
* @param {Object} errObject
* @return {boolean}
* @internal
*/
function isBadTokenError( errObject ) {
return errObject.xhr &&
errObject.xhr.responseJSON &&
errObject.xhr.responseJSON.errorKey &&
errObject.xhr.responseJSON.errorKey === 'rest-badtoken';
}
module.exports = {
performRevealRequest: performRevealRequest,
performFullRevealRequest: performFullRevealRequest,
performBatchRevealRequest: performBatchRevealRequest,
isRevisionLookup: isRevisionLookup,
isLogLookup: isLogLookup,
isAbuseFilterLogLookup: isAbuseFilterLogLookup
};