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 io.netty.example.http.file;
17  
18  import io.netty.buffer.ByteBuf;
19  import io.netty.buffer.Unpooled;
20  import io.netty.channel.ChannelFuture;
21  import io.netty.channel.ChannelFutureListener;
22  import io.netty.channel.ChannelHandlerContext;
23  import io.netty.channel.ChannelProgressiveFuture;
24  import io.netty.channel.ChannelProgressiveFutureListener;
25  import io.netty.channel.DefaultFileRegion;
26  import io.netty.channel.SimpleChannelInboundHandler;
27  import io.netty.handler.codec.http.DefaultFullHttpResponse;
28  import io.netty.handler.codec.http.DefaultHttpResponse;
29  import io.netty.handler.codec.http.FullHttpRequest;
30  import io.netty.handler.codec.http.FullHttpResponse;
31  import io.netty.handler.codec.http.HttpChunkedInput;
32  import io.netty.handler.codec.http.HttpHeaders;
33  import io.netty.handler.codec.http.HttpResponse;
34  import io.netty.handler.codec.http.HttpResponseStatus;
35  import io.netty.handler.codec.http.LastHttpContent;
36  import io.netty.handler.ssl.SslHandler;
37  import io.netty.handler.stream.ChunkedFile;
38  import io.netty.util.CharsetUtil;
39  import io.netty.util.internal.SystemPropertyUtil;
40  
41  import javax.activation.MimetypesFileTypeMap;
42  import java.io.File;
43  import java.io.FileNotFoundException;
44  import java.io.RandomAccessFile;
45  import java.io.UnsupportedEncodingException;
46  import java.net.URLDecoder;
47  import java.text.SimpleDateFormat;
48  import java.util.Calendar;
49  import java.util.Date;
50  import java.util.GregorianCalendar;
51  import java.util.Locale;
52  import java.util.TimeZone;
53  import java.util.regex.Pattern;
54  
55  import static io.netty.handler.codec.http.HttpHeaders.Names.*;
56  import static io.netty.handler.codec.http.HttpMethod.*;
57  import static io.netty.handler.codec.http.HttpResponseStatus.*;
58  import static io.netty.handler.codec.http.HttpVersion.*;
59  
60  /**
61   * A simple handler that serves incoming HTTP requests to send their respective
62   * HTTP responses.  It also implements {@code 'If-Modified-Since'} header to
63   * take advantage of browser cache, as described in
64   * <a href="http://tools.ietf.org/html/rfc2616#section-14.25">RFC 2616</a>.
65   *
66   * <h3>How Browser Caching Works</h3>
67   *
68   * Web browser caching works with HTTP headers as illustrated by the following
69   * sample:
70   * <ol>
71   * <li>Request #1 returns the content of {@code /file1.txt}.</li>
72   * <li>Contents of {@code /file1.txt} is cached by the browser.</li>
73   * <li>Request #2 for {@code /file1.txt} does not return the contents of the
74   *     file again. Rather, a 304 Not Modified is returned. This tells the
75   *     browser to use the contents stored in its cache.</li>
76   * <li>The server knows the file has not been modified because the
77   *     {@code If-Modified-Since} date is the same as the file's last
78   *     modified date.</li>
79   * </ol>
80   *
81   * <pre>
82   * Request #1 Headers
83   * ===================
84   * GET /file1.txt HTTP/1.1
85   *
86   * Response #1 Headers
87   * ===================
88   * HTTP/1.1 200 OK
89   * Date:               Tue, 01 Mar 2011 22:44:26 GMT
90   * Last-Modified:      Wed, 30 Jun 2010 21:36:48 GMT
91   * Expires:            Tue, 01 Mar 2012 22:44:26 GMT
92   * Cache-Control:      private, max-age=31536000
93   *
94   * Request #2 Headers
95   * ===================
96   * GET /file1.txt HTTP/1.1
97   * If-Modified-Since:  Wed, 30 Jun 2010 21:36:48 GMT
98   *
99   * Response #2 Headers
100  * ===================
101  * HTTP/1.1 304 Not Modified
102  * Date:               Tue, 01 Mar 2011 22:44:28 GMT
103  *
104  * </pre>
105  */
106 public class HttpStaticFileServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
107 
108     public static final String HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
109     public static final String HTTP_DATE_GMT_TIMEZONE = "GMT";
110     public static final int HTTP_CACHE_SECONDS = 60;
111 
112     @Override
113     public void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
114         if (!request.getDecoderResult().isSuccess()) {
115             sendError(ctx, BAD_REQUEST);
116             return;
117         }
118 
119         if (request.getMethod() != GET) {
120             sendError(ctx, METHOD_NOT_ALLOWED);
121             return;
122         }
123 
124         final String uri = request.getUri();
125         final String path = sanitizeUri(uri);
126         if (path == null) {
127             sendError(ctx, FORBIDDEN);
128             return;
129         }
130 
131         File file = new File(path);
132         if (file.isHidden() || !file.exists()) {
133             sendError(ctx, NOT_FOUND);
134             return;
135         }
136 
137         if (file.isDirectory()) {
138             if (uri.endsWith("/")) {
139                 sendListing(ctx, file, uri);
140             } else {
141                 sendRedirect(ctx, uri + '/');
142             }
143             return;
144         }
145 
146         if (!file.isFile()) {
147             sendError(ctx, FORBIDDEN);
148             return;
149         }
150 
151         // Cache Validation
152         String ifModifiedSince = request.headers().get(IF_MODIFIED_SINCE);
153         if (ifModifiedSince != null && !ifModifiedSince.isEmpty()) {
154             SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
155             Date ifModifiedSinceDate = dateFormatter.parse(ifModifiedSince);
156 
157             // Only compare up to the second because the datetime format we send to the client
158             // does not have milliseconds
159             long ifModifiedSinceDateSeconds = ifModifiedSinceDate.getTime() / 1000;
160             long fileLastModifiedSeconds = file.lastModified() / 1000;
161             if (ifModifiedSinceDateSeconds == fileLastModifiedSeconds) {
162                 sendNotModified(ctx);
163                 return;
164             }
165         }
166 
167         RandomAccessFile raf;
168         try {
169             raf = new RandomAccessFile(file, "r");
170         } catch (FileNotFoundException ignore) {
171             sendError(ctx, NOT_FOUND);
172             return;
173         }
174         long fileLength = raf.length();
175 
176         HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
177         HttpHeaders.setContentLength(response, fileLength);
178         setContentTypeHeader(response, file);
179         setDateAndCacheHeaders(response, file);
180         if (HttpHeaders.isKeepAlive(request)) {
181             response.headers().set(CONNECTION, HttpHeaders.Values.KEEP_ALIVE);
182         }
183 
184         // Write the initial line and the header.
185         ctx.write(response);
186 
187         // Write the content.
188         ChannelFuture sendFileFuture;
189         ChannelFuture lastContentFuture;
190         if (ctx.pipeline().get(SslHandler.class) == null) {
191             sendFileFuture =
192                     ctx.write(new DefaultFileRegion(raf.getChannel(), 0, fileLength), ctx.newProgressivePromise());
193             // Write the end marker.
194             lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
195         } else {
196             sendFileFuture =
197                     ctx.writeAndFlush(new HttpChunkedInput(new ChunkedFile(raf, 0, fileLength, 8192)),
198                             ctx.newProgressivePromise());
199             // HttpChunkedInput will write the end marker (LastHttpContent) for us.
200             lastContentFuture = sendFileFuture;
201         }
202 
203         sendFileFuture.addListener(new ChannelProgressiveFutureListener() {
204             @Override
205             public void operationProgressed(ChannelProgressiveFuture future, long progress, long total) {
206                 if (total < 0) { // total unknown
207                     System.err.println(future.channel() + " Transfer progress: " + progress);
208                 } else {
209                     System.err.println(future.channel() + " Transfer progress: " + progress + " / " + total);
210                 }
211             }
212 
213             @Override
214             public void operationComplete(ChannelProgressiveFuture future) {
215                 System.err.println(future.channel() + " Transfer complete.");
216             }
217         });
218 
219         // Decide whether to close the connection or not.
220         if (!HttpHeaders.isKeepAlive(request)) {
221             // Close the connection when the whole content is written out.
222             lastContentFuture.addListener(ChannelFutureListener.CLOSE);
223         }
224     }
225 
226     @Override
227     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
228         cause.printStackTrace();
229         if (ctx.channel().isActive()) {
230             sendError(ctx, INTERNAL_SERVER_ERROR);
231         }
232     }
233 
234     private static final Pattern INSECURE_URI = Pattern.compile(".*[<>&\"].*");
235 
236     private static String sanitizeUri(String uri) {
237         // Decode the path.
238         try {
239             uri = URLDecoder.decode(uri, "UTF-8");
240         } catch (UnsupportedEncodingException e) {
241             throw new Error(e);
242         }
243 
244         if (uri.isEmpty() || uri.charAt(0) != '/') {
245             return null;
246         }
247 
248         // Convert file separators.
249         uri = uri.replace('/', File.separatorChar);
250 
251         // Simplistic dumb security check.
252         // You will have to do something serious in the production environment.
253         if (uri.contains(File.separator + '.') ||
254             uri.contains('.' + File.separator) ||
255             uri.charAt(0) == '.' || uri.charAt(uri.length() - 1) == '.' ||
256             INSECURE_URI.matcher(uri).matches()) {
257             return null;
258         }
259 
260         // Convert to absolute path.
261         return SystemPropertyUtil.get("user.dir") + File.separator + uri;
262     }
263 
264     private static final Pattern ALLOWED_FILE_NAME = Pattern.compile("[^-\\._]?[^<>&\\\"]*");
265 
266     private static void sendListing(ChannelHandlerContext ctx, File dir, String dirPath) {
267         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK);
268         response.headers().set(CONTENT_TYPE, "text/html; charset=UTF-8");
269 
270         StringBuilder buf = new StringBuilder()
271             .append("<!DOCTYPE html>\r\n")
272             .append("<html><head><meta charset='utf-8' /><title>")
273             .append("Listing of: ")
274             .append(dirPath)
275             .append("</title></head><body>\r\n")
276 
277             .append("<h3>Listing of: ")
278             .append(dirPath)
279             .append("</h3>\r\n")
280 
281             .append("<ul>")
282             .append("<li><a href=\"../\">..</a></li>\r\n");
283 
284         for (File f: dir.listFiles()) {
285             if (f.isHidden() || !f.canRead()) {
286                 continue;
287             }
288 
289             String name = f.getName();
290             if (!ALLOWED_FILE_NAME.matcher(name).matches()) {
291                 continue;
292             }
293 
294             buf.append("<li><a href=\"")
295                .append(name)
296                .append("\">")
297                .append(name)
298                .append("</a></li>\r\n");
299         }
300 
301         buf.append("</ul></body></html>\r\n");
302         ByteBuf buffer = Unpooled.copiedBuffer(buf, CharsetUtil.UTF_8);
303         response.content().writeBytes(buffer);
304         buffer.release();
305 
306         // Close the connection as soon as the error message is sent.
307         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
308     }
309 
310     private static void sendRedirect(ChannelHandlerContext ctx, String newUri) {
311         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, FOUND);
312         response.headers().set(LOCATION, newUri);
313 
314         // Close the connection as soon as the error message is sent.
315         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
316     }
317 
318     private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
319         FullHttpResponse response = new DefaultFullHttpResponse(
320                 HTTP_1_1, status, Unpooled.copiedBuffer("Failure: " + status + "\r\n", CharsetUtil.UTF_8));
321         response.headers().set(CONTENT_TYPE, "text/plain; charset=UTF-8");
322 
323         // Close the connection as soon as the error message is sent.
324         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
325     }
326 
327     /**
328      * When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
329      *
330      * @param ctx
331      *            Context
332      */
333     private static void sendNotModified(ChannelHandlerContext ctx) {
334         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, NOT_MODIFIED);
335         setDateHeader(response);
336 
337         // Close the connection as soon as the error message is sent.
338         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
339     }
340 
341     /**
342      * Sets the Date header for the HTTP response
343      *
344      * @param response
345      *            HTTP response
346      */
347     private static void setDateHeader(FullHttpResponse response) {
348         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
349         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
350 
351         Calendar time = new GregorianCalendar();
352         response.headers().set(DATE, dateFormatter.format(time.getTime()));
353     }
354 
355     /**
356      * Sets the Date and Cache headers for the HTTP Response
357      *
358      * @param response
359      *            HTTP response
360      * @param fileToCache
361      *            file to extract content type
362      */
363     private static void setDateAndCacheHeaders(HttpResponse response, File fileToCache) {
364         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
365         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
366 
367         // Date header
368         Calendar time = new GregorianCalendar();
369         response.headers().set(DATE, dateFormatter.format(time.getTime()));
370 
371         // Add cache headers
372         time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
373         response.headers().set(EXPIRES, dateFormatter.format(time.getTime()));
374         response.headers().set(CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
375         response.headers().set(
376                 LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified())));
377     }
378 
379     /**
380      * Sets the content type header for the HTTP Response
381      *
382      * @param response
383      *            HTTP response
384      * @param file
385      *            file to extract content type
386      */
387     private static void setContentTypeHeader(HttpResponse response, File file) {
388         MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
389         response.headers().set(CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath()));
390     }
391 }