View Javadoc

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