/* Copyright (c) 2001 - 2008 TOPP - www.openplans.org. All rights reserved. * This code is licensed under the GPL 2.0 license, availible at the root * application directory. */ package org.geoserver.filters; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.net.URL; import java.util.HashSet; import java.util.Iterator; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletOutputStream; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponseWrapper; import org.geoserver.config.GeoServer; import org.geoserver.config.GeoServerInfo; import org.geoserver.ows.util.RequestUtils; import org.geoserver.platform.GeoServerExtensions; import org.geotools.util.logging.Logging; /** * Servlet Filter that performs URL translation on content based on configured mime types. * <p> * This filter does the job of a content filtering reverse proxy, like apache2 <code>mod_html</code> * , but meant to be used out of the box for situations where the UI needs to be exposed through a * proxy server but for one reason or another the external reverse proxy is not installed or can't * be configured to perform URL translation on contents. * </p> * <p> * <h2>Init parameters</h2> * <ul> * <li><b><code>enabled</code></b>: one of <code>true</code> or <code>false</code>, defaults to * <code>false</code>. Indicates whether to enable this filter or not. * <li><b><code>mime-types</code></b>: comma separated list of java regular expressions used to * match the response mime type and decide whether to perform URL translation on the response * content or not. * </ul> * </p> * <p> * <h2>Operation</h2> * This Filter uses the configured {@link GeoServer#getProxyBaseUrl() proxyBaseUrl} to translate the * URL's found in textual content whose MIME type matches one of the regular expressions provided * through the <code>"mime-types"</code> filter init parameter. * </p> * <p> * Sample translations: given GeoServer being running in a servlet engine at * <code>http://localhost:8080/geoserver</code> and the <code>proxyBaseUrl</code> configured as * <code>http://myserver/tools/geoserver</code>: * <ul> * <li><code>"http://localhost:8080/geoserver/welcome.do"</code> gets translated as * <code>"http://myserver/tools/geoserver/welcome.do"</code> * <li><code>"/geoserver/style.css"</code> gets translated as * <code>"/tools/geoserver/style.css"</code> * </ul> * </p> * * @author Gabriel Roldan (TOPP) * @version $Id$ * @since 2.5.x * @source $URL: * https://svn.codehaus.org/geoserver/trunk/geoserver/web/src/main/java/org/geoserver/filters * /ReverseProxyFilter.java $ */ public class ReverseProxyFilter implements Filter { private static final Logger LOGGER = Logging.getLogger("org.geoserver.filters"); /** * Name of the filter init parameter that indicates whether the filter is enabled or disabled */ private static final String ENABLED_INIT_PARAM = "enabled"; /** * The name of the filter init parameter that contains the comma separated list of regular * expressions used to match the response mime types to translate URL's for */ private static final String MIME_TYPES_INIT_PARAM = "mime-types"; private boolean filterIsEnabled; /** * The set of Patterns used to match response mime types */ private final Set<Pattern> mimeTypePatterns = new HashSet<Pattern>(); private GeoServerInfo geoServer; /** * Parses the <code>mime-types</code> init parameter, which is a comma separated list of regular * expressions used to match the response mime types to decide whether to apply the URL * translation on content or not. */ public void init(final FilterConfig filterConfig) throws ServletException { final String enabledInitParam = filterConfig.getInitParameter(ENABLED_INIT_PARAM); this.filterIsEnabled = Boolean.valueOf(enabledInitParam).booleanValue(); if (filterIsEnabled) { final String mimeTypesInitParam = filterConfig.getInitParameter(MIME_TYPES_INIT_PARAM); GeoServer geoServerConfig = GeoServerExtensions.bean(GeoServer.class); if (geoServerConfig == null) { throw new ServletException("No " + GeoServer.class.getName() + " found, the system is either not properly " + "configured or the method to get to the GeoServer " + "config instance have changed!"); } geoServer = geoServerConfig.getGlobal(); if (geoServer == null) { throw new ServletException( "No GeoServerInfo instance found. Needed to look for the proxy base URL"); } Set<Pattern> patterns = parsePatterns(geoServer, mimeTypesInitParam); this.mimeTypePatterns.addAll(patterns); LOGGER.finer("Reverse Proxy Filter configured"); } else { LOGGER.fine("Reverse Proxy Filter is disabled by configuration"); } } static Set<Pattern> parsePatterns(final GeoServerInfo geoServer, final String mimeTypesInitParam) throws ServletException { final String[] split = mimeTypesInitParam.split(","); LOGGER.finer("Initializing Reverse Proxy Filter"); Set<Pattern> mimeTypePatterns = new HashSet<Pattern>(); try { for (int i = 0; i < split.length; i++) { String mimeTypeRegExp = split[i]; LOGGER.finest("Registering mime type regexp for reverse proxy filter: " + mimeTypeRegExp); Pattern mimeTypePattern = Pattern.compile(mimeTypeRegExp); mimeTypePatterns.add(mimeTypePattern); } } catch (PatternSyntaxException e) { throw new ServletException("Error compiling Reverse Proxy Filter mime-types: " + e.getMessage(), e); } return mimeTypePatterns; } /** * Uses a response wrapper to evaluate the mime type set and if it matches one of the configured * mime types applies URL translation from internal URL's to proxified ones. * <p> * When a matching mime type is found, the full response is cached during * <code>chain.doFilter</code>, and the content is assumed to be textual in the * <code>response.getCharacterEncoding()</code> charset. If the mime type does not match any of * the configured ones no translation nor response cacheing is performed. * <p> * </p> * The URL translation is a two-step process, done line by line from the cached content and * written to the actual response output stream. It first translates the * <code>protocol://host:port</code> section of URL's and then replaces the servlet context from * the server URL by the proxy base URL context. This accounts for absolute urls as well as * relative, root based, urls as used in javascript code and css. </p> */ public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException { LOGGER.finer("filtering " + ((HttpServletRequest) request).getRequestURL()); if (!filterIsEnabled || !(request instanceof HttpServletRequest)) { chain.doFilter(request, response); return; } final String proxyBaseUrl = geoServer.getProxyBaseUrl(); if (proxyBaseUrl == null || "".equals(proxyBaseUrl)) { chain.doFilter(request, response); return; } final CacheingResponseWrapper wrapper = new CacheingResponseWrapper( (HttpServletResponse) response, mimeTypePatterns); chain.doFilter(request, wrapper); wrapper.flushBuffer(); if (wrapper.isCacheing()) { BufferedReader reader; { byte[] cachedContent = wrapper.getCachedContent(); String cs = wrapper.getCharacterEncoding(); reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream( cachedContent), cs)); } PrintWriter writer = response.getWriter(); // the request base url (eg, http://localhost:8080/) final String serverBase; // the proxy base url (eg, http://myproxyserver/) final String proxyBase; // the request context (eg, /geoserver/) final String context; // the proxy context (eg, /tools/geoserver/) final String proxyContext; final String baseUrl; { String _baseUrl = RequestUtils.baseURL((HttpServletRequest) request); if (_baseUrl.endsWith("/")) { _baseUrl = _baseUrl.substring(0, _baseUrl.length() - 1); } baseUrl = _baseUrl; final URL base = new URL(baseUrl); final URL proxy = new URL(proxyBaseUrl); serverBase = getServerBase(base); proxyBase = getServerBase(proxy); context = getContext(base); proxyContext = getContext(proxy); } String line; String translatedLine; LOGGER.finer("translating " + ((HttpServletRequest) request).getRequestURI()); while ((line = reader.readLine()) != null) { // ugh, we need to revert any already translated URL, like in the case // of the server config form where the proxyBaseUrl is set. Otherwise // it could be mangled if (line.indexOf(proxyBaseUrl) != -1) { translatedLine = line.replaceAll(proxyBaseUrl, baseUrl); } else { translatedLine = line; } // now apply the translation from servlet url to proxy url translatedLine = translatedLine.replaceAll(serverBase, proxyBase); translatedLine = translatedLine.replaceAll(context, proxyContext); if (LOGGER.isLoggable(Level.FINE)) { if (!line.equals(translatedLine)) { LOGGER.finest("translated '" + line + "'"); LOGGER.finest(" as '" + translatedLine + "'"); } } writer.println(translatedLine); } writer.flush(); } } private String getContext(URL url) { String context = url.getPath(); return context.endsWith("/") ? context : context + "/"; } private String getServerBase(URL url) { StringBuffer sb = new StringBuffer(); sb.append(url.getProtocol()).append("://"); sb.append(url.getHost()); if (url.getPort() != -1) { sb.append(":").append(url.getPort()); } sb.append("/"); return sb.toString(); } public void destroy() { } /** * A servlet response wrapper that caches the content if its mime type matches one of the * provided patterns. * <p> * Whether to cache the content or not has to be decided when {@link #setContentType(String)} is * called, doing the pattern matching with the provided set of regular expression patterns. So * after using this response wrapper, {@link #isCacheing()} indicates whether content cache was * done, and if so, the cached content is accessed through {@link #getCachedContent()}. * </p> * * @author Gabriel Roldan (TOPP) * @version $Id$ * @since 2.5.x * @source $URL: * https://svn.codehaus.org/geoserver/trunk/geoserver/web/src/main/java/org/geoserver * /filters/ReverseProxyFilter.java $ */ private static class CacheingResponseWrapper extends HttpServletResponseWrapper { private Set<Pattern> cacheingMimeTypes; private boolean cacheContent; private DeferredCacheingOutputStream outputStream; private PrintWriter writer; /** * @param response * the wrapped response * @param cacheingMimeTypes * the patterns to do mime type matching with to decide whether to cache content * or not */ public CacheingResponseWrapper(final HttpServletResponse response, Set<Pattern> cacheingMimeTypes) { super(response); this.cacheingMimeTypes = cacheingMimeTypes; // we can't know until setContentType is called this.cacheContent = false; } /** * @return whether content cacheing has been accomplished or not after the response was * used. */ public boolean isCacheing() { return cacheContent; } /** * @return the cached contend, as long as <code>isCacheing() == true</code> */ public byte[] getCachedContent() { return outputStream.getCachedContent(); } /** * Among setting the response content type, determines whether the response content should * be cached or not, depending on the <code>mimeType</code> matching one of the patterns or * not. */ @Override public void setContentType(final String mimeType) { Pattern p; for (Iterator<Pattern> it = cacheingMimeTypes.iterator(); it.hasNext();) { p = it.next(); Matcher matcher = p.matcher(mimeType); if (matcher.matches()) { cacheContent = true; break; } } super.setContentType(mimeType); } @Override public void flushBuffer() throws IOException { if (cacheContent) { if (writer != null) { writer.flush(); } if (outputStream != null) { outputStream.flush(); } } else { super.flushBuffer(); } } /** * Waits until the first write operation to decide whether to cache the contents or not. * This way it tolerates calls to {@link ServletResponse#getOutputStream()} before calling * {@link ServletResponse#setContentType(String)}. * * @author Gabriel Roldan */ private class DeferredCacheingOutputStream extends ServletOutputStream { /** * non null iif {@link CacheingResponseWrapper#isCacheing()} == false */ private ServletOutputStream actualStream; /** * non null iif {@link CacheingResponseWrapper#isCacheing()} == true */ private ByteArrayOutputStream cache; @Override public void write(int b) throws IOException { if (isCacheing()) { if (cache == null) { cache = new ByteArrayOutputStream(); } cache.write(b); } else { if (actualStream == null) { actualStream = getOutputStreamInternal(); } actualStream.write(b); } } public byte[] getCachedContent() { if (cache == null) { // the request produced no content return new byte[0]; } return cache.toByteArray(); } @Override public void flush() throws IOException { if (actualStream != null) { actualStream.flush(); } } } @Override public ServletOutputStream getOutputStream() throws IOException { if (outputStream == null) { outputStream = new DeferredCacheingOutputStream(); } return outputStream; } private ServletOutputStream getOutputStreamInternal() throws IOException { return super.getOutputStream(); } /** * The default behavior of this method is to return getWriter() on the wrapped response * object. */ @Override public PrintWriter getWriter() throws IOException { if (writer == null) { if (cacheContent) { String charset = super.getCharacterEncoding(); writer = new PrintWriter(new OutputStreamWriter(getOutputStream(), charset)); } else { writer = super.getWriter(); } } return writer; } } }