View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2012 Mort Bay Consulting Pty. Ltd.
4   //  ------------------------------------------------------------------------
5   //  All rights reserved. This program and the accompanying materials
6   //  are made available under the terms of the Eclipse Public License v1.0
7   //  and Apache License v2.0 which accompanies this distribution.
8   //
9   //      The Eclipse Public License is available at
10  //      http://www.eclipse.org/legal/epl-v10.html
11  //
12  //      The Apache License v2.0 is available at
13  //      http://www.opensource.org/licenses/apache2.0.php
14  //
15  //  You may elect to redistribute this code under either of these licenses.
16  //  ========================================================================
17  //
18  
19  
20  package org.eclipse.jetty.spdy.http;
21  
22  import java.util.Arrays;
23  import java.util.Collections;
24  import java.util.HashSet;
25  import java.util.List;
26  import java.util.Set;
27  import java.util.concurrent.ConcurrentHashMap;
28  import java.util.concurrent.ConcurrentMap;
29  import java.util.concurrent.TimeUnit;
30  import java.util.concurrent.atomic.AtomicLong;
31  import java.util.regex.Pattern;
32  
33  import org.eclipse.jetty.spdy.api.Headers;
34  import org.eclipse.jetty.spdy.api.Stream;
35  import org.eclipse.jetty.util.log.Log;
36  import org.eclipse.jetty.util.log.Logger;
37  
38  /**
39   * <p>A SPDY push strategy that auto-populates push metadata based on referrer URLs.</p>
40   * <p>A typical request for a main resource such as <tt>index.html</tt> is immediately
41   * followed by a number of requests for associated resources. Associated resource requests
42   * will have a <tt>Referer</tt> HTTP header that points to <tt>index.html</tt>, which we
43   * use to link the associated resource to the main resource.</p>
44   * <p>However, also following a hyperlink generates a HTTP request with a <tt>Referer</tt>
45   * HTTP header that points to <tt>index.html</tt>; therefore a proper value for {@link #getReferrerPushPeriod()}
46   * has to be set. If the referrerPushPeriod for a main resource has been passed, no more
47   * associated resources will be added for that main resource.</p>
48   * <p>This class distinguishes associated main resources by their URL path suffix and content
49   * type.
50   * CSS stylesheets, images and JavaScript files have recognizable URL path suffixes that
51   * are classified as associated resources. The suffix regexs can be configured by constructor argument</p>
52   * <p>When CSS stylesheets refer to images, the CSS image request will have the CSS
53   * stylesheet as referrer. This implementation will push also the CSS image.</p>
54   * <p>The push metadata built by this implementation is limited by the number of pages
55   * of the application itself, and by the
56   * {@link #getMaxAssociatedResources() max associated resources} parameter.
57   * This parameter limits the number of associated resources per each main resource, so
58   * that if a main resource has hundreds of associated resources, only up to the number
59   * specified by this parameter will be pushed.</p>
60   */
61  public class ReferrerPushStrategy implements PushStrategy
62  {
63      private static final Logger logger = Log.getLogger(ReferrerPushStrategy.class);
64      private final ConcurrentMap<String, MainResource> mainResources = new ConcurrentHashMap<>();
65      private final Set<Pattern> pushRegexps = new HashSet<>();
66      private final Set<String> pushContentTypes = new HashSet<>();
67      private final Set<Pattern> allowedPushOrigins = new HashSet<>();
68      private volatile int maxAssociatedResources = 32;
69      private volatile int referrerPushPeriod = 5000;
70  
71      public ReferrerPushStrategy()
72      {
73          this(Arrays.asList(".*\\.css", ".*\\.js", ".*\\.png", ".*\\.jpeg", ".*\\.jpg", ".*\\.gif", ".*\\.ico"));
74      }
75  
76      public ReferrerPushStrategy(List<String> pushRegexps)
77      {
78          this(pushRegexps, Arrays.asList(
79                  "text/css",
80                  "text/javascript", "application/javascript", "application/x-javascript",
81                  "image/png", "image/x-png",
82                  "image/jpeg",
83                  "image/gif",
84                  "image/x-icon", "image/vnd.microsoft.icon"));
85      }
86  
87      public ReferrerPushStrategy(List<String> pushRegexps, List<String> pushContentTypes)
88      {
89          this(pushRegexps, pushContentTypes, Collections.<String>emptyList());
90      }
91  
92      public ReferrerPushStrategy(List<String> pushRegexps, List<String> pushContentTypes, List<String> allowedPushOrigins)
93      {
94          for (String pushRegexp : pushRegexps)
95              this.pushRegexps.add(Pattern.compile(pushRegexp));
96          this.pushContentTypes.addAll(pushContentTypes);
97          for (String allowedPushOrigin : allowedPushOrigins)
98              this.allowedPushOrigins.add(Pattern.compile(allowedPushOrigin.replace(".", "\\.").replace("*", ".*")));
99      }
100 
101     public int getMaxAssociatedResources()
102     {
103         return maxAssociatedResources;
104     }
105 
106     public void setMaxAssociatedResources(int maxAssociatedResources)
107     {
108         this.maxAssociatedResources = maxAssociatedResources;
109     }
110 
111     public int getReferrerPushPeriod()
112     {
113         return referrerPushPeriod;
114     }
115 
116     public void setReferrerPushPeriod(int referrerPushPeriod)
117     {
118         this.referrerPushPeriod = referrerPushPeriod;
119     }
120 
121     @Override
122     public Set<String> apply(Stream stream, Headers requestHeaders, Headers responseHeaders)
123     {
124         Set<String> result = Collections.<String>emptySet();
125         short version = stream.getSession().getVersion();
126         if (!isIfModifiedSinceHeaderPresent(requestHeaders) && isValidMethod(requestHeaders.get(HTTPSPDYHeader.METHOD.name(version)).value()))
127         {
128             String scheme = requestHeaders.get(HTTPSPDYHeader.SCHEME.name(version)).value();
129             String host = requestHeaders.get(HTTPSPDYHeader.HOST.name(version)).value();
130             String origin = scheme + "://" + host;
131             String url = requestHeaders.get(HTTPSPDYHeader.URI.name(version)).value();
132             String absoluteURL = origin + url;
133             logger.debug("Applying push strategy for {}", absoluteURL);
134             if (isMainResource(url, responseHeaders))
135             {
136                 MainResource mainResource = getOrCreateMainResource(absoluteURL);
137                 result = mainResource.getResources();
138             }
139             else if (isPushResource(url, responseHeaders))
140             {
141                 Headers.Header referrerHeader = requestHeaders.get("referer");
142                 if (referrerHeader != null)
143                 {
144                     String referrer = referrerHeader.value();
145                     MainResource mainResource = mainResources.get(referrer);
146                     if (mainResource == null)
147                         mainResource = getOrCreateMainResource(referrer);
148 
149                     Set<String> pushResources = mainResource.getResources();
150                     if (!pushResources.contains(url))
151                         mainResource.addResource(url, origin, referrer);
152                     else
153                         result = getPushResources(absoluteURL);
154                 }
155             }
156             logger.debug("Pushing {} resources for {}: {}", result.size(), absoluteURL, result);
157         }
158         return result;
159     }
160 
161     private Set<String> getPushResources(String absoluteURL)
162     {
163         Set<String> result = Collections.emptySet();
164         if (mainResources.get(absoluteURL) != null)
165             result = mainResources.get(absoluteURL).getResources();
166         return result;
167     }
168 
169     private MainResource getOrCreateMainResource(String absoluteURL)
170     {
171         MainResource mainResource = mainResources.get(absoluteURL);
172         if (mainResource == null)
173         {
174             logger.debug("Creating new main resource for {}", absoluteURL);
175             MainResource value = new MainResource(absoluteURL);
176             mainResource = mainResources.putIfAbsent(absoluteURL, value);
177             if (mainResource == null)
178                 mainResource = value;
179         }
180         return mainResource;
181     }
182 
183     private boolean isIfModifiedSinceHeaderPresent(Headers headers)
184     {
185         return headers.get("if-modified-since") != null;
186     }
187 
188     private boolean isValidMethod(String method)
189     {
190         return "GET".equalsIgnoreCase(method);
191     }
192 
193     private boolean isMainResource(String url, Headers responseHeaders)
194     {
195         return !isPushResource(url, responseHeaders);
196     }
197 
198     private boolean isPushResource(String url, Headers responseHeaders)
199     {
200         for (Pattern pushRegexp : pushRegexps)
201         {
202             if (pushRegexp.matcher(url).matches())
203             {
204                 Headers.Header header = responseHeaders.get("content-type");
205                 if (header == null)
206                     return true;
207 
208                 String contentType = header.value().toLowerCase();
209                 for (String pushContentType : pushContentTypes)
210                     if (contentType.startsWith(pushContentType))
211                         return true;
212             }
213         }
214         return false;
215     }
216 
217     private class MainResource
218     {
219         private final String name;
220         private final Set<String> resources = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
221         private final AtomicLong firstResourceAdded = new AtomicLong(-1);
222 
223         private MainResource(String name)
224         {
225             this.name = name;
226         }
227 
228         public boolean addResource(String url, String origin, String referrer)
229         {
230             // We start the push period here and not when initializing the main resource, because a browser with a
231             // prefilled cache won't request the subresources. If the browser with warmed up cache now hits the main
232             // resource after a server restart, the push period shouldn't start until the first subresource is
233             // being requested.
234             firstResourceAdded.compareAndSet(-1, System.nanoTime());
235 
236             long delay = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - firstResourceAdded.get());
237             if (!referrer.startsWith(origin) && !isPushOriginAllowed(origin))
238             {
239                 logger.debug("Skipped store of push metadata {} for {}: Origin: {} doesn't match or origin not allowed",
240                         url, name, origin);
241                 return false;
242             }
243 
244             // This check is not strictly concurrent-safe, but limiting
245             // the number of associated resources is achieved anyway
246             // although in rare cases few more resources will be stored
247             if (resources.size() >= maxAssociatedResources)
248             {
249                 logger.debug("Skipped store of push metadata {} for {}: max associated resources ({}) reached",
250                         url, name, maxAssociatedResources);
251                 return false;
252             }
253             if (delay > referrerPushPeriod)
254             {
255                 logger.debug("Delay: {}ms longer than referrerPushPeriod: {}ms. Not adding resource: {} for: {}", delay, referrerPushPeriod, url, name);
256                 return false;
257             }
258 
259             logger.debug("Adding resource: {} for: {} with delay: {}ms.", url, name, delay);
260             resources.add(url);
261             return true;
262         }
263 
264         public Set<String> getResources()
265         {
266             return Collections.unmodifiableSet(resources);
267         }
268 
269         public String toString()
270         {
271             return "MainResource: " + name + " associated resources:" + resources.size();
272         }
273 
274         private boolean isPushOriginAllowed(String origin)
275         {
276             for (Pattern allowedPushOrigin : allowedPushOrigins)
277             {
278                 if (allowedPushOrigin.matcher(origin).matches())
279                     return true;
280             }
281             return false;
282         }
283     }
284 }