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