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}