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         for (File f: dir.listFiles()) {
290             if (f.isHidden() || !f.canRead()) {
291                 continue;
292             }
293 
294             String name = f.getName();
295             if (!ALLOWED_FILE_NAME.matcher(name).matches()) {
296                 continue;
297             }
298 
299             buf.append("<li><a href=\"")
300                .append(name)
301                .append("\">")
302                .append(name)
303                .append("</a></li>\r\n");
304         }
305 
306         buf.append("</ul></body></html>\r\n");
307 
308         ByteBuf buffer = ctx.alloc().buffer(buf.length());
309         buffer.writeCharSequence(buf.toString(), CharsetUtil.UTF_8);
310 
311         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK, buffer);
312         response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=UTF-8");
313 
314         this.sendAndCleanupConnection(ctx, response);
315     }
316 
317     private void sendRedirect(ChannelHandlerContext ctx, String newUri) {
318         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, FOUND, Unpooled.EMPTY_BUFFER);
319         response.headers().set(HttpHeaderNames.LOCATION, newUri);
320 
321         this.sendAndCleanupConnection(ctx, response);
322     }
323 
324     private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
325         FullHttpResponse response = new DefaultFullHttpResponse(
326                 HTTP_1_1, status, Unpooled.copiedBuffer("Failure: " + status + "\r\n", CharsetUtil.UTF_8));
327         response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
328 
329         this.sendAndCleanupConnection(ctx, response);
330     }
331 
332     /**
333      * When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
334      *
335      * @param ctx
336      *            Context
337      */
338     private void sendNotModified(ChannelHandlerContext ctx) {
339         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, NOT_MODIFIED, Unpooled.EMPTY_BUFFER);
340         setDateHeader(response);
341 
342         this.sendAndCleanupConnection(ctx, response);
343     }
344 
345     /**
346      * If Keep-Alive is disabled, attaches "Connection: close" header to the response
347      * and closes the connection after the response being sent.
348      */
349     private void sendAndCleanupConnection(ChannelHandlerContext ctx, FullHttpResponse response) {
350         final FullHttpRequest request = this.request;
351         final boolean keepAlive = HttpUtil.isKeepAlive(request);
352         HttpUtil.setContentLength(response, response.content().readableBytes());
353         if (!keepAlive) {
354             // We're going to close the connection as soon as the response is sent,
355             // so we should also make it clear for the client.
356             response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
357         } else if (request.protocolVersion().equals(HTTP_1_0)) {
358             response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
359         }
360 
361         ChannelFuture flushPromise = ctx.writeAndFlush(response);
362 
363         if (!keepAlive) {
364             // Close the connection as soon as the response is sent.
365             flushPromise.addListener(ChannelFutureListener.CLOSE);
366         }
367     }
368 
369     /**
370      * Sets the Date header for the HTTP response
371      *
372      * @param response
373      *            HTTP response
374      */
375     private static void setDateHeader(FullHttpResponse response) {
376         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
377         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
378 
379         Calendar time = new GregorianCalendar();
380         response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));
381     }
382 
383     /**
384      * Sets the Date and Cache headers for the HTTP Response
385      *
386      * @param response
387      *            HTTP response
388      * @param fileToCache
389      *            file to extract content type
390      */
391     private static void setDateAndCacheHeaders(HttpResponse response, File fileToCache) {
392         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
393         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
394 
395         // Date header
396         Calendar time = new GregorianCalendar();
397         response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));
398 
399         // Add cache headers
400         time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
401         response.headers().set(HttpHeaderNames.EXPIRES, dateFormatter.format(time.getTime()));
402         response.headers().set(HttpHeaderNames.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
403         response.headers().set(
404                 HttpHeaderNames.LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified())));
405     }
406 
407     /**
408      * Sets the content type header for the HTTP Response
409      *
410      * @param response
411      *            HTTP response
412      * @param file
413      *            file to extract content type
414      */
415     private static void setContentTypeHeader(HttpResponse response, File file) {
416         MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
417         response.headers().set(HttpHeaderNames.CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath()));
418     }
419 }