package org.xmlrpc.android;
import android.support.annotation.StringRes;
import android.text.TextUtils;
import android.util.Xml;
import android.webkit.URLUtil;
import com.android.volley.TimeoutError;
import org.apache.http.conn.ConnectTimeoutException;
import org.wordpress.android.R;
import org.wordpress.android.analytics.AnalyticsTracker;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.BlogUtils;
import org.wordpress.android.util.UrlUtils;
import org.wordpress.android.util.WPUrlUtils;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlrpc.android.XMLRPCUtils.XMLRPCUtilsException.Kind;
import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
public class XMLRPCUtils {
public static class XMLRPCUtilsException extends Exception {
public enum Kind {
SITE_URL_CANNOT_BE_EMPTY,
INVALID_URL,
MISSING_XMLRPC_METHOD,
ERRONEOUS_SSL_CERTIFICATE,
HTTP_AUTH_REQUIRED,
SITE_TIME_OUT,
NO_SITE_ERROR,
XMLRPC_MALFORMED_RESPONSE,
XMLRPC_ERROR
}
public final Kind kind;
public final
@StringRes
int errorMsgId;
public final String failedUrl;
public final String clientResponse;
public XMLRPCUtilsException(Kind kind, @StringRes int errorMsgId, String failedUrl, String clientResponse) {
this.kind = kind;
this.errorMsgId = errorMsgId;
this.failedUrl = failedUrl;
this.clientResponse = clientResponse;
}
}
private static @StringRes int handleXmlRpcFault(XMLRPCFault xmlRpcFault) {
AppLog.e(AppLog.T.NUX, "XMLRPCFault received from XMLRPC call wp.getUsersBlogs", xmlRpcFault);
switch (xmlRpcFault.getFaultCode()) {
case 403:
return org.wordpress.android.R.string.username_or_password_incorrect;
case 404:
return org.wordpress.android.R.string.xmlrpc_error;
case 425:
return org.wordpress.android.R.string.account_two_step_auth_enabled;
default:
return org.wordpress.android.R.string.no_site_error;
}
}
private static boolean isHTTPAuthErrorMessage(Exception e) {
return e != null && e.getMessage() != null && e.getMessage().contains("401");
}
private static Object doSystemListMethodsXMLRPC(String url, String httpUsername, String httpPassword) throws
XMLRPCException, IOException, XmlPullParserException, XMLRPCUtilsException {
if (!UrlUtils.isValidUrlAndHostNotNull(url)) {
AppLog.e(AppLog.T.NUX, "invalid URL: " + url);
throw new XMLRPCUtilsException(Kind.INVALID_URL, org.wordpress.android.R.string
.invalid_site_url_message, url, null);
}
AppLog.i(AppLog.T.NUX, "Trying system.listMethods on the following URL: " + url);
URI uri = URI.create(url);
XMLRPCClientInterface client = XMLRPCFactory.instantiate(uri, httpUsername, httpPassword);
return client.call(ApiHelper.Method.LIST_METHODS);
}
private static boolean validateListMethodsResponse(Object[] availableMethods) {
if (availableMethods == null) {
AppLog.e(AppLog.T.NUX, "The response of system.listMethods was empty!");
return false;
}
// validate xmlrpc methods
String[] requiredMethods = {"wp.getUsersBlogs", "wp.getPage", "wp.getCommentStatusList", "wp.newComment",
"wp.editComment", "wp.deleteComment", "wp.getComments", "wp.getComment",
"wp.getOptions", "wp.uploadFile", "wp.newCategory",
"wp.getTags", "wp.getCategories", "wp.editPage", "wp.deletePage",
"wp.newPage", "wp.getPages"};
for (String currentRequiredMethod : requiredMethods) {
boolean match = false;
for (Object currentAvailableMethod : availableMethods) {
if ((currentAvailableMethod).equals(currentRequiredMethod)) {
match = true;
break;
}
}
if (!match) {
AppLog.e(AppLog.T.NUX, "The following XML-RPC method: " + currentRequiredMethod + " is missing on the" +
" server.");
return false;
}
}
return true;
}
// Append "xmlrpc.php" if missing in the URL
private static String appendXMLRPCPath(String url) {
// Don't use 'ends' here! Some hosting wants parameters passed to baseURL/xmlrpc-php?my-authcode=XXX
if (url.contains("xmlrpc.php")) {
return url;
} else {
return url + "/xmlrpc.php";
}
}
/**
* Truncate a string beginning at the marker
* @param url input string
* @param marker the marker to begin the truncation from
* @return new string truncated to the begining of the marker or the input string if marker is not found
*/
private static String truncateUrl(String url, String marker) {
if (TextUtils.isEmpty(marker) || url.indexOf(marker) < 0) {
return url;
}
final String newUrl = url.substring(0, url.indexOf(marker));
return URLUtil.isValidUrl(newUrl) ? newUrl : url;
}
public static String sanitizeSiteUrl(String siteUrl, boolean addHttps) throws XMLRPCUtilsException {
// remove padding whitespace
String url = siteUrl.trim();
if (TextUtils.isEmpty(url)) {
throw new XMLRPCUtilsException(XMLRPCUtilsException.Kind.SITE_URL_CANNOT_BE_EMPTY, R.string
.invalid_site_url_message, siteUrl, null);
}
// Convert IDN names to punycode if necessary
url = UrlUtils.convertUrlToPunycodeIfNeeded(url);
// Add http to the beginning of the URL if needed
url = UrlUtils.addUrlSchemeIfNeeded(url, addHttps);
// strip url from known usual trailing paths
url = XMLRPCUtils.stripKnownPaths(url);
if (!(URLUtil.isHttpsUrl(url) || URLUtil.isHttpUrl(url))) {
throw new XMLRPCUtilsException(Kind.INVALID_URL, R.string.invalid_site_url_message, url, null);
}
return url;
}
private static String stripKnownPaths(String url) {
// Remove 'wp-login.php' if available in the URL
String sanitizedURL = truncateUrl(url, "wp-login.php");
// Remove '/wp-admin' if available in the URL
sanitizedURL = truncateUrl(sanitizedURL, "/wp-admin");
// Remove '/wp-content' if available in the URL
sanitizedURL = truncateUrl(sanitizedURL, "/wp-content");
sanitizedURL = truncateUrl(sanitizedURL, "/xmlrpc.php?rsd");
// remove any trailing slashes
while (sanitizedURL.endsWith("/")) {
sanitizedURL = sanitizedURL.substring(0, sanitizedURL.length() - 1);
}
return sanitizedURL;
}
private static boolean checkXMLRPCEndpointValidity(String url, String httpUsername, String httpPassword) throws
XMLRPCUtilsException {
try {
Object[] methods = (Object[]) doSystemListMethodsXMLRPC(url, httpUsername, httpPassword);
if (methods == null) {
AppLog.e(AppLog.T.NUX, "The response of system.listMethods was empty!");
return false;
}
// Exit the loop on the first URL that replies with a XML-RPC doc.
AppLog.i(AppLog.T.NUX, "system.listMethods replied with XML-RPC objects on the URL: " + url);
AppLog.i(AppLog.T.NUX, "Validating the XML-RPC response...");
if (validateListMethodsResponse(methods)) {
// Endpoint address found and works fine.
AppLog.i(AppLog.T.NUX, "Validation ended with success!!! Endpoint found!!!");
return true;
} else {
// Endpoint found, but it has problem.
AppLog.w(AppLog.T.NUX, "Validation ended with errors!!! Endpoint found but doesn't contain all the " +
"required methods.");
throw new XMLRPCUtilsException(Kind.MISSING_XMLRPC_METHOD, org.wordpress.android
.R.string.xmlrpc_missing_method_error, url, null);
}
} catch (XMLRPCException e) {
AppLog.e(AppLog.T.NUX, "system.listMethods failed on: " + url, e);
if (isHTTPAuthErrorMessage(e)) {
throw new XMLRPCUtilsException(Kind.HTTP_AUTH_REQUIRED, 0, url, null);
}
} catch (SSLHandshakeException | SSLPeerUnverifiedException e) {
if (!WPUrlUtils.isWordPressCom(url)) {
throw new XMLRPCUtilsException(Kind.ERRONEOUS_SSL_CERTIFICATE, 0, url, null);
}
AppLog.e(AppLog.T.NUX, "SSL error. Erroneous SSL certificate detected.", e);
} catch (IOException | XmlPullParserException e) {
AnalyticsTracker.track(AnalyticsTracker.Stat.LOGIN_FAILED_TO_GUESS_XMLRPC);
AppLog.e(AppLog.T.NUX, "system.listMethods failed on: " + url, e);
if (isHTTPAuthErrorMessage(e)) {
throw new XMLRPCUtilsException(Kind.HTTP_AUTH_REQUIRED, 0, url, null);
}
} catch (IllegalArgumentException e) {
// The XML-RPC client returns this error in case of redirect to an invalid URL.
AnalyticsTracker.track(AnalyticsTracker.Stat.LOGIN_FAILED_TO_GUESS_XMLRPC);
throw new XMLRPCUtilsException(Kind.INVALID_URL, org.wordpress.android.R.string
.invalid_site_url_message, url, null);
}
return false;
}
public static String verifyOrDiscoverXmlRpcUrl(final String siteUrl, final String httpUsername, final String
httpPassword) throws XMLRPCUtilsException {
String xmlrpcUrl = XMLRPCUtils.verifyXmlrpcUrl(siteUrl, httpUsername, httpPassword);
if (xmlrpcUrl == null) {
AppLog.w(AppLog.T.NUX, "The XML-RPC endpoint was not found by using our 'smart' cleaning approach" +
". Time to start the Endpoint discovery process");
// Try to discover the XML-RPC Endpoint address
xmlrpcUrl = XMLRPCUtils.discoverSelfHostedXmlrpcUrl(siteUrl, httpUsername, httpPassword);
}
// Validate the XML-RPC URL we've found before. This check prevents a crash that can occur
// during the setup of self-hosted sites that have malformed xmlrpc URLs in their declaration.
if (!URLUtil.isValidUrl(xmlrpcUrl)) {
throw new XMLRPCUtilsException(Kind.NO_SITE_ERROR, R.string.invalid_site_url_message, xmlrpcUrl, null);
}
return xmlrpcUrl;
}
private static String verifyXmlrpcUrl(final String siteUrl, final String httpUsername, final String httpPassword)
throws XMLRPCUtilsException {
// Ordered set of Strings that contains the URLs we want to try. No discovery ;)
final Set<String> urlsToTry = new LinkedHashSet<>();
final String sanitizedSiteUrlHttps = XMLRPCUtils.sanitizeSiteUrl(siteUrl, true);
final String sanitizedSiteUrlHttp = XMLRPCUtils.sanitizeSiteUrl(siteUrl, false);
// start by adding the https URL with 'xmlrpc.php'. This will be the first URL to try.
urlsToTry.add(XMLRPCUtils.appendXMLRPCPath(sanitizedSiteUrlHttp));
urlsToTry.add(XMLRPCUtils.appendXMLRPCPath(sanitizedSiteUrlHttps));
// add the sanitized https URL without the '/xmlrpc.php' suffix added to it
urlsToTry.add(sanitizedSiteUrlHttp);
urlsToTry.add(sanitizedSiteUrlHttps);
// add the user provided URL as well
urlsToTry.add(siteUrl);
AppLog.i(AppLog.T.NUX, "The app will call system.listMethods on the following URLs: " + urlsToTry);
for (String url : urlsToTry) {
try {
if (XMLRPCUtils.checkXMLRPCEndpointValidity(url, httpUsername, httpPassword)) {
// Endpoint found and works fine.
return url;
}
} catch (XMLRPCUtilsException e) {
if (e.kind == XMLRPCUtilsException.Kind.ERRONEOUS_SSL_CERTIFICATE ||
e.kind == XMLRPCUtilsException.Kind.HTTP_AUTH_REQUIRED ||
e.kind == XMLRPCUtilsException.Kind.MISSING_XMLRPC_METHOD) {
throw e;
}
// swallow the error since we are just verifying various URLs
continue;
} catch (RuntimeException re) {
// depending how corrupt the user entered URL is, it can generate several kind of runtime exceptions,
// ignore them
continue;
}
}
// input url was not verified to be working
return null;
}
// Attempts to retrieve the xmlrpc url for a self-hosted site.
// See diagrams here https://github.com/wordpress-mobile/WordPress-Android/issues/3805 for details about the
// whole process.
private static String discoverSelfHostedXmlrpcUrl(String siteUrl, String httpUsername, String httpPassword) throws
XMLRPCUtilsException {
// Ordered set of Strings that contains the URLs we want to try
final Set<String> urlsToTry = new LinkedHashSet<>();
// add the url as provided by the user
urlsToTry.add(siteUrl);
// add a sanitized version of the https url (if the user didn't specify it)
urlsToTry.add(sanitizeSiteUrl(siteUrl, true));
// add a sanitized version of the http url (if the user didn't specify it)
urlsToTry.add(sanitizeSiteUrl(siteUrl, false));
AppLog.i(AppLog.T.NUX, "The app will call the RSD discovery process on the following URLs: " + urlsToTry);
String xmlrpcUrl = null;
for (String currentURL : urlsToTry) {
try {
// Download the HTML content
AppLog.i(AppLog.T.NUX, "Downloading the HTML content at the following URL: " + currentURL);
String responseHTML = ApiHelper.getResponse(currentURL);
if (TextUtils.isEmpty(responseHTML)) {
AppLog.w(AppLog.T.NUX, "Content downloaded but it's empty or null. Skipping this URL");
continue;
}
// Try to find the RSD tag with a regex
String rsdUrl = getRSDMetaTagHrefRegEx(responseHTML);
// If the regex approach fails try to parse the HTML doc and retrieve the RSD tag.
if (rsdUrl == null) {
rsdUrl = getRSDMetaTagHref(responseHTML);
}
rsdUrl = UrlUtils.addUrlSchemeIfNeeded(rsdUrl, false);
// if the RSD URL is empty here, try to see if there is already the pingback or the Apilink in the doc
// the user could have inserted a direct link to the xml-rpc endpoint
if (rsdUrl == null) {
AppLog.i(AppLog.T.NUX, "Can't find the RSD endpoint in the HTML document. Try to check the " +
"pingback tag, and the apiLink tag.");
xmlrpcUrl = UrlUtils.addUrlSchemeIfNeeded(getXMLRPCPingback(responseHTML), false);
if (xmlrpcUrl == null) {
xmlrpcUrl = UrlUtils.addUrlSchemeIfNeeded(getXMLRPCApiLink(responseHTML), false);
}
} else {
AppLog.i(AppLog.T.NUX, "RSD endpoint found at the following address: " + rsdUrl);
AppLog.i(AppLog.T.NUX, "Downloading the RSD document...");
String rsdEndpointDocument = ApiHelper.getResponse(rsdUrl);
if (TextUtils.isEmpty(rsdEndpointDocument)) {
AppLog.w(AppLog.T.NUX, "Content downloaded but it's empty or null. Skipping this RSD document" +
" URL.");
continue;
}
AppLog.i(AppLog.T.NUX, "Extracting the XML-RPC Endpoint address from the RSD document");
xmlrpcUrl = UrlUtils.addUrlSchemeIfNeeded(getXMLRPCApiLink(rsdEndpointDocument), false);
}
if (xmlrpcUrl != null) {
AppLog.i(AppLog.T.NUX, "Found the XML-RPC endpoint in the HTML document!!!");
break;
} else {
AppLog.i(AppLog.T.NUX, "XML-RPC endpoint NOT found");
}
} catch (SSLHandshakeException e) {
if (!WPUrlUtils.isWordPressCom(currentURL)) {
throw new XMLRPCUtilsException(Kind.ERRONEOUS_SSL_CERTIFICATE, 0, currentURL, null);
}
AppLog.w(AppLog.T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
return null;
} catch (TimeoutError | TimeoutException e) {
AppLog.w(AppLog.T.NUX, "Timeout error while connecting to the site: " + currentURL);
throw new XMLRPCUtilsException(Kind.SITE_TIME_OUT, org.wordpress.android.R
.string.site_timeout_error, currentURL, null);
}
}
if (URLUtil.isValidUrl(xmlrpcUrl)) {
if (checkXMLRPCEndpointValidity(xmlrpcUrl, httpUsername, httpPassword)) {
// Endpoint found and works fine.
return xmlrpcUrl;
}
}
throw new XMLRPCUtilsException(Kind.NO_SITE_ERROR, org.wordpress.android.R.string.no_site_error, null, null);
}
public static List<Map<String, Object>> getUserBlogsList(URI xmlrpcUri, String username, String password, String
httpUsername, String httpPassword) throws XMLRPCUtilsException {
XMLRPCClientInterface client = XMLRPCFactory.instantiate(xmlrpcUri, httpUsername, httpPassword);
Object[] params = { username, password };
try {
Object[] userBlogs = (Object[]) client.call(ApiHelper.Method.GET_BLOGS, params);
if (userBlogs == null) {
// Could happen if the returned server response is truncated
throw new XMLRPCUtilsException(Kind.XMLRPC_MALFORMED_RESPONSE, R.string.xmlrpc_malformed_response_error,
xmlrpcUri.toString(), client.getResponse());
}
Arrays.sort(userBlogs, BlogUtils.BlogNameComparator);
List<Map<String, Object>> userBlogList = new ArrayList<>();
for (Object blog : userBlogs) {
try {
userBlogList.add((Map<String, Object>) blog);
} catch (ClassCastException e) {
AppLog.e(AppLog.T.NUX, "invalid data received from XMLRPC call wp.getUsersBlogs");
}
}
return userBlogList;
} catch (XmlPullParserException parserException) {
AppLog.e(AppLog.T.NUX, "invalid data received from XMLRPC call wp.getUsersBlogs", parserException);
throw new XMLRPCUtilsException(Kind.XMLRPC_ERROR, R.string.xmlrpc_error, xmlrpcUri.toString(), client
.getResponse());
} catch (XMLRPCFault xmlRpcFault) {
AppLog.e(AppLog.T.NUX, "XMLRPCFault received from XMLRPC call wp.getUsersBlogs", xmlRpcFault);
throw new XMLRPCUtilsException(Kind.XMLRPC_ERROR, handleXmlRpcFault(xmlRpcFault), xmlrpcUri.toString()
, client.getResponse());
} catch (XMLRPCException xmlRpcException) {
AppLog.e(AppLog.T.NUX, "XMLRPCException received from XMLRPC call wp.getUsersBlogs", xmlRpcException);
throw new XMLRPCUtilsException(Kind.XMLRPC_ERROR, R.string.no_site_error, xmlrpcUri.toString(), client
.getResponse());
} catch (SSLHandshakeException e) {
if (!WPUrlUtils.isWordPressCom(xmlrpcUri.toString())) {
throw new XMLRPCUtilsException(Kind.ERRONEOUS_SSL_CERTIFICATE, 0, xmlrpcUri.toString(), null);
}
AppLog.w(AppLog.T.NUX, "SSLHandshakeException failed. Erroneous SSL certificate detected.");
} catch (ConnectTimeoutException e) {
AppLog.e(AppLog.T.NUX, "Timeout exception when calling wp.getUsersBlogs", e);
throw new XMLRPCUtilsException(Kind.SITE_TIME_OUT, R.string.site_timeout_error,
xmlrpcUri.toString(), client.getResponse());
} catch (IOException e) {
AppLog.e(AppLog.T.NUX, "Exception received from XMLRPC call wp.getUsersBlogs", e);
throw new XMLRPCUtilsException(Kind.XMLRPC_ERROR, R.string.no_site_error, xmlrpcUri.toString(), client
.getResponse());
}
throw new XMLRPCUtilsException(Kind.XMLRPC_ERROR, R.string.no_site_error, xmlrpcUri.toString(), client
.getResponse());
}
/**
* Regex pattern for matching the RSD link found in most WordPress sites.
*/
private static final Pattern rsdLink = Pattern.compile(
"<link\\s*?rel=\"EditURI\"\\s*?type=\"application/rsd\\+xml\"\\s*?title=\"RSD\"\\s*?href=\"(.*?)\"",
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
/**
* Returns RSD URL based on regex match
*
* @return String RSD url
*/
private static String getRSDMetaTagHrefRegEx(String html) {
if (html != null) {
Matcher matcher = rsdLink.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
}
return null;
}
/**
* Returns RSD URL based on html tag search
*
* @return String RSD url
*/
private static String getRSDMetaTagHref(String data) {
// parse the html and get the attribute for xmlrpc endpoint
if (data != null) {
// Many WordPress configs can output junk before the xml response (php warnings for example), this cleans
// it.
int indexOfFirstXML = data.indexOf("<?xml");
if (indexOfFirstXML > 0) {
data = data.substring(indexOfFirstXML);
}
StringReader stringReader = new StringReader(data);
XmlPullParser parser = Xml.newPullParser();
try {
// auto-detect the encoding from the stream
parser.setInput(stringReader);
int eventType = parser.getEventType();
while (eventType != XmlPullParser.END_DOCUMENT) {
String name;
String rel = "";
String type = "";
String href = "";
switch (eventType) {
case XmlPullParser.START_TAG:
name = parser.getName();
if (name.equalsIgnoreCase("link")) {
for (int i = 0; i < parser.getAttributeCount(); i++) {
String attrName = parser.getAttributeName(i);
String attrValue = parser.getAttributeValue(i);
if (attrName.equals("rel")) {
rel = attrValue;
} else if (attrName.equals("type"))
type = attrValue;
else if (attrName.equals("href"))
href = attrValue;
}
if (rel.equals("EditURI") && type.equals("application/rsd+xml")) {
return href;
}
// currentMessage.setLink(parser.nextText());
}
break;
}
eventType = parser.next();
}
} catch (XmlPullParserException e) {
AppLog.e(AppLog.T.API, e);
return null;
} catch (IOException e) {
AppLog.e(AppLog.T.API, e);
return null;
}
}
return null; // never found the rsd tag
}
/**
* Find the XML-RPC endpoint for the WordPress API.
*
* @return XML-RPC endpoint for the specified blog, or null if unable to discover endpoint.
*/
private static String getXMLRPCApiLink(String html) {
Pattern xmlrpcLink = Pattern.compile("<api\\s*?name=\"WordPress\".*?apiLink=\"(.*?)\"",
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
if (html != null) {
Matcher matcher = xmlrpcLink.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
}
return null; // never found the api link tag
}
/**
* Find the XML-RPC endpoint by using the pingback tag
*
* @return String XML-RPC url
*/
private static String getXMLRPCPingback(String html) {
Pattern pingbackLink = Pattern.compile(
"<link\\s*?rel=\"pingback\"\\s*?href=\"(.*?)\"",
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
if (html != null) {
Matcher matcher = pingbackLink.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
}
return null;
}
}