+package de.juplo.thymeproxy;
+
+
+import java.io.IOException;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.Header;
+import org.apache.http.HeaderElement;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.CacheControl;
+import org.springframework.util.Assert;
+import org.springframework.util.StreamUtils;
+import org.springframework.util.StringUtils;
+import org.springframework.web.HttpRequestHandler;
+import org.springframework.web.servlet.HandlerMapping;
+import org.springframework.web.servlet.support.WebContentGenerator;
+import org.springframework.web.util.UrlPathHelper;
+
+
+
+/**
+ *
+ * @author kai
+ */
+public class ProxyHttpRequestHandler
+ extends
+ WebContentGenerator
+ implements
+ HttpRequestHandler
+{
+ private final static Logger LOG =
+ LoggerFactory.getLogger(ProxyHttpRequestHandler.class);
+
+
+ public final static Long DEFAULT_TTL = 300000l; /** 5 minutes */
+
+
+ private UrlPathHelper urlPathHelper = new UrlPathHelper();
+
+ private CloseableHttpClient client;
+ private String origin;
+ private Long ttl;
+
+ private final Clock clock;
+
+
+ public ProxyHttpRequestHandler()
+ {
+ clock = Clock.systemDefaultZone();
+ }
+
+ public ProxyHttpRequestHandler(Clock clock)
+ {
+ this.clock = clock;
+ }
+
+
+ @Override
+ public void handleRequest(
+ HttpServletRequest request,
+ HttpServletResponse response
+ )
+ throws
+ ServletException,
+ IOException
+ {
+ String path =
+ (String)
+ request.getAttribute(
+ HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
+ );
+
+ if (!StringUtils.hasText(path))
+ path = urlPathHelper.getLookupPathForRequest(request);
+
+ StringBuilder builder = new StringBuilder();
+ builder.append(origin);
+ builder.append(path);
+
+ String query = request.getQueryString();
+ if (query != null)
+ {
+ builder.append('?');
+ builder.append(query);
+ }
+
+ String resource = builder.toString();
+
+ try
+ {
+ HttpGet p_request = new HttpGet(resource);
+ CloseableHttpResponse p_response = client.execute(p_request);
+
+ setCacheControl(computeCacheControl(p_response));
+ prepareResponse(response);
+
+ Header length = p_response.getLastHeader("Content-Length");
+ if (length != null)
+ response.addHeader(length.getName(), length.getValue());
+
+ int status = p_response.getStatusLine().getStatusCode();
+ switch (status)
+ {
+ case HttpServletResponse.SC_FOUND:
+ case HttpServletResponse.SC_OK:
+ /** OK. Continue as normal... */
+ response.setStatus(status);
+
+ HttpEntity entity = p_response.getEntity();
+
+ StreamUtils.copy(entity.getContent(), response.getOutputStream());
+
+ /** Release the connection */
+ EntityUtils.consume(entity);
+ p_response.close();
+
+ break;
+
+ case HttpServletResponse.SC_NOT_FOUND:
+ case HttpServletResponse.SC_GONE:
+ /** The resource can not be resolved through this origin */
+ response.sendError(status, p_response.getStatusLine().getReasonPhrase());
+ return;
+
+ case HttpServletResponse.SC_MOVED_PERMANENTLY:
+ // TODO
+
+ case HttpServletResponse.SC_SEE_OTHER:
+ // TODO
+
+ case HttpServletResponse.SC_TEMPORARY_REDIRECT:
+ // TODO
+
+ default:
+ LOG.error("{} -- {}", p_response.getStatusLine(), resource);
+ // TODO: throw sensible exceptions, to communicate resolving-errors
+ throw new RuntimeException(p_response.getStatusLine().toString() + " -- " + resource);
+ }
+ }
+ catch (IOException e)
+ {
+ LOG.error("unexpected error while resolving {}: {}", resource, e.getMessage());
+ // TODO: throw sensible exceptions, to communicate resolving-errors
+ throw new RuntimeException(e);
+ }
+ }
+
+ public CacheControl computeCacheControl(HttpResponse response)
+ {
+ if (ttl == null)
+ /** Caching is disabled! */
+ return CacheControl.noStore();
+
+
+ boolean has_cache_control = false;
+ boolean is_public = false;
+ boolean is_private = false;
+ boolean no_cache = false;
+ boolean no_store = false;
+ boolean no_transform = false;
+ boolean must_revalidate = false;
+ boolean proxy_revalidate = false;
+ Long max_age = null;
+ Long s_maxage = null;
+
+ for (Header header : response.getHeaders("Cache-Control"))
+ {
+ has_cache_control = true;
+ for (HeaderElement element : header.getElements())
+ {
+ switch (element.getName())
+ {
+ case "public":
+ is_public = true;
+ break;
+ case "private":
+ is_private = true;
+ break;
+ case "no-cache":
+ no_cache = true;
+ break;
+ case "no-store":
+ no_store = true;
+ break;
+ case "no_transform":
+ no_transform = true;
+ break;
+ case "must-revalidate":
+ must_revalidate = true;
+ break;
+ case "proxy-revalidate":
+ proxy_revalidate = true;
+ case "max-age":
+ try
+ {
+ max_age = Long.parseLong(element.getValue());
+ }
+ catch (NumberFormatException e)
+ {
+ LOG.warn(
+ "invalid header \"Cache-Control: max-age={}\"",
+ element.getValue()
+ );
+ }
+ break;
+ case "s-maxage":
+ try
+ {
+ s_maxage = Long.parseLong(element.getValue());
+ }
+ catch (NumberFormatException e)
+ {
+ LOG.warn(
+ "invalid header \"Cache-Control: s-maxage={}\"",
+ element.getValue()
+ );
+ }
+ break;
+ default:
+ LOG.warn(
+ "invalid header \"Cache-Control: {}{}\"",
+ element.getName(),
+ element.getValue() == null ? "" : "=" + element.getValue()
+ );
+ }
+ }
+ }
+
+ if (!has_cache_control)
+ {
+ Header header = response.getLastHeader("Expires");
+ if (header == null)
+ /** No TTL specified in response-headers: use configured default */
+ max_age = ttl;
+ else
+ try
+ {
+ OffsetDateTime expires
+ = OffsetDateTime.parse(
+ header.getValue(),
+ DateTimeFormatter.RFC_1123_DATE_TIME
+ );
+
+ Duration delta = Duration.between(OffsetDateTime.now(clock), expires);
+ if (delta.isNegative())
+ no_store = true;
+ else
+ max_age = delta.getSeconds();
+ }
+ catch (DateTimeParseException e)
+ {
+ LOG.warn("invalid header \"Expires: {}\"", header.getValue());
+ /**
+ * No TTL specified in response-headers: assume expired
+ * see: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.21
+ */
+ max_age = 0l;
+ }
+ }
+
+ CacheControl cache_control;
+
+ if (no_store)
+ cache_control = CacheControl.noStore();
+ else if (no_cache)
+ cache_control = CacheControl.noCache();
+ else if (max_age != null)
+ cache_control = CacheControl.maxAge(max_age, TimeUnit.SECONDS);
+ else
+ cache_control = CacheControl.empty();
+
+ if (is_private)
+ cache_control.cachePrivate();
+ if (is_public)
+ cache_control.cachePublic();
+ if (no_transform)
+ cache_control.noTransform();
+ if (must_revalidate)
+ cache_control.mustRevalidate();
+ if (proxy_revalidate)
+ cache_control.proxyRevalidate();
+ if (s_maxage != null)
+ cache_control.sMaxAge(s_maxage, TimeUnit.SECONDS);
+
+ return cache_control;
+ }
+
+
+ public ProxyHttpRequestHandler setClient(CloseableHttpClient client)
+ {
+ this.client = client;
+ return this;
+ }
+
+ public ProxyHttpRequestHandler setOrigin(String origin)
+ {
+ this.origin = origin;
+ return this;
+ }
+
+ public ProxyHttpRequestHandler setTtl(Long ttl)
+ {
+ this.ttl = ttl;
+ return this;
+ }
+
+ /**
+ * Set if URL lookup should always use full path within current servlet
+ * context. Else, the path within the current servlet mapping is used
+ * if applicable (i.e. in the case of a ".../*" servlet mapping in web.xml).
+ * Default is "false".
+ *
+ * @see org.springframework.web.util.UrlPathHelper#setAlwaysUseFullPath
+ */
+ public void setAlwaysUseFullPath(boolean alwaysUseFullPath)
+ {
+ this.urlPathHelper.setAlwaysUseFullPath(alwaysUseFullPath);
+ }
+
+ /**
+ * Set if context path and request URI should be URL-decoded.
+ * Both are returned <i>undecoded</i> by the Servlet API,
+ * in contrast to the servlet path.
+ * <p>
+ * Uses either the request encoding or the default encoding according
+ * to the Servlet spec (ISO-8859-1).
+ *
+ * @see org.springframework.web.util.UrlPathHelper#setUrlDecode
+ */
+ public void setUrlDecode(boolean urlDecode)
+ {
+ this.urlPathHelper.setUrlDecode(urlDecode);
+ }
+
+ /**
+ * Set if ";" (semicolon) content should be stripped from the request URI.
+ *
+ * @see
+ * org.springframework.web.util.UrlPathHelper#setRemoveSemicolonContent(boolean)
+ */
+ public void setRemoveSemicolonContent(boolean removeSemicolonContent)
+ {
+ this.urlPathHelper.setRemoveSemicolonContent(removeSemicolonContent);
+ }
+
+ /**
+ * Set the UrlPathHelper to use for the resolution of lookup paths.
+ * <p>
+ * Use this to override the default UrlPathHelper with a custom subclass,
+ * or to share common UrlPathHelper settings across multiple
+ * MethodNameResolvers
+ * and HandlerMappings.
+ *
+ * @see
+ * org.springframework.web.servlet.handler.AbstractUrlHandlerMapping#setUrlPathHelper
+ */
+ public void setUrlPathHelper(UrlPathHelper urlPathHelper)
+ {
+ Assert.notNull(urlPathHelper, "UrlPathHelper must not be null");
+ this.urlPathHelper = urlPathHelper;
+ }
+
+ /**
+ * Return the UrlPathHelper to use for the resolution of lookup paths.
+ */
+ protected UrlPathHelper getUrlPathHelper()
+ {
+ return this.urlPathHelper;
+ }
+}