View Javadoc

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