View Javadoc

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