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</code>.</li>
69   * <li>Contents of <code>/file1.txt</code> is cached by the browser.</li>
70   * <li>Request #2 for <code>/file1.txt</code> 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</code> 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     public static final String HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
106     public static final String HTTP_DATE_GMT_TIMEZONE = "GMT";
107     public 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.getHeader(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.out.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             throws Exception {
200         Channel ch = e.getChannel();
201         Throwable cause = e.getCause();
202         if (cause instanceof TooLongFrameException) {
203             sendError(ctx, BAD_REQUEST);
204             return;
205         }
206 
207         cause.printStackTrace();
208         if (ch.isConnected()) {
209             sendError(ctx, INTERNAL_SERVER_ERROR);
210         }
211     }
212 
213     private static String sanitizeUri(String uri) {
214         // Decode the path.
215         try {
216             uri = URLDecoder.decode(uri, "UTF-8");
217         } catch (UnsupportedEncodingException e) {
218             try {
219                 uri = URLDecoder.decode(uri, "ISO-8859-1");
220             } catch (UnsupportedEncodingException e1) {
221                 throw new Error();
222             }
223         }
224 
225         // Convert file separators.
226         uri = uri.replace('/', File.separatorChar);
227 
228         // Simplistic dumb security check.
229         // You will have to do something serious in the production environment.
230         if (uri.contains(File.separator + '.') ||
231             uri.contains('.' + File.separator) ||
232             uri.startsWith(".") || uri.endsWith(".")) {
233             return null;
234         }
235 
236         // Convert to absolute path.
237         return System.getProperty("user.dir") + File.separator + uri;
238     }
239 
240     private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
241         HttpResponse response = new DefaultHttpResponse(HTTP_1_1, status);
242         response.setHeader(CONTENT_TYPE, "text/plain; charset=UTF-8");
243         response.setContent(ChannelBuffers.copiedBuffer(
244                 "Failure: " + status.toString() + "\r\n",
245                 CharsetUtil.UTF_8));
246 
247         // Close the connection as soon as the error message is sent.
248         ctx.getChannel().write(response).addListener(ChannelFutureListener.CLOSE);
249     }
250 
251     /**
252      * When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
253      *
254      * @param ctx
255      *            Context
256      */
257     private static void sendNotModified(ChannelHandlerContext ctx) {
258         HttpResponse response = new DefaultHttpResponse(HTTP_1_1, NOT_MODIFIED);
259         setDateHeader(response);
260 
261         // Close the connection as soon as the error message is sent.
262         ctx.getChannel().write(response).addListener(ChannelFutureListener.CLOSE);
263     }
264 
265     /**
266      * Sets the Date header for the HTTP response
267      *
268      * @param response
269      *            HTTP response
270      */
271     private static void setDateHeader(HttpResponse response) {
272         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
273         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
274 
275         Calendar time = new GregorianCalendar();
276         response.setHeader(DATE, dateFormatter.format(time.getTime()));
277     }
278 
279     /**
280      * Sets the Date and Cache headers for the HTTP Response
281      *
282      * @param response
283      *            HTTP response
284      * @param fileToCache
285      *            file to extract content type
286      */
287     private static void setDateAndCacheHeaders(HttpResponse response, File fileToCache) {
288         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
289         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
290 
291         // Date header
292         Calendar time = new GregorianCalendar();
293         response.setHeader(DATE, dateFormatter.format(time.getTime()));
294 
295         // Add cache headers
296         time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
297         response.setHeader(EXPIRES, dateFormatter.format(time.getTime()));
298         response.setHeader(CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
299         response.setHeader(
300                 LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified())));
301     }
302 
303     /**
304      * Sets the content type header for the HTTP Response
305      *
306      * @param response
307      *            HTTP response
308      * @param file
309      *            file to extract content type
310      */
311     private static void setContentTypeHeader(HttpResponse response, File file) {
312         MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
313         response.setHeader(CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath()));
314     }
315 
316 }