View Javadoc

1   /*
2    * Copyright 2012 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package org.jboss.netty.example.http.file;
17  
18  import static org.jboss.netty.handler.codec.http.HttpHeaders.*;
19  import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*;
20  import static org.jboss.netty.handler.codec.http.HttpMethod.*;
21  import static org.jboss.netty.handler.codec.http.HttpResponseStatus.*;
22  import static org.jboss.netty.handler.codec.http.HttpVersion.*;
23  
24  import java.io.File;
25  import java.io.FileNotFoundException;
26  import java.io.RandomAccessFile;
27  import java.io.UnsupportedEncodingException;
28  import java.net.URLDecoder;
29  import java.text.SimpleDateFormat;
30  import java.util.Calendar;
31  import java.util.Date;
32  import java.util.GregorianCalendar;
33  import java.util.Locale;
34  import java.util.TimeZone;
35  
36  import javax.activation.MimetypesFileTypeMap;
37  
38  import org.jboss.netty.buffer.ChannelBuffers;
39  import org.jboss.netty.channel.Channel;
40  import org.jboss.netty.channel.ChannelFuture;
41  import org.jboss.netty.channel.ChannelFutureListener;
42  import org.jboss.netty.channel.ChannelFutureProgressListener;
43  import org.jboss.netty.channel.ChannelHandlerContext;
44  import org.jboss.netty.channel.DefaultFileRegion;
45  import org.jboss.netty.channel.ExceptionEvent;
46  import org.jboss.netty.channel.FileRegion;
47  import org.jboss.netty.channel.MessageEvent;
48  import org.jboss.netty.channel.SimpleChannelUpstreamHandler;
49  import org.jboss.netty.handler.codec.frame.TooLongFrameException;
50  import org.jboss.netty.handler.codec.http.DefaultHttpResponse;
51  import org.jboss.netty.handler.codec.http.HttpHeaders;
52  import org.jboss.netty.handler.codec.http.HttpRequest;
53  import org.jboss.netty.handler.codec.http.HttpResponse;
54  import org.jboss.netty.handler.codec.http.HttpResponseStatus;
55  import org.jboss.netty.handler.ssl.SslHandler;
56  import org.jboss.netty.handler.stream.ChunkedFile;
57  import org.jboss.netty.util.CharsetUtil;
58  
59  /**
60   * A simple handler that serves incoming HTTP requests to send their respective
61   * HTTP responses.  It also implements {@code 'If-Modified-Since'} header to
62   * take advantage of browser cache, as described in
63   * <a href="http://tools.ietf.org/html/rfc2616#section-14.25">RFC 2616</a>.
64   *
65   * <h3>How Browser Caching Works</h3>
66   *
67   * Web browser caching works with HTTP headers as illustrated by the following
68   * sample:
69   * <ol>
70   * <li>Request #1 returns the content of <code>/file1.txt</code>.</li>
71   * <li>Contents of <code>/file1.txt</code> is cached by the browser.</li>
72   * <li>Request #2 for <code>/file1.txt</code> does return the contents of the
73   *     file again. Rather, a 304 Not Modified is returned. This tells the
74   *     browser to use the contents stored in its cache.</li>
75   * <li>The server knows the file has not been modified because the
76   *     <code>If-Modified-Since</code> date is the same as the file's last
77   *     modified date.</li>
78   * </ol>
79   *
80   * <pre>
81   * Request #1 Headers
82   * ===================
83   * GET /file1.txt HTTP/1.1
84   *
85   * Response #1 Headers
86   * ===================
87   * HTTP/1.1 200 OK
88   * Date:               Tue, 01 Mar 2011 22:44:26 GMT
89   * Last-Modified:      Wed, 30 Jun 2010 21:36:48 GMT
90   * Expires:            Tue, 01 Mar 2012 22:44:26 GMT
91   * Cache-Control:      private, max-age=31536000
92   *
93   * Request #2 Headers
94   * ===================
95   * GET /file1.txt HTTP/1.1
96   * If-Modified-Since:  Wed, 30 Jun 2010 21:36:48 GMT
97   *
98   * Response #2 Headers
99   * ===================
100  * HTTP/1.1 304 Not Modified
101  * Date:               Tue, 01 Mar 2011 22:44:28 GMT
102  *
103  * </pre>
104  */
105 public class HttpStaticFileServerHandler extends SimpleChannelUpstreamHandler {
106 
107     public static final String HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
108     public static final String HTTP_DATE_GMT_TIMEZONE = "GMT";
109     public static final int HTTP_CACHE_SECONDS = 60;
110 
111     @Override
112     public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
113         HttpRequest request = (HttpRequest) e.getMessage();
114         if (request.getMethod() != GET) {
115             sendError(ctx, METHOD_NOT_ALLOWED);
116             return;
117         }
118 
119         final String path = sanitizeUri(request.getUri());
120         if (path == null) {
121             sendError(ctx, FORBIDDEN);
122             return;
123         }
124 
125         File file = new File(path);
126         if (file.isHidden() || !file.exists()) {
127             sendError(ctx, NOT_FOUND);
128             return;
129         }
130         if (!file.isFile()) {
131             sendError(ctx, FORBIDDEN);
132             return;
133         }
134 
135         // Cache Validation
136         String ifModifiedSince = request.getHeader(HttpHeaders.Names.IF_MODIFIED_SINCE);
137         if (ifModifiedSince != null && !ifModifiedSince.equals("")) {
138             SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
139             Date ifModifiedSinceDate = dateFormatter.parse(ifModifiedSince);
140 
141             // Only compare up to the second because the datetime format we send to the client does
142             // not have milliseconds
143             long ifModifiedSinceDateSeconds = ifModifiedSinceDate.getTime() / 1000;
144             long fileLastModifiedSeconds = file.lastModified() / 1000;
145             if (ifModifiedSinceDateSeconds == fileLastModifiedSeconds) {
146                 sendNotModified(ctx);
147                 return;
148             }
149         }
150 
151         RandomAccessFile raf;
152         try {
153             raf = new RandomAccessFile(file, "r");
154         } catch (FileNotFoundException fnfe) {
155             sendError(ctx, NOT_FOUND);
156             return;
157         }
158         long fileLength = raf.length();
159 
160         HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
161         setContentLength(response, fileLength);
162         setContentTypeHeader(response, file);
163         setDateAndCacheHeaders(response, file);
164 
165         Channel ch = e.getChannel();
166 
167         // Write the initial line and the header.
168         ch.write(response);
169 
170         // Write the content.
171         ChannelFuture writeFuture;
172         if (ch.getPipeline().get(SslHandler.class) != null) {
173             // Cannot use zero-copy with HTTPS.
174             writeFuture = ch.write(new ChunkedFile(raf, 0, fileLength, 8192));
175         } else {
176             // No encryption - use zero-copy.
177             final FileRegion region =
178                 new DefaultFileRegion(raf.getChannel(), 0, fileLength);
179             writeFuture = ch.write(region);
180             writeFuture.addListener(new ChannelFutureProgressListener() {
181                 public void operationComplete(ChannelFuture future) {
182                     region.releaseExternalResources();
183                 }
184 
185                 public void operationProgressed(
186                         ChannelFuture future, long amount, long current, long total) {
187                     System.out.printf("%s: %d / %d (+%d)%n", path, current, total, amount);
188                 }
189             });
190         }
191 
192         // Decide whether to close the connection or not.
193         if (!isKeepAlive(request)) {
194             // Close the connection when the whole content is written out.
195             writeFuture.addListener(ChannelFutureListener.CLOSE);
196         }
197     }
198 
199     @Override
200     public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e)
201             throws Exception {
202         Channel ch = e.getChannel();
203         Throwable cause = e.getCause();
204         if (cause instanceof TooLongFrameException) {
205             sendError(ctx, BAD_REQUEST);
206             return;
207         }
208 
209         cause.printStackTrace();
210         if (ch.isConnected()) {
211             sendError(ctx, INTERNAL_SERVER_ERROR);
212         }
213     }
214 
215     private static String sanitizeUri(String uri) {
216         // Decode the path.
217         try {
218             uri = URLDecoder.decode(uri, "UTF-8");
219         } catch (UnsupportedEncodingException e) {
220             try {
221                 uri = URLDecoder.decode(uri, "ISO-8859-1");
222             } catch (UnsupportedEncodingException e1) {
223                 throw new Error();
224             }
225         }
226 
227         // Convert file separators.
228         uri = uri.replace('/', File.separatorChar);
229 
230         // Simplistic dumb security check.
231         // You will have to do something serious in the production environment.
232         if (uri.contains(File.separator + ".") ||
233             uri.contains("." + File.separator) ||
234             uri.startsWith(".") || uri.endsWith(".")) {
235             return null;
236         }
237 
238         // Convert to absolute path.
239         return System.getProperty("user.dir") + File.separator + uri;
240     }
241 
242     private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
243         HttpResponse response = new DefaultHttpResponse(HTTP_1_1, status);
244         response.setHeader(CONTENT_TYPE, "text/plain; charset=UTF-8");
245         response.setContent(ChannelBuffers.copiedBuffer(
246                 "Failure: " + status.toString() + "\r\n",
247                 CharsetUtil.UTF_8));
248 
249         // Close the connection as soon as the error message is sent.
250         ctx.getChannel().write(response).addListener(ChannelFutureListener.CLOSE);
251     }
252 
253     /**
254      * When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
255      *
256      * @param ctx
257      *            Context
258      */
259     private static void sendNotModified(ChannelHandlerContext ctx) {
260         HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.NOT_MODIFIED);
261         setDateHeader(response);
262 
263         // Close the connection as soon as the error message is sent.
264         ctx.getChannel().write(response).addListener(ChannelFutureListener.CLOSE);
265     }
266 
267     /**
268      * Sets the Date header for the HTTP response
269      *
270      * @param response
271      *            HTTP response
272      */
273     private static void setDateHeader(HttpResponse response) {
274         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
275         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
276 
277         Calendar time = new GregorianCalendar();
278         response.setHeader(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime()));
279     }
280 
281     /**
282      * Sets the Date and Cache headers for the HTTP Response
283      *
284      * @param response
285      *            HTTP response
286      * @param fileToCache
287      *            file to extract content type
288      */
289     private static void setDateAndCacheHeaders(HttpResponse response, File fileToCache) {
290         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
291         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
292 
293         // Date header
294         Calendar time = new GregorianCalendar();
295         response.setHeader(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime()));
296 
297         // Add cache headers
298         time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
299         response.setHeader(HttpHeaders.Names.EXPIRES, dateFormatter.format(time.getTime()));
300         response.setHeader(HttpHeaders.Names.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
301         response.setHeader(
302                 HttpHeaders.Names.LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified())));
303     }
304 
305     /**
306      * Sets the content type header for the HTTP Response
307      *
308      * @param response
309      *            HTTP response
310      * @param file
311      *            file to extract content type
312      */
313     private static void setContentTypeHeader(HttpResponse response, File file) {
314         MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
315         response.setHeader(HttpHeaders.Names.CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath()));
316     }
317 
318 }