blob: aabfb2705ed0715d56289bb7cbb2909c46510b82 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.wicket.http2.markup.head;
import java.io.IOException;
import java.net.URL;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.wicket.Application;
import org.apache.wicket.Page;
import org.apache.wicket.WicketRuntimeException;
import org.apache.wicket.http2.Http2Settings;
import org.apache.wicket.markup.head.HeaderItem;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.request.IRequestHandler;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.Response;
import org.apache.wicket.request.Url;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.http.WebRequest;
import org.apache.wicket.request.http.WebResponse;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.apache.wicket.request.mapper.parameter.PageParametersEncoder;
import org.apache.wicket.request.resource.ResourceReference;
/**
* A push header item to be used in the http/2 context and to reduce the latency of the web
* application. Follow these steps for your page:<br>
* <br>
* - Override the setHeaders method and don't call super.setHeaders to disable caching<br>
* - Get the page request / response and store them as transient fields that are given into the
* PushHeaderItem<br>
* - Ensure a valid https connection (not self signed), because otherwise no caching information are
* accepted from Chrome or other browsers
*
* @author Tobias Soloschenko
*
*/
public class PushHeaderItem extends HeaderItem
{
private static final long serialVersionUID = 1L;
/**
* The header date formats for if-modified-since / last-modified
*/
private static final DateTimeFormatter headerDateFormat_RFC1123 = DateTimeFormatter
.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz")
.withLocale(java.util.Locale.ENGLISH)
.withZone(ZoneOffset.UTC); // Sun, 06 Nov 1994 08:49:37 GMT ; RFC 822, updated by RFC 1123
private static final DateTimeFormatter headerDateFormat_RFC1036 = DateTimeFormatter
.ofPattern("EEEE, dd-MMM-yy HH:mm:ss zzz")
.withLocale(java.util.Locale.ENGLISH)
.withZone(ZoneOffset.UTC); // Sunday, 06-Nov-94 08:49:37 GMT ; RFC 850, obs. by RFC 1036
private static final DateTimeFormatter headerDateFormat_ASCTIME = DateTimeFormatter
.ofPattern("EEE MMM d HH:mm:ss yyyy")
.withLocale(java.util.Locale.ENGLISH)
.withZone(ZoneOffset.UTC); // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format
/**
* The http2 protocol string
*/
public static final String HTTP2_PROTOCOL = "http/2";
/**
* The token suffix to be used in this header item
*/
private static final String TOKEN_SUFFIX = HTTP2_PROTOCOL + "_pushed";
/**
* The URLs of resources to be pushed to the client
*/
private Set<PushItem> pushItems = ConcurrentHashMap.newKeySet();
/**
* The web response of the page to apply the caching information to
*/
private WebResponse pageWebResponse;
/**
* The web request of the page to get the caching information from
*/
private WebRequest pageWebRequest;
/**
* The page to get the modification time of
*/
private Page page;
/**
* Creates a push header item based on the given page and the corresponding page request / page
* response. To get the request and response
*
*
* @param page
* the page this header item is applied to
* @param pageRequest
* the page request this header item is applied to
* @param pageResponse
* the page response this header item is applied to
*/
public PushHeaderItem(Page page, Request pageRequest, Response pageResponse)
{
if (page == null || !(page instanceof WebPage) || pageResponse == null ||
!(pageResponse instanceof WebResponse))
{
throw new WicketRuntimeException(
"Please hand over the web page, the web request and the web response to the push header item like \"new PushHeaderItem(this, yourWebPageRequest, yourWebPageResponse)\" - " +
"The webPageResponse / webPageRequest can be obtained via \"getRequestCycle().getRequest()\" / \"getRequestCycle().getResponse()\" and placed into the page as fields " +
"\"private transient Response webPageResponse;\" / \"private transient Request webPageRequest;\"");
}
this.pageWebRequest = (WebRequest)pageRequest;
this.pageWebResponse = (WebResponse)pageResponse;
this.page = page;
}
/**
* Uses the URLs that has already been pushed to the client to ensure not to push them again
*/
@Override
public Iterable<?> getRenderTokens()
{
Set<String> tokens = new TreeSet<String>();
for (PushItem pushItem : pushItems)
{
tokens.add(pushItem.getUrl() + TOKEN_SUFFIX);
}
return tokens;
}
/**
* Gets the time the page of this header item has been modified. The default implementation is
* to get the last modification date of the HTML file of the corresponding page, but it can be
* overridden to apply a custom behavior. For example place in a properties-file into the class
* path which contains the compile time. <br>
* Example: <code>
* <pre>
* protected Time getPageModificationTime(){
* Time time = getPageModificationTime();
* // read properties file with build time and place it into a second time variable
* return time.before(buildTime) ? buildTime : time;
* }
* </pre>
* </code>
*
* @return the time the page of this header item has been modified
*/
protected Instant getPageModificationTime()
{
URL resource = page.getClass().getResource(page.getClass().getSimpleName() + ".html");
if (resource == null)
{
throw new WicketRuntimeException(
"The markup to the page couldn't be found: " + page.getClass().getName());
}
try
{
return Instant.ofEpochMilli(resource.openConnection().getLastModified());
}
catch (IOException e)
{
throw new WicketRuntimeException(
"The time couln't be determined of the markup file of the page: " +
page.getClass().getName(),
e);
}
}
/**
* Applies the cache header item to the response
*/
protected void applyPageCacheHeader()
{
// check modification of page html
Instant pageModificationTime = getPageModificationTime();
// The date of the page is now
pageWebResponse.setDateHeader("Date", Instant.now());
// Set the modification time so that the browser sends a "If-Modified-Since" header which
// can be compared
pageWebResponse.setLastModifiedTime(pageModificationTime);
// Make the resource stale so that it gets revalidated even if a cache entry is set
// (see http://stackoverflow.com/questions/11357430/http-expires-header-values-0-and-1)
pageWebResponse.setHeader("Expires", "-1");
// Set a cache but set it to max-age=0 / must-revalidate so that the request to the page is
// done
pageWebResponse.setHeader("Cache-Control",
"max-age=0, public, must-revalidate, proxy-revalidate");
}
/**
* Pushes the previously created URLs to the client
*/
@Override
public void render(Response response)
{
// applies the caching header to the actual page request
applyPageCacheHeader();
HttpServletRequest request = getContainerRequest(RequestCycle.get().getRequest());
// Check if the protocol is http/2 or http/2.0 to only push the resources in this case
if (isHttp2(request))
{
Instant pageModificationTime = getPageModificationTime();
String ifModifiedSinceHeader = pageWebRequest.getHeader("If-Modified-Since");
// Check if the if-modified-since header is set - if not push all resources
if (ifModifiedSinceHeader != null)
{
// Try to parse RFC1123
Instant ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader(
ifModifiedSinceHeader, headerDateFormat_RFC1123);
// Try to parse ASCTIME
if (ifModifiedSinceFromRequestTime == null)
{
ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader(
ifModifiedSinceHeader, headerDateFormat_ASCTIME);
}
// Try to parse RFC1036 - because it is obsolete due to RFC 1036 check this last.
if (ifModifiedSinceFromRequestTime == null)
{
ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader(
ifModifiedSinceHeader, headerDateFormat_RFC1036);
}
// if the modified since header is before the page modification time or if it can't
// be parsed push it.
if (ifModifiedSinceFromRequestTime == null ||
ifModifiedSinceFromRequestTime.isBefore(pageModificationTime))
{
// Some browsers like IE 9-11 or Chrome 39 that does not send right headers
// receive the resource via push all the time
push(request);
}
}
else
{
// Push the resources if the "if-modified-since" is not available
push(request);
}
}
}
/**
* Parses the given if modified since header with the date time formatter
*
* @param ifModifiedSinceHeader
* the if modified since header string
* @param dateTimeFormatter
* the formatter to parse the header string with
* @return the time or null
*/
private Instant parseIfModifiedSinceHeader(String ifModifiedSinceHeader,
DateTimeFormatter dateTimeFormatter)
{
try
{
return LocalDateTime
.parse(ifModifiedSinceHeader, dateTimeFormatter)
.toInstant(ZoneOffset.UTC);
}
catch (DateTimeParseException e)
{
// NOOP
}
return null;
}
/**
* Pushed all URLs of this header item to the client
*
* @param request
* the request to push the URLs to
*/
protected void push(HttpServletRequest request)
{
// Receives the vendor specific push builder
Http2Settings http2Settings = Http2Settings.Holder.get(Application.get());
PushBuilder pushBuilder = http2Settings.getPushBuilder();
pushBuilder.push(request, pushItems.toArray(new PushItem[pushItems.size()]));
}
/**
* Creates a URL and pushes the resource to the client - this is only supported if http2 is
* enabled
*
* @param pushItems
* a list of items to be pushed to the client
* @return the current push header item
*/
@SuppressWarnings("unchecked")
public PushHeaderItem push(List<PushItem> pushItems)
{
RequestCycle requestCycle = RequestCycle.get();
if (isHttp2(getContainerRequest(requestCycle.getRequest())))
for (PushItem pushItem : pushItems)
{
Object object = pushItem.getObject();
PageParameters parameters = pushItem.getPageParameters();
if (object == null)
{
throw new WicketRuntimeException(
"Please provide an object to the items to be pushed, so that the url can be created for the given resource.");
}
CharSequence url = null;
if (object instanceof ResourceReference)
{
url = requestCycle.urlFor((ResourceReference)object, parameters);
}
else if (Page.class.isAssignableFrom(object.getClass()))
{
url = requestCycle.urlFor((Class<? extends Page>)object, parameters);
}
else if (object instanceof IRequestHandler)
{
url = requestCycle.urlFor((IRequestHandler)object);
}
else if (pushItem.getUrl() != null)
{
url = pushItem.getUrl();
}
else
{
Url encoded = new PageParametersEncoder().encodePageParameters(parameters);
String queryString = encoded.getQueryString();
url = object.toString() + (queryString != null ? "?" + queryString : "");
}
if (url.toString().equals("."))
{
url = "/";
}
else if (url.toString().startsWith("."))
{
url = url.toString().substring(1);
}
// The context path and the filter have to be applied to the URL, because otherwise
// the resource is not pushed correctly
StringBuilder partialUrl = new StringBuilder();
String contextPath = WebApplication.get().getServletContext().getContextPath();
partialUrl.append(contextPath);
if (!"/".equals(contextPath))
{
partialUrl.append('/');
}
String filterPath = WebApplication.get().getWicketFilter().getFilterPath();
if ("/".equals(filterPath))
{
filterPath = "";
}
else if (filterPath.endsWith("/"))
{
filterPath = filterPath.substring(0, filterPath.length() - 1);
}
partialUrl.append(filterPath);
partialUrl.append(url.toString());
// Set the url the resource is going to be pushed with
pushItem.setUrl(partialUrl.toString());
// Apply the push item to be used during the push process
this.pushItems.add(pushItem);
}
return this;
}
/**
* Gets the container request
*
* @param request
* the wicket request to get the container request from
* @return the container request
*/
public HttpServletRequest getContainerRequest(Request request)
{
return checkHttpServletRequest(request);
}
/**
* Checks if the given request is a http/2 request
*
* @param request
* the request to check if it is a http/2 request
* @return if the request is a http/2 request
*/
public boolean isHttp2(HttpServletRequest request)
{
// detects http/2 and http/2.0
return request.getProtocol().toLowerCase(Locale.ROOT).contains(HTTP2_PROTOCOL);
}
/**
* Checks if the container request from the given request is instance of
* {@link HttpServletRequest} if not the API of the PushHeaderItem can't be used and a
* {@link WicketRuntimeException} is thrown.
*
* @param request
* the request to get the container request from. The container request is checked if
* it is instance of {@link HttpServletRequest}
* @return the container request get from the given request casted to {@link HttpServletRequest}
* @throw {@link WicketRuntimeException} if the container request is not a
* {@link HttpServletRequest}
*/
public HttpServletRequest checkHttpServletRequest(Request request)
{
Object assumedHttpServletRequest = request.getContainerRequest();
if (!(assumedHttpServletRequest instanceof HttpServletRequest))
{
throw new WicketRuntimeException(
"The request is not a HttpServletRequest - the usage of PushHeaderItem is not support in the current environment: " +
request.getClass().getName());
}
return (HttpServletRequest)assumedHttpServletRequest;
}
@Override
public boolean equals(Object o)
{
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
PushHeaderItem that = (PushHeaderItem)o;
return Objects.equals(pushItems, that.pushItems) &&
Objects.equals(pageWebResponse, that.pageWebResponse) &&
Objects.equals(pageWebRequest, that.pageWebRequest) && Objects.equals(page, that.page);
}
@Override
public int hashCode()
{
return Objects.hash(pushItems, pageWebResponse, pageWebRequest, page);
}
}