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