/** * Copyright 2005-2014 Restlet * * The contents of this file are subject to the terms of one of the following * open source licenses: Apache 2.0 or or EPL 1.0 (the "Licenses"). You can * select the license that you prefer but you may not use this file except in * compliance with one of these Licenses. * * You can obtain a copy of the Apache 2.0 license at * http://www.opensource.org/licenses/apache-2.0 * * You can obtain a copy of the EPL 1.0 license at * http://www.opensource.org/licenses/eclipse-1.0 * * See the Licenses for the specific language governing permissions and * limitations under the Licenses. * * Alternatively, you can obtain a royalty free commercial license with less * limitations, transferable or non-transferable, directly at * http://restlet.com/products/restlet-framework * * Restlet is a registered trademark of Restlet S.A.S. */ package org.restlet.engine.application; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import org.restlet.Context; import org.restlet.Request; import org.restlet.Response; import org.restlet.data.CharacterSet; import org.restlet.data.ClientInfo; import org.restlet.data.Encoding; import org.restlet.data.Form; import org.restlet.data.Header; import org.restlet.data.Language; import org.restlet.data.MediaType; import org.restlet.data.Metadata; import org.restlet.data.Method; import org.restlet.data.Preference; import org.restlet.data.Reference; import org.restlet.engine.Engine; import org.restlet.engine.header.HeaderConstants; import org.restlet.engine.header.PreferenceReader; import org.restlet.engine.io.IoUtils; import org.restlet.routing.Filter; import org.restlet.service.MetadataService; import org.restlet.service.TunnelService; import org.restlet.util.Series; // [excludes gwt] /** * Filter tunneling browser calls into full REST calls. The request method can * be changed (via POST requests only) as well as the accepted media types, * languages, encodings and character sets. * * Concurrency note: instances of this class or its subclasses can be invoked by * several threads at the same time and therefore must be thread-safe. You * should be especially careful when storing state in member variables. * * @author Jerome Louvel */ public class TunnelFilter extends Filter { /** * Used to describe the replacement value for an old client preference and * for a a series of specific agent (i.e. web client) attributes. * * @author Thierry Boileau */ private static class HeaderReplacer { static class Builder { Map<String, String> agentAttributes = new HashMap<String, String>(); String newValue; String oldValue; HeaderReplacer build() { return new HeaderReplacer(oldValue, newValue, agentAttributes); } void putAgentAttribute(String key, String value) { agentAttributes.put(key, value); } void setNewValue(String newValue) { this.newValue = newValue; } void setOldValue(String oldValue) { this.oldValue = oldValue; } } /** Agent attributes that must be checked. */ private final Map<String, String> agentAttributes; /** New header value. */ private final String headerNew; /** Old header value. */ private final String headerOld; HeaderReplacer(String headerOld, String headerNew, Map<String, String> agentAttributes) { this.headerOld = headerOld; this.headerNew = headerNew; this.agentAttributes = Collections.unmodifiableMap(agentAttributes); } public Map<String, String> getAgentAttributes() { return agentAttributes; } public String getHeaderNew() { return headerNew; } public String getHeaderOld() { return headerOld; } /** * Indicates if the current header replacer matches the request * attributes. * * @param agentAttributes * The user agent attributes to match. * @param headerOld * The facultative value of the current's request header to * match. * @return true if the given request's attibutes match the current * header replacer. */ public boolean matchesConditions(Map<String, String> agentAttributes, String headerOld) { // Check the conditions boolean checked = true; // Check that the agent properties match the properties // set by the rule. for (Iterator<Entry<String, String>> iterator = getAgentAttributes() .entrySet().iterator(); checked && iterator.hasNext();) { Entry<String, String> entry = iterator.next(); String attribute = agentAttributes.get(entry.getKey()); checked = (attribute != null && attribute .equalsIgnoreCase(entry.getValue())); } if (checked && getHeaderOld() != null) { // If the rule defines an old header value, check that it is the // same than the user agent's header value. checked = getHeaderOld().equals(headerOld); } return checked; } } /** Used to replace accept-encoding header values. */ private final List<HeaderReplacer> acceptEncodingReplacers = getAcceptEncodingReplacers(); /** Used to replace accept header values. */ private final List<HeaderReplacer> acceptReplacers = getAcceptReplacers(); /** * Constructor. * * @param context * The parent context. */ public TunnelFilter(Context context) { super(context); } @Override public int beforeHandle(Request request, Response response) { if (getTunnelService().isUserAgentTunnel()) { processUserAgent(request); } if (getTunnelService().isExtensionsTunnel()) { processExtensions(request); } if (getTunnelService().isQueryTunnel()) { processQuery(request); } if (getTunnelService().isHeadersTunnel()) { processHeaders(request); } return CONTINUE; } /** * Returns the list of new accept-encoding header values. Each of them * describe also a set of conditions required to set the new value. This * method is used only to initialize the headerReplacers field. * * @return The list of new accept-encoding header values. */ private List<HeaderReplacer> getAcceptEncodingReplacers() { // Load the accept.properties file. return getheaderReplacers( Engine.getResource("org/restlet/service/accept-encoding.properties"), "acceptEncodingOld", "acceptEncodingNew"); } /** * Returns the list of new accept header values. Each of them describe also * a set of conditions required to set the new value. This method is used * only to initialize the headerReplacers field. * * @return The list of new accept header values. */ private List<HeaderReplacer> getAcceptReplacers() { // Load the accept.properties file. return getheaderReplacers( Engine.getResource("org/restlet/service/accept.properties"), "acceptOld", "acceptNew"); } /** * Returns the list of new header values. Each of them describe also a set * of conditions required to set the new value. This method is used only to * initialize the headerReplacers field. * * @param userAgentPropertiesUrl * The URL of the properties file that describe replacement * values based on the user agent string. * @param oldHeaderName * The name of the property that gives the value of the header to * be replaced (could be null - in that case, the new value is * unconditionnaly set. * @param newHeaderName * The name of the property that gives the replacement value. * @return The list of new header values. */ private List<HeaderReplacer> getheaderReplacers( final URL userAgentPropertiesUrl, String oldHeaderName, String newHeaderName) { List<HeaderReplacer> headerReplacers = new ArrayList<HeaderReplacer>(); if (userAgentPropertiesUrl != null) { BufferedReader reader; try { reader = new BufferedReader(new InputStreamReader( userAgentPropertiesUrl.openStream(), CharacterSet.UTF_8.getName()), IoUtils.BUFFER_SIZE); HeaderReplacer.Builder headerReplacerBuilder = new HeaderReplacer.Builder(); try { // Read the entire file, excluding comment lines starting // with "#" character. String line = reader.readLine(); for (; line != null; line = reader.readLine()) { if (!line.startsWith("#")) { final String[] keyValue = line.split(":"); if (keyValue.length == 2) { final String key = keyValue[0].trim(); final String value = keyValue[1].trim(); if (oldHeaderName.equalsIgnoreCase(key)) { headerReplacerBuilder.setOldValue(("" .equals(value)) ? null : value); } else if (newHeaderName.equalsIgnoreCase(key)) { headerReplacerBuilder.setNewValue(value); headerReplacers.add(headerReplacerBuilder .build()); headerReplacerBuilder = new HeaderReplacer.Builder(); } else { headerReplacerBuilder.putAgentAttribute( key, value); } } } } } finally { reader.close(); } } catch (IOException e) { getContext().getLogger().warning( "Cannot read '" + userAgentPropertiesUrl.toString() + "' due to: " + e.getMessage()); } } return headerReplacers; } /** * Returns the metadata associated to the given extension using the * {@link MetadataService}. * * @param extension * The extension to lookup. * @return The matched metadata. */ private Metadata getMetadata(String extension) { return getMetadataService().getMetadata(extension); } /** * Returns the metadata service of the parent application. * * @return The metadata service of the parent application. */ public MetadataService getMetadataService() { return getApplication().getMetadataService(); } /** * Returns the tunnel service of the parent application. * * @return The tunnel service of the parent application. */ public TunnelService getTunnelService() { return getApplication().getTunnelService(); } /** * Updates the client preferences based on file-like extensions. The matched * extensions are removed from the last segment. * * See also section 3.6.1 of JAX-RS specification (<a * href="https://jsr311.dev.java.net">https://jsr311.dev.java.net</a>) * * @param request * The request to update. * @return True if the query has been updated, false otherwise. */ private boolean processExtensions(Request request) { final TunnelService tunnelService = getTunnelService(); boolean extensionsModified = false; // Tunnel the client preferences only for GET or HEAD requests final Method method = request.getMethod(); if (tunnelService.isPreferencesTunnel() && (method.equals(Method.GET) || method.equals(Method.HEAD))) { final Reference resourceRef = request.getResourceRef(); if (resourceRef.hasExtensions()) { final ClientInfo clientInfo = request.getClientInfo(); boolean encodingFound = false; boolean characterSetFound = false; boolean mediaTypeFound = false; boolean languageFound = false; String extensions = resourceRef.getExtensions(); // Discover extensions from right to left and stop at the first // unknown extension. Only one extension per type of metadata is // also allowed: i.e. one language, one media type, one // encoding, one character set. while (true) { final int lastIndexOfPoint = extensions.lastIndexOf('.'); final String extension = extensions .substring(lastIndexOfPoint + 1); final Metadata metadata = getMetadata(extension); if (!mediaTypeFound && (metadata instanceof MediaType)) { updateMetadata(clientInfo, metadata); mediaTypeFound = true; } else if (!languageFound && (metadata instanceof Language)) { updateMetadata(clientInfo, metadata); languageFound = true; } else if (!characterSetFound && (metadata instanceof CharacterSet)) { updateMetadata(clientInfo, metadata); characterSetFound = true; } else if (!encodingFound && (metadata instanceof Encoding)) { updateMetadata(clientInfo, metadata); encodingFound = true; } else { // extension do not match -> break loop break; } if (lastIndexOfPoint > 0) { extensions = extensions.substring(0, lastIndexOfPoint); } else { // no more extensions -> break loop extensions = ""; break; } } // Update the extensions if necessary if (encodingFound || characterSetFound || mediaTypeFound || languageFound) { resourceRef.setExtensions(extensions); extensionsModified = true; } } } return extensionsModified; } /** * Updates the request method based on specific header. * * @param request * The request to update. */ @SuppressWarnings("unchecked") private void processHeaders(Request request) { final TunnelService tunnelService = getTunnelService(); if (tunnelService.isMethodTunnel()) { // get the headers Series<Header> extraHeaders = (Series<Header>) request .getAttributes().get(HeaderConstants.ATTRIBUTE_HEADERS); if (extraHeaders != null) { // look for the new value of the method final String newMethodValue = extraHeaders.getFirstValue( getTunnelService().getMethodHeader(), true); if (newMethodValue != null && newMethodValue.trim().length() > 0) { // set the current method to the new method request.setMethod(Method.valueOf(newMethodValue)); } } } } /** * Updates the request method and client preferences based on query * parameters. The matched parameters are removed from the query. * * @param request * The request to update. * @return True if the query has been updated, false otherwise. */ private boolean processQuery(Request request) { TunnelService tunnelService = getTunnelService(); boolean queryModified = false; Reference resourceRef = request.getResourceRef(); if (resourceRef.hasQuery()) { Form query = resourceRef.getQueryAsForm(CharacterSet.UTF_8); // Tunnel the request method Method method = request.getMethod(); if (tunnelService.isMethodTunnel()) { String methodName = query.getFirstValue(tunnelService .getMethodParameter()); Method tunnelledMethod = Method.valueOf(methodName); // The OPTIONS method can be tunneled via GET requests. if (tunnelledMethod != null && (Method.POST.equals(method) || Method.OPTIONS .equals(tunnelledMethod))) { request.setMethod(tunnelledMethod); query.removeFirst(tunnelService.getMethodParameter()); queryModified = true; } } // Tunnel the client preferences if (tunnelService.isPreferencesTunnel()) { // Get the parameter names to look for String charSetParameter = tunnelService .getCharacterSetParameter(); String encodingParameter = tunnelService.getEncodingParameter(); String languageParameter = tunnelService.getLanguageParameter(); String mediaTypeParameter = tunnelService .getMediaTypeParameter(); // Get the preferences from the query String acceptedCharSet = query.getFirstValue(charSetParameter); String acceptedEncoding = query .getFirstValue(encodingParameter); String acceptedLanguage = query .getFirstValue(languageParameter); String acceptedMediaType = query .getFirstValue(mediaTypeParameter); // Updates the client preferences ClientInfo clientInfo = request.getClientInfo(); Metadata metadata = getMetadata(acceptedCharSet); if ((metadata == null) && (acceptedCharSet != null)) { metadata = CharacterSet.valueOf(acceptedCharSet); } if (metadata instanceof CharacterSet) { updateMetadata(clientInfo, metadata); query.removeFirst(charSetParameter); queryModified = true; } metadata = getMetadata(acceptedEncoding); if ((metadata == null) && (acceptedEncoding != null)) { metadata = Encoding.valueOf(acceptedEncoding); } if (metadata instanceof Encoding) { updateMetadata(clientInfo, metadata); query.removeFirst(encodingParameter); queryModified = true; } metadata = getMetadata(acceptedLanguage); if ((metadata == null) && (acceptedLanguage != null)) { metadata = Language.valueOf(acceptedLanguage); } if (metadata instanceof Language) { updateMetadata(clientInfo, metadata); query.removeFirst(languageParameter); queryModified = true; } metadata = getMetadata(acceptedMediaType); if ((metadata == null) && (acceptedMediaType != null)) { metadata = MediaType.valueOf(acceptedMediaType); } if (metadata instanceof MediaType) { updateMetadata(clientInfo, metadata); query.removeFirst(mediaTypeParameter); queryModified = true; } } // Update the query if it has been modified if (queryModified) { request.getResourceRef().setQuery(query.getQueryString(CharacterSet.UTF_8)); } } return queryModified; } /** * Updates the client preferences according to the user agent properties * (name, version, etc.) taken from the "agent.properties" file located in * the classpath. See {@link ClientInfo#getAgentAttributes()} for more * details.<br> * The list of new media type preferences is loaded from a property file * called "accept.properties" located in the classpath in the sub directory * "org/restlet/service". This property file is composed of blocks of * properties. One "block" of properties starts either with the beginning of * the properties file or with the end of the previous block. One block ends * with the "acceptNew" property which contains the value of the new accept * header. Here is a sample block. * * <pre> * agentName: firefox * acceptOld: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,\*\/\*;q=0.5 * acceptNew: application/xhtml+xml,text/html,text/xml;q=0.9,application/xml;q=0.9,text/plain;q=0.8,image/png,\*\/\*;q=0.5 * </pre> * * Each declared property is a condition that must be filled in order to * update the client preferences. For example "agentName: firefox" expresses * the fact this block concerns only "firefox" clients. * * The "acceptOld" property allows to check the value of the current * "Accept" header. If the latest equals to the value of the "acceptOld" * property then the preferences will be updated. This is useful for Ajax * clients which looks like their browser (same agentName, agentVersion, * etc.) but can provide their own "Accept" header. * * @param request * the request to update. */ private void processUserAgent(Request request) { final Map<String, String> agentAttributes = request.getClientInfo() .getAgentAttributes(); if (agentAttributes != null) { if (!this.acceptReplacers.isEmpty() || !this.acceptEncodingReplacers.isEmpty()) { // Get the old Accept header value @SuppressWarnings("unchecked") Series<Header> headers = (Series<Header>) request .getAttributes().get(HeaderConstants.ATTRIBUTE_HEADERS); String acceptOld = (headers != null) ? headers.getFirstValue( HeaderConstants.HEADER_ACCEPT, true) : null; // Check each replacer for (HeaderReplacer headerReplacer : this.acceptReplacers) { if (headerReplacer.matchesConditions(agentAttributes, acceptOld)) { ClientInfo clientInfo = new ClientInfo(); PreferenceReader.addMediaTypes( headerReplacer.getHeaderNew(), clientInfo); request.getClientInfo().setAcceptedMediaTypes( clientInfo.getAcceptedMediaTypes()); break; } } String acceptEncodingOld = (headers != null) ? headers .getFirstValue(HeaderConstants.HEADER_ACCEPT_ENCODING, true) : null; // Check each replacer for (HeaderReplacer headerReplacer : this.acceptEncodingReplacers) { if (headerReplacer.matchesConditions(agentAttributes, acceptEncodingOld)) { ClientInfo clientInfo = new ClientInfo(); PreferenceReader.addEncodings( headerReplacer.getHeaderNew(), clientInfo); request.getClientInfo().setAcceptedEncodings( clientInfo.getAcceptedEncodings()); break; } } } } } /** * Updates the client info with the given metadata. It clears existing * preferences for the same type of metadata if necessary. * * @param clientInfo * The client info to update. * @param metadata * The metadata to use. */ private void updateMetadata(ClientInfo clientInfo, Metadata metadata) { if (metadata != null) { if (metadata instanceof CharacterSet) { clientInfo.getAcceptedCharacterSets().clear(); clientInfo.getAcceptedCharacterSets().add( new Preference<CharacterSet>((CharacterSet) metadata)); } else if (metadata instanceof Encoding) { clientInfo.getAcceptedEncodings().clear(); clientInfo.getAcceptedEncodings().add( new Preference<Encoding>((Encoding) metadata)); } else if (metadata instanceof Language) { clientInfo.getAcceptedLanguages().clear(); clientInfo.getAcceptedLanguages().add( new Preference<Language>((Language) metadata)); } else if (metadata instanceof MediaType) { clientInfo.getAcceptedMediaTypes().clear(); clientInfo.getAcceptedMediaTypes().add( new Preference<MediaType>((MediaType) metadata)); } } } }