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