package er.extensions.appserver;
import java.net.HttpCookie;
import java.text.SimpleDateFormat;
import java.util.Enumeration;
import java.util.Map;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.webobjects.appserver.WOApplication;
import com.webobjects.appserver.WOContext;
import com.webobjects.appserver.WORequest;
import com.webobjects.appserver._private.WOProperties;
import com.webobjects.appserver._private.WOShared;
import com.webobjects.appserver._private.WOURLFormatException;
import com.webobjects.foundation.NSArray;
import com.webobjects.foundation.NSComparator;
import com.webobjects.foundation.NSData;
import com.webobjects.foundation.NSDictionary;
import com.webobjects.foundation.NSForwardException;
import com.webobjects.foundation.NSMutableArray;
import com.webobjects.foundation.NSMutableDictionary;
import com.webobjects.foundation.NSTimestamp;
import er.extensions.foundation.ERXProperties;
import er.extensions.localization.ERXLocalizer;
/**
* Subclass of WORequest that fixes several Bugs.
* The ID's are #2924761 and #2961017. It can also be extended to handle
* #2957558 ("de-at" is converted to "German" instead of "German_Austria").
* The request is created via {@link ERXApplication#createRequest(String, String, String, Map, NSData, Map)}.
*/
public class ERXRequest extends WORequest {
private static final Logger log = LoggerFactory.getLogger(ERXRequest.class);
public static final String UNKNOWN_HOST = "UNKNOWN";
public static final String X_FORWARDED_PROTO_FOR_SSL = ERXProperties.stringForKeyWithDefault("er.extensions.appserver.ERXRequest.xForwardedProtoForSsl", "https");
public static final String X_FORWARDED_PROTO_HEADER_KEY_FOR_SSL = ERXProperties.stringForKeyWithDefault("er.extensions.appserver.ERXRequest.xForwardedProtoHeaderKeyForSsl", "x-forwarded-proto");
protected static Boolean isBrowserFormValueEncodingOverrideEnabled;
protected static final NSArray<String> HOST_ADDRESS_KEYS = new NSArray<>(new String[]{"x-forwarded-for", "pc-remote-addr", "remote_host", "remote_addr", "remote_user", "x-webobjects-remote-addr"});
// 'Host' is the official HTTP 1.1 header for the host name in the request URL, so this should be checked first.
// @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.23
// when the app is behind a reverse proxy 'Host' will contain the proxy address instead of the requested one so check first for 'x-forwarded-host'
// @see http://httpd.apache.org/docs/2.2/mod/mod_proxy.html#x-headers
// Fallback headers such as server_name will screw up your complete URL generation for secure domains that have wildcard subdomains since it returns sth like *.domain.com for host name
protected static final NSArray<String> HOST_NAME_KEYS = new NSArray<>(new String[]{"x-forwarded-host", "Host", "x-webobjects-server-name", "server_name", "http_host"});
/** NSArray to keep browserLanguages in. */
protected NSArray<String> _browserLanguages;
/** holds a reference to the browser object */
protected ERXBrowser _browser;
/**
* Specifies whether https should be overridden to be enabled or disabled app-wide. This is
* useful if you are developing with DirectConnect and you want to be able to specify secure
* forms and links, but you want to be able to continue testing them without setting up SSL.
*
* Defaults to <code>false</code>, set er.extensions.ERXRequest.secureDisabled=true to turn it off.
*/
protected boolean _secureDisabled;
/**
* Holds the cookies in a NSDictionary.
*/
protected NSDictionary<String, NSArray<String>> _cookieDictionary;
/**
* Returns a ERXRequest object initialized with the specified parameters.
*
* @param aMethod a "GET", "POST" or "HEAD", may not be <code>null</code>. If <code>null</code>, or not one of the allowed methods, an IllegalArgumentException will be thrown
* @param aURL a URL, may not be null or an IllegalArgumentException will be thrown
* @param anHTTPVersion the version of HTTP used when sending the message, may not be <code>null</code> or an IllegalArgumentException will be thrown
* @param someHeaders a dictionary whose String keys correspond to header names and whose values are arrays of one or more strings corresponding to the values of each header
* @param aContent the HTML content
* @param aUserInfoDictionary java.util.Map that contains any information that the WORequest object wants to pass along to other objects
*/
public ERXRequest(String aMethod, String aURL, String anHTTPVersion,
Map someHeaders, NSData aContent, Map aUserInfoDictionary) {
super(aMethod, aURL, anHTTPVersion, someHeaders, aContent, aUserInfoDictionary);
if (isBrowserFormValueEncodingOverrideEnabled() && browser().formValueEncoding() != null) {
setDefaultFormValueEncoding(browser().formValueEncoding());
}
_secureDisabled = ERXRequest._isSecureDisabled();
}
/**
* This method is used by WOContext when generating full URLs for form actions in secure mode, etc.
*
* Overriding this because WORequest checks 'server_name' before 'Host' by default and it does not cut it for generating full secure
* urls in the case of a hostname that uses a wildcard SSL certificate allowing infinite secure subdomains.
*
* For example, if we have a wildcard ssl cert for https://*.mydomain.com (where * = wildcard subdomain), and we use
* subdomains to implement CSS skinning for different customers that are all using the
* same WO app while using subdomains to get their "custom" site, with host names such as
* acmesandwiches.mydomain.com, apple.mydomain.com, kfc.mydomain.com, etc., and we configure apache with one virtual host config for
* *.mydomain.com, then the stupid 'server_name' header will return *.mydomain.com INSTEAD OF the host name
* used in the request URL, and thus all https urls in forms, links etc will be broken.
*
* @return the server name, which happens to be used by WOContext for generating full URLs.
* @see com.webobjects.appserver.WORequest#_serverName()
* @see WOContext#completeURLWithRequestHandlerKey(String, String, String, String, boolean, int)
* @see WORequest#_completeURLPrefix(StringBuffer, boolean, int)
*/
@Override
public String _serverName() {
String serverName = headerForKey("x-webobjects-servlet-server-name");
if ((serverName == null) || (serverName.length() == 0)) {
if (isUsingWebServer()) {
// Check our host name keys in our preferred order instead of Apple WO 5.4.3 default header check logic.
serverName = remoteHostName();
if ((serverName == null) || (serverName.length() == 0) || serverName.equals(UNKNOWN_HOST))
throw new NSForwardException(new WOURLFormatException("<" + super.getClass().getName() + ">: Unable to build complete url as no server name was provided in the headers of the request."));
}
else {
serverName = WOApplication.application().host();
}
}
return serverName;
}
/**
* Returns <code>true</code> if er.extensions.ERXRequest.secureDisabled is true.
* Defaults to <code>false</code>.
*
* @return <code>true</code> if er.extensions.ERXRequest.secureDisabled is true
*/
public static boolean _isSecureDisabled() {
return ERXProperties.booleanForKeyWithDefault("er.extensions.ERXRequest.secureDisabled", false);
}
/**
* Returns <code>true</code> if er.extensions.ERXRequest.secureDisabled is true.
*
* @return <code>true</code> if er.extensions.ERXRequest.secureDisabled is true
*/
public boolean isSecureDisabled() {
return _secureDisabled;
}
public boolean isBrowserFormValueEncodingOverrideEnabled() {
if (isBrowserFormValueEncodingOverrideEnabled == null) {
isBrowserFormValueEncodingOverrideEnabled = ERXProperties.booleanForKeyWithDefault("er.extensions.ERXRequest.BrowserFormValueEncodingOverrideEnabled", false) ? Boolean.TRUE : Boolean.FALSE;
}
return isBrowserFormValueEncodingOverrideEnabled.booleanValue();
}
@Override
public WOContext context() {
return _context();
}
/** Returns a cooked version of the languages the user has set in his Browser.
* Adds "Nonlocalized" and {@link er.extensions.localization.ERXLocalizer#defaultLanguage()} if not
* already present. Transforms regionalized en_us to English_US as a key.
* @return cooked version of user's languages
*/
@Override
@SuppressWarnings("unchecked")
public NSArray<String> browserLanguages() {
if (_browserLanguages == null) {
NSMutableArray<String> languageKeys = new NSMutableArray<>();
NSArray<String> fixedLanguages = null;
String string = headerForKey("accept-language");
if (string != null) {
NSArray<String> rawLanguages = NSArray.componentsSeparatedByString(string, ",");
fixedLanguages = fixAbbreviationArray(rawLanguages);
for (Enumeration<String> e = fixedLanguages.objectEnumerator(); e.hasMoreElements();) {
String languageKey = e.nextElement();
String language = WOProperties.TheLanguageDictionary.objectForKey(languageKey);
if(language == null) {
int index = languageKey.indexOf('_');
if(index > 0) {
String mainLanguageKey = languageKey.substring(0, index);
String region = languageKey.substring(index);
language = WOProperties.TheLanguageDictionary.objectForKey(mainLanguageKey);
if(language != null) {
language = language + region.toUpperCase();
}
}
}
if(language != null) {
languageKeys.addObject(language);
}
}
}
languageKeys.addObject("Nonlocalized");
if(!languageKeys.containsObject(ERXLocalizer.defaultLanguage())) {
languageKeys.addObject(ERXLocalizer.defaultLanguage());
}
_browserLanguages = languageKeys.immutableClone();
}
return _browserLanguages;
}
@Override
public String stringFormValueForKey(String key) {
String result = super.stringFormValueForKey(key);
if (result == null && "wodata".equals(key)) {
// AK: yet another crappy 5.4 fix, WODynamicURL changed packages
String requestHandlerKey = (String)valueForKeyPath("_uriDecomposed.requestHandlerKey");
if (WOApplication.application().resourceRequestHandlerKey().equals(requestHandlerKey)) {
String requestHandlerPath = (String)valueForKeyPath("_uriDecomposed.requestHandlerPath");
if(requestHandlerPath != null) {
requestHandlerPath = "file:/" + requestHandlerPath.substring("wodata=/".length());
result = requestHandlerPath.replace('+', ' ');
}
}
}
return result;
}
@Override
public NSTimestamp dateFormValueForKey(String aKey, SimpleDateFormat dateFormatter) {
String aDateString = stringFormValueForKey(aKey);
java.util.Date aDate = null;
if (aDateString != null && dateFormatter != null) {
try {
aDate = dateFormatter.parse(aDateString);
} catch (java.text.ParseException e) {
log.error("Could not parse date '{}'.", aDateString, e);
}
}
return aDate == null ? null : new NSTimestamp(aDate);
}
/**
* Gets the ERXBrowser associated with the user-agent of
* the request.
* @return browser object for the request
*/
public ERXBrowser browser() {
if (_browser == null) {
ERXBrowserFactory browserFactory = ERXBrowserFactory.factory();
_browser = browserFactory.browserMatchingRequest(this);
browserFactory.retainBrowser(_browser);
}
return _browser;
}
/**
* Cleaning up retain count on the browser.
*/
@Override
public void finalize() throws Throwable {
if (_browser != null) {
ERXBrowserFactory.factory().releaseBrowser(_browser);
}
super.finalize();
}
/**
* Returns whether or not this request is secure.
*
* @return whether or not this request is secure
*/
@Override
public boolean isSecure() {
return ERXRequest.isRequestSecure(this);
}
@Override
public void _completeURLPrefix(StringBuffer stringbuffer, boolean secure, int port) {
if (_secureDisabled) {
secure = false;
}
String serverName = _serverName();
String portStr;
if (port == 0) {
String sslPort = String.valueOf(ERXApplication.erxApplication().sslPort());
portStr = secure ? sslPort : _serverPort();
} else {
portStr = WOShared.unsignedIntString(port);
}
if (secure) {
stringbuffer.append("https://");
} else {
stringbuffer.append("http://");
}
stringbuffer.append(serverName);
if(portStr != null && WOApplication.application().isDirectConnectEnabled() && ((secure && !"443".equals(portStr)) || (!secure && !"80".equals(portStr)))) {
stringbuffer.append(':');
stringbuffer.append(portStr);
}
}
/**
* Returns whether or not the given request is secure.
* MS: I found this somewhere else a while ago, but I have no idea where or
* I'd give attribution.
*
* @param request the request to check
* @return whether or not the given request is secure.
*/
public static boolean isRequestSecure(WORequest request) {
boolean isRequestSecure = false;
// Depending on the adaptor the incoming port can be found in one of two
// places.
if (request != null) {
String serverPort = request.headerForKey("SERVER_PORT");
if (serverPort == null) {
serverPort = request.headerForKey("x-webobjects-server-port");
}
// Apache and some other web servers use this to indicate HTTPS mode.
String httpsMode = request.headerForKey("https");
// If either the https header is 'on' or the server port is 443 then we
// consider this to be an HTTP request.
if (httpsMode != null && httpsMode.equalsIgnoreCase("on")) {
isRequestSecure = true;
}
else if (serverPort != null && WOApplication.application() instanceof ERXApplication && String.valueOf(ERXApplication.erxApplication().sslPort()).equals(serverPort)) {
isRequestSecure = true;
}
// MS: I have no idea how to do this properly ... There doesn't appear to be any way to
// determine which adaptor is servicing this request right now, and WOHttpIO only tracks the
// the originating port, not the original server port that serviced the request.
else if (!request.isUsingWebServer()) {
// It turns out there appears to always be a "host" header of the format "hostname:port" ... I
// don't believe this is actually secure at ALL, so I'm only enabling it when you're not using
// a webserver (i.e. probably testing).
String hostHeader = request.headerForKey("host");
if (hostHeader != null && WOApplication.application() instanceof ERXApplication && hostHeader.endsWith(":" + ERXApplication.erxApplication().sslPort())) {
isRequestSecure = true;
}
}
// Check if we've got an x-forwarded-proto header which is typically sent by a load balancer that is
// implementing ssl termination to indicate the request on the public side of the load balancer is secure.
else if (X_FORWARDED_PROTO_FOR_SSL.equals(request.headerForKey(X_FORWARDED_PROTO_HEADER_KEY_FOR_SSL))) {
isRequestSecure = true;
}
}
return isRequestSecure;
}
private static class _LanguageComparator extends NSComparator {
public _LanguageComparator() {}
private static float quality(String languageString) {
float result=0f;
if (languageString!=null) {
languageString = languageString.trim();
int semicolon=languageString.indexOf(';');
if (semicolon!=-1 &&
languageString.length()>semicolon+2) {
result=Float.parseFloat(languageString.substring(semicolon+1).trim().substring(2));
} else
result=1.0f;
}
return result;
}
@Override
public int compare(Object o1, Object o2) {
float f1=quality((String)o1);
float f2=quality((String)o2);
return f1<f2 ? OrderedDescending : ( f1==f2 ? OrderedSame : OrderedAscending ); // we want DESCENDING SORT!!
}
}
private final static NSComparator COMPARE_Qs = new _LanguageComparator();
/** Translates ("de", "en-us;q=0.33", "en", "en-gb;q=0.66") to ("de", "en_gb", "en-us", "en").
* @param languages NSArray of Strings
* @return sorted NSArray of normalized Strings
*/
protected NSArray<String> fixAbbreviationArray(NSArray<String> languages) {
try {
languages=languages.sortedArrayUsingComparator(COMPARE_Qs);
} catch (NSComparator.ComparisonException e) {
log.warn("Couldn't sort language array {}.", languages, e);
} catch (NumberFormatException e2) {
log.warn("Couldn't sort language array {}.", languages, e2);
}
NSMutableArray<String> languagePrefix = new NSMutableArray<>(languages.count());
for (int languageNum = languages.count() - 1; languageNum >= 0; languageNum--) {
String language = languages.objectAtIndex(languageNum);
int offset;
language = language.trim();
offset = language.indexOf(';');
if (offset > 0) {
language = language.substring(0, offset);
}
offset = language.indexOf('-');
if (offset > 0) {
String langPrefix = language.substring(0, offset); // "en" part of "en-us"
if (!languagePrefix.containsObject(langPrefix)) {
languagePrefix.insertObjectAtIndex(langPrefix, 0);
}
// converts "en-us" into "en_us";
String cooked = language.replace('-', '_');
language = cooked;
}
languagePrefix.insertObjectAtIndex(language, 0);
}
return languagePrefix;
}
/**
* Parses all cookies one at a time catch parse exception which just discards
* that cookie and not all cookies. It uses java.net.HttpCookie as a parser.
* @return a dictionary of cookies, parsed one cookie at a time
*/
private NSDictionary _cookieDictionary() {
if (_cookieDictionary == null) {
NSMutableDictionary<String, NSArray<String>> cookieDictionary = new NSMutableDictionary<String, NSArray<String>>();
//
// from WORequest._cookieDescription()
String cookie = headerForKey("cookie");
if (cookie == null || cookie.length() == 0)
// IIS cookies use a different header
cookie = headerForKey("http_cookie");
if (cookie != null && cookie.length() > 0) {
String[] cookies = cookie.split(";");
for (int i = 0; i < cookies.length; i++) {
try {
//
// only parse one cookie at a time => get(0)
HttpCookie httpCookie = HttpCookie.parse(cookies[i]).get(0);
log.debug("Cookie: '"+httpCookie.getName()+"' = '"+httpCookie.getValue()+"'");
cookieDictionary.setObjectForKey(new NSArray<String>(httpCookie.getValue()), httpCookie.getName());
} catch (Throwable t) {
log.warn("Unable to parse cookie '"+cookies[i]+"' : "+t.getMessage());
}
}
}
_cookieDictionary = cookieDictionary.immutableClone();
}
return _cookieDictionary;
}
/**
* Overridden to call _cookieDictionary() where we parse the cookies one
* at a time using java.net.HttpCookie so that we don't get an empty cookie
* dictionary if one cookie is malformed.
*/
@Override
public NSDictionary cookieValues() {
return _cookieDictionary();
}
/**
* Overridden because the super implementation would pull in all
* content even if the request is supposed to be streaming and thus
* very large. Will now return <code>false</code> if the request
* handler is streaming.
*
* @return <code>true</code> if the session ID can be obtained from the form values or a cookie.
*/
@Override
public boolean isSessionIDInRequest() {
ERXApplication app = (ERXApplication)WOApplication.application();
if (app.isStreamingRequestHandlerKey(requestHandlerKey())) {
return false;
}
return super.isSessionIDInRequest();
}
/**
* Overridden because the super implementation would pull in all
* content even if the request is supposed to be streaming and thus
* very large. Will now look for the session ID only in the cookie
* values.
*
* @param inCookiesFirst
* define if session ID should be searched first in cookie
*/
@Override
protected String _getSessionIDFromValuesOrCookie(boolean inCookiesFirst) {
ERXApplication app = (ERXApplication)WOApplication.application();
String sessionIdKey = WOApplication.application().sessionIdKey();
boolean wis = WOApplication.application().streamActionRequestHandlerKey().equals(requestHandlerKey());
boolean alternateStreaming = app.isStreamingRequestHandlerKey(requestHandlerKey());
boolean streaming = wis || alternateStreaming;
String sessionID = null;
if(inCookiesFirst) {
sessionID = cookieValueForKey(sessionIdKey);
if(sessionID == null && !streaming) {
sessionID = stringFormValueForKey(sessionIdKey);
}
} else {
if(!streaming) {
sessionID = stringFormValueForKey(sessionIdKey);
}
if(sessionID == null) {
sessionID = cookieValueForKey(sessionIdKey);
}
}
return sessionID;
}
/**
* Utility method to set credentials for basic authorization.
*
* @param userName
* the user name
* @param password
* the password
*/
public void setCredentials(String userName, String password) {
String up = userName + ":" + password;
byte[] bytes = up.getBytes();
String encodedString = Base64.encodeBase64String(bytes);
setHeader("Basic " + encodedString, "authorization");
}
/**
* Returns the remote client host address. Works in various setups, like
* direct connect, deployed etc. If no host name can be found,
* returns "UNKNOWN".
*
* @return remote client host address
*/
public String remoteHostAddress() {
if (WOApplication.application().isDirectConnectEnabled()) {
if (_originatingAddress() != null) {
return _originatingAddress().getHostAddress();
}
}
for (String key : HOST_ADDRESS_KEYS) {
String remoteAddressHeaderValue = headerForKey(key);
if (remoteAddressHeaderValue != null) {
return remoteAddressHeaderValue;
}
}
return UNKNOWN_HOST;
}
/**
* Returns the remote client host name. If no host name can be found,
* returns "UNKNOWN".
*
* @return remote client host name
*/
public String remoteHostName() {
for (String key : HOST_NAME_KEYS) {
if (headerForKey(key) != null) {
return headerForKey(key);
}
}
return UNKNOWN_HOST;
}
public NSMutableDictionary<String, Object> mutableUserInfo() {
NSDictionary userInfo = userInfo();
NSMutableDictionary mutableUserInfo;
if (userInfo == null) {
mutableUserInfo = new NSMutableDictionary();
_userInfo = mutableUserInfo;
}
else if (userInfo instanceof NSMutableDictionary) {
mutableUserInfo = (NSMutableDictionary) userInfo;
}
else {
mutableUserInfo = userInfo.mutableClone();
_userInfo = mutableUserInfo;
}
return mutableUserInfo;
}
}