001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.wicket.http2.markup.head; 018 019import java.io.IOException; 020import java.net.URL; 021import java.time.Instant; 022import java.time.LocalDateTime; 023import java.time.ZoneOffset; 024import java.time.format.DateTimeFormatter; 025import java.time.format.DateTimeParseException; 026import java.util.List; 027import java.util.Locale; 028import java.util.Objects; 029import java.util.Set; 030import java.util.TreeSet; 031import java.util.concurrent.ConcurrentHashMap; 032 033import javax.servlet.http.HttpServletRequest; 034import org.apache.wicket.Application; 035import org.apache.wicket.Page; 036import org.apache.wicket.WicketRuntimeException; 037import org.apache.wicket.http2.Http2Settings; 038import org.apache.wicket.markup.head.HeaderItem; 039import org.apache.wicket.markup.html.WebPage; 040import org.apache.wicket.protocol.http.WebApplication; 041import org.apache.wicket.request.IRequestHandler; 042import org.apache.wicket.request.Request; 043import org.apache.wicket.request.Response; 044import org.apache.wicket.request.Url; 045import org.apache.wicket.request.cycle.RequestCycle; 046import org.apache.wicket.request.http.WebRequest; 047import org.apache.wicket.request.http.WebResponse; 048import org.apache.wicket.request.mapper.parameter.PageParameters; 049import org.apache.wicket.request.mapper.parameter.PageParametersEncoder; 050import org.apache.wicket.request.resource.ResourceReference; 051 052/** 053 * A push header item to be used in the http/2 context and to reduce the latency of the web 054 * application. Follow these steps for your page:<br> 055 * <br> 056 * - Override the setHeaders method and don't call super.setHeaders to disable caching<br> 057 * - Get the page request / response and store them as transient fields that are given into the 058 * PushHeaderItem<br> 059 * - Ensure a valid https connection (not self signed), because otherwise no caching information are 060 * accepted from Chrome or other browsers 061 * 062 * @author Tobias Soloschenko 063 * 064 */ 065public class PushHeaderItem extends HeaderItem 066{ 067 private static final long serialVersionUID = 1L; 068 069 /** 070 * The header date formats for if-modified-since / last-modified 071 */ 072 private static final DateTimeFormatter headerDateFormat_RFC1123 = DateTimeFormatter 073 .ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz") 074 .withLocale(java.util.Locale.ENGLISH) 075 .withZone(ZoneOffset.UTC); // Sun, 06 Nov 1994 08:49:37 GMT ; RFC 822, updated by RFC 1123 076 077 private static final DateTimeFormatter headerDateFormat_RFC1036 = DateTimeFormatter 078 .ofPattern("EEEE, dd-MMM-yy HH:mm:ss zzz") 079 .withLocale(java.util.Locale.ENGLISH) 080 .withZone(ZoneOffset.UTC); // Sunday, 06-Nov-94 08:49:37 GMT ; RFC 850, obs. by RFC 1036 081 082 private static final DateTimeFormatter headerDateFormat_ASCTIME = DateTimeFormatter 083 .ofPattern("EEE MMM d HH:mm:ss yyyy") 084 .withLocale(java.util.Locale.ENGLISH) 085 .withZone(ZoneOffset.UTC); // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format 086 087 /** 088 * The http2 protocol string 089 */ 090 public static final String HTTP2_PROTOCOL = "http/2"; 091 092 /** 093 * The token suffix to be used in this header item 094 */ 095 private static final String TOKEN_SUFFIX = HTTP2_PROTOCOL + "_pushed"; 096 097 /** 098 * The URLs of resources to be pushed to the client 099 */ 100 private Set<PushItem> pushItems = ConcurrentHashMap.newKeySet(); 101 /** 102 * The web response of the page to apply the caching information to 103 */ 104 private WebResponse pageWebResponse; 105 106 /** 107 * The web request of the page to get the caching information from 108 */ 109 private WebRequest pageWebRequest; 110 111 /** 112 * The page to get the modification time of 113 */ 114 private Page page; 115 116 /** 117 * Creates a push header item based on the given page and the corresponding page request / page 118 * response. To get the request and response 119 * 120 * 121 * @param page 122 * the page this header item is applied to 123 * @param pageRequest 124 * the page request this header item is applied to 125 * @param pageResponse 126 * the page response this header item is applied to 127 */ 128 public PushHeaderItem(Page page, Request pageRequest, Response pageResponse) 129 { 130 if (page == null || !(page instanceof WebPage) || pageResponse == null || 131 !(pageResponse instanceof WebResponse)) 132 { 133 throw new WicketRuntimeException( 134 "Please hand over the web page, the web request and the web response to the push header item like \"new PushHeaderItem(this, yourWebPageRequest, yourWebPageResponse)\" - " + 135 "The webPageResponse / webPageRequest can be obtained via \"getRequestCycle().getRequest()\" / \"getRequestCycle().getResponse()\" and placed into the page as fields " + 136 "\"private transient Response webPageResponse;\" / \"private transient Request webPageRequest;\""); 137 } 138 this.pageWebRequest = (WebRequest)pageRequest; 139 this.pageWebResponse = (WebResponse)pageResponse; 140 this.page = page; 141 } 142 143 /** 144 * Uses the URLs that has already been pushed to the client to ensure not to push them again 145 */ 146 @Override 147 public Iterable<?> getRenderTokens() 148 { 149 Set<String> tokens = new TreeSet<String>(); 150 for (PushItem pushItem : pushItems) 151 { 152 tokens.add(pushItem.getUrl() + TOKEN_SUFFIX); 153 } 154 return tokens; 155 } 156 157 /** 158 * Gets the time the page of this header item has been modified. The default implementation is 159 * to get the last modification date of the HTML file of the corresponding page, but it can be 160 * overridden to apply a custom behavior. For example place in a properties-file into the class 161 * path which contains the compile time. <br> 162 * Example: <code> 163 * <pre> 164 * protected Time getPageModificationTime(){ 165 * Time time = getPageModificationTime(); 166 * // read properties file with build time and place it into a second time variable 167 * return time.before(buildTime) ? buildTime : time; 168 * } 169 * </pre> 170 * </code> 171 * 172 * @return the time the page of this header item has been modified 173 */ 174 protected Instant getPageModificationTime() 175 { 176 URL resource = page.getClass().getResource(page.getClass().getSimpleName() + ".html"); 177 if (resource == null) 178 { 179 throw new WicketRuntimeException( 180 "The markup to the page couldn't be found: " + page.getClass().getName()); 181 } 182 try 183 { 184 return Instant.ofEpochMilli(resource.openConnection().getLastModified()); 185 } 186 catch (IOException e) 187 { 188 throw new WicketRuntimeException( 189 "The time couln't be determined of the markup file of the page: " + 190 page.getClass().getName(), 191 e); 192 } 193 } 194 195 /** 196 * Applies the cache header item to the response 197 */ 198 protected void applyPageCacheHeader() 199 { 200 // check modification of page html 201 Instant pageModificationTime = getPageModificationTime(); 202 // The date of the page is now 203 pageWebResponse.setDateHeader("Date", Instant.now()); 204 // Set the modification time so that the browser sends a "If-Modified-Since" header which 205 // can be compared 206 pageWebResponse.setLastModifiedTime(pageModificationTime); 207 // Make the resource stale so that it gets revalidated even if a cache entry is set 208 // (see http://stackoverflow.com/questions/11357430/http-expires-header-values-0-and-1) 209 pageWebResponse.setHeader("Expires", "-1"); 210 // Set a cache but set it to max-age=0 / must-revalidate so that the request to the page is 211 // done 212 pageWebResponse.setHeader("Cache-Control", 213 "max-age=0, public, must-revalidate, proxy-revalidate"); 214 } 215 216 /** 217 * Pushes the previously created URLs to the client 218 */ 219 @Override 220 public void render(Response response) 221 { 222 // applies the caching header to the actual page request 223 applyPageCacheHeader(); 224 225 HttpServletRequest request = getContainerRequest(RequestCycle.get().getRequest()); 226 // Check if the protocol is http/2 or http/2.0 to only push the resources in this case 227 if (isHttp2(request)) 228 { 229 230 Instant pageModificationTime = getPageModificationTime(); 231 String ifModifiedSinceHeader = pageWebRequest.getHeader("If-Modified-Since"); 232 233 // Check if the if-modified-since header is set - if not push all resources 234 if (ifModifiedSinceHeader != null) 235 { 236 237 // Try to parse RFC1123 238 Instant ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader( 239 ifModifiedSinceHeader, headerDateFormat_RFC1123); 240 241 // Try to parse ASCTIME 242 if (ifModifiedSinceFromRequestTime == null) 243 { 244 ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader( 245 ifModifiedSinceHeader, headerDateFormat_ASCTIME); 246 } 247 248 // Try to parse RFC1036 - because it is obsolete due to RFC 1036 check this last. 249 if (ifModifiedSinceFromRequestTime == null) 250 { 251 ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader( 252 ifModifiedSinceHeader, headerDateFormat_RFC1036); 253 } 254 255 // if the modified since header is before the page modification time or if it can't 256 // be parsed push it. 257 if (ifModifiedSinceFromRequestTime == null || 258 ifModifiedSinceFromRequestTime.isBefore(pageModificationTime)) 259 { 260 // Some browsers like IE 9-11 or Chrome 39 that does not send right headers 261 // receive the resource via push all the time 262 push(request); 263 } 264 } 265 else 266 { 267 // Push the resources if the "if-modified-since" is not available 268 push(request); 269 } 270 } 271 } 272 273 /** 274 * Parses the given if modified since header with the date time formatter 275 * 276 * @param ifModifiedSinceHeader 277 * the if modified since header string 278 * @param dateTimeFormatter 279 * the formatter to parse the header string with 280 * @return the time or null 281 */ 282 private Instant parseIfModifiedSinceHeader(String ifModifiedSinceHeader, 283 DateTimeFormatter dateTimeFormatter) 284 { 285 try 286 { 287 return LocalDateTime 288 .parse(ifModifiedSinceHeader, dateTimeFormatter) 289 .toInstant(ZoneOffset.UTC); 290 } 291 catch (DateTimeParseException e) 292 { 293 // NOOP 294 } 295 return null; 296 } 297 298 /** 299 * Pushed all URLs of this header item to the client 300 * 301 * @param request 302 * the request to push the URLs to 303 */ 304 protected void push(HttpServletRequest request) 305 { 306 // Receives the vendor specific push builder 307 Http2Settings http2Settings = Http2Settings.Holder.get(Application.get()); 308 PushBuilder pushBuilder = http2Settings.getPushBuilder(); 309 pushBuilder.push(request, pushItems.toArray(new PushItem[pushItems.size()])); 310 } 311 312 /** 313 * Creates a URL and pushes the resource to the client - this is only supported if http2 is 314 * enabled 315 * 316 * @param pushItems 317 * a list of items to be pushed to the client 318 * @return the current push header item 319 */ 320 @SuppressWarnings("unchecked") 321 public PushHeaderItem push(List<PushItem> pushItems) 322 { 323 RequestCycle requestCycle = RequestCycle.get(); 324 if (isHttp2(getContainerRequest(requestCycle.getRequest()))) 325 for (PushItem pushItem : pushItems) 326 { 327 Object object = pushItem.getObject(); 328 PageParameters parameters = pushItem.getPageParameters(); 329 330 if (object == null) 331 { 332 throw new WicketRuntimeException( 333 "Please provide an object to the items to be pushed, so that the url can be created for the given resource."); 334 } 335 336 CharSequence url = null; 337 if (object instanceof ResourceReference) 338 { 339 url = requestCycle.urlFor((ResourceReference)object, parameters); 340 } 341 else if (Page.class.isAssignableFrom(object.getClass())) 342 { 343 url = requestCycle.urlFor((Class<? extends Page>)object, parameters); 344 } 345 else if (object instanceof IRequestHandler) 346 { 347 url = requestCycle.urlFor((IRequestHandler)object); 348 } 349 else if (pushItem.getUrl() != null) 350 { 351 url = pushItem.getUrl(); 352 } 353 else 354 { 355 Url encoded = new PageParametersEncoder().encodePageParameters(parameters); 356 String queryString = encoded.getQueryString(); 357 url = object.toString() + (queryString != null ? "?" + queryString : ""); 358 } 359 360 if (url.toString().equals(".")) 361 { 362 url = "/"; 363 } 364 else if (url.toString().startsWith(".")) 365 { 366 url = url.toString().substring(1); 367 } 368 369 // The context path and the filter have to be applied to the URL, because otherwise 370 // the resource is not pushed correctly 371 StringBuilder partialUrl = new StringBuilder(); 372 String contextPath = WebApplication.get().getServletContext().getContextPath(); 373 partialUrl.append(contextPath); 374 if (!"/".equals(contextPath)) 375 { 376 partialUrl.append('/'); 377 } 378 String filterPath = WebApplication.get().getWicketFilter().getFilterPath(); 379 if ("/".equals(filterPath)) 380 { 381 filterPath = ""; 382 } 383 else if (filterPath.endsWith("/")) 384 { 385 filterPath = filterPath.substring(0, filterPath.length() - 1); 386 } 387 partialUrl.append(filterPath); 388 partialUrl.append(url.toString()); 389 390 // Set the url the resource is going to be pushed with 391 pushItem.setUrl(partialUrl.toString()); 392 393 // Apply the push item to be used during the push process 394 this.pushItems.add(pushItem); 395 } 396 return this; 397 } 398 399 /** 400 * Gets the container request 401 * 402 * @param request 403 * the wicket request to get the container request from 404 * @return the container request 405 */ 406 public HttpServletRequest getContainerRequest(Request request) 407 { 408 409 return checkHttpServletRequest(request); 410 } 411 412 /** 413 * Checks if the given request is a http/2 request 414 * 415 * @param request 416 * the request to check if it is a http/2 request 417 * @return if the request is a http/2 request 418 */ 419 public boolean isHttp2(HttpServletRequest request) 420 { 421 // detects http/2 and http/2.0 422 return request.getProtocol().toLowerCase(Locale.ROOT).contains(HTTP2_PROTOCOL); 423 } 424 425 /** 426 * Checks if the container request from the given request is instance of 427 * {@link HttpServletRequest} if not the API of the PushHeaderItem can't be used and a 428 * {@link WicketRuntimeException} is thrown. 429 * 430 * @param request 431 * the request to get the container request from. The container request is checked if 432 * it is instance of {@link HttpServletRequest} 433 * @return the container request get from the given request casted to {@link HttpServletRequest} 434 * @throws WicketRuntimeException - if the container request is not a {@link HttpServletRequest} 435 */ 436 public HttpServletRequest checkHttpServletRequest(Request request) 437 { 438 Object assumedHttpServletRequest = request.getContainerRequest(); 439 if (!(assumedHttpServletRequest instanceof HttpServletRequest)) 440 { 441 throw new WicketRuntimeException( 442 "The request is not a HttpServletRequest - the usage of PushHeaderItem is not support in the current environment: " + 443 request.getClass().getName()); 444 } 445 return (HttpServletRequest)assumedHttpServletRequest; 446 } 447 448 @Override 449 public boolean equals(Object o) 450 { 451 if (this == o) 452 return true; 453 if (o == null || getClass() != o.getClass()) 454 return false; 455 PushHeaderItem that = (PushHeaderItem)o; 456 return Objects.equals(pushItems, that.pushItems) && 457 Objects.equals(pageWebResponse, that.pageWebResponse) && 458 Objects.equals(pageWebRequest, that.pageWebRequest) && Objects.equals(page, that.page); 459 } 460 461 @Override 462 public int hashCode() 463 { 464 return Objects.hash(pushItems, pageWebResponse, pageWebRequest, page); 465 } 466}