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