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 [email protected] '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 [email protected] /file1.txt}.</li>
73   * <li>Contents of [email protected] /file1.txt} is cached by the browser.</li>
74   * <li>Request #2 for [email protected] /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   *     [email protected] 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     @Override
114     public void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
115         if (!request.decoderResult().isSuccess()) {
116             sendError(ctx, BAD_REQUEST);
117             return;
118         }
119 
120         if (request.method() != GET) {
121             sendError(ctx, METHOD_NOT_ALLOWED);
122             return;
123         }
124 
125         final String uri = request.uri();
126         final String path = sanitizeUri(uri);
127         if (path == null) {
128             sendError(ctx, FORBIDDEN);
129             return;
130         }
131 
132         File file = new File(path);
133         if (file.isHidden() || !file.exists()) {
134             sendError(ctx, NOT_FOUND);
135             return;
136         }
137 
138         if (file.isDirectory()) {
139             if (uri.endsWith("/")) {
140                 sendListing(ctx, file, uri);
141             } else {
142                 sendRedirect(ctx, uri + '/');
143             }
144             return;
145         }
146 
147         if (!file.isFile()) {
148             sendError(ctx, FORBIDDEN);
149             return;
150         }
151 
152         // Cache Validation
153         String ifModifiedSince = request.headers().get(HttpHeaderNames.IF_MODIFIED_SINCE);
154         if (ifModifiedSince != null && !ifModifiedSince.isEmpty()) {
155             SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
156             Date ifModifiedSinceDate = dateFormatter.parse(ifModifiedSince);
157 
158             // Only compare up to the second because the datetime format we send to the client
159             // does not have milliseconds
160             long ifModifiedSinceDateSeconds = ifModifiedSinceDate.getTime() / 1000;
161             long fileLastModifiedSeconds = file.lastModified() / 1000;
162             if (ifModifiedSinceDateSeconds == fileLastModifiedSeconds) {
163                 sendNotModified(ctx);
164                 return;
165             }
166         }
167 
168         RandomAccessFile raf;
169         try {
170             raf = new RandomAccessFile(file, "r");
171         } catch (FileNotFoundException ignore) {
172             sendError(ctx, NOT_FOUND);
173             return;
174         }
175         long fileLength = raf.length();
176 
177         HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
178         HttpUtil.setContentLength(response, fileLength);
179         setContentTypeHeader(response, file);
180         setDateAndCacheHeaders(response, file);
181         if (HttpUtil.isKeepAlive(request)) {
182             response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
183         }
184 
185         // Write the initial line and the header.
186         ctx.write(response);
187 
188         // Write the content.
189         ChannelFuture sendFileFuture;
190         ChannelFuture lastContentFuture;
191         if (ctx.pipeline().get(SslHandler.class) == null) {
192             sendFileFuture =
193                     ctx.write(new DefaultFileRegion(raf.getChannel(), 0, fileLength), ctx.newProgressivePromise());
194             // Write the end marker.
195             lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
196         } else {
197             sendFileFuture =
198                     ctx.writeAndFlush(new HttpChunkedInput(new ChunkedFile(raf, 0, fileLength, 8192)),
199                             ctx.newProgressivePromise());
200             // HttpChunkedInput will write the end marker (LastHttpContent) for us.
201             lastContentFuture = sendFileFuture;
202         }
203 
204         sendFileFuture.addListener(new ChannelProgressiveFutureListener() {
205             @Override
206             public void operationProgressed(ChannelProgressiveFuture future, long progress, long total) {
207                 if (total < 0) { // total unknown
208                     System.err.println(future.channel() + " Transfer progress: " + progress);
209                 } else {
210                     System.err.println(future.channel() + " Transfer progress: " + progress + " / " + total);
211                 }
212             }
213 
214             @Override
215             public void operationComplete(ChannelProgressiveFuture future) {
216                 System.err.println(future.channel() + " Transfer complete.");
217             }
218         });
219 
220         // Decide whether to close the connection or not.
221         if (!HttpUtil.isKeepAlive(request)) {
222             // Close the connection when the whole content is written out.
223             lastContentFuture.addListener(ChannelFutureListener.CLOSE);
224         }
225     }
226 
227     @Override
228     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
229         cause.printStackTrace();
230         if (ctx.channel().isActive()) {
231             sendError(ctx, INTERNAL_SERVER_ERROR);
232         }
233     }
234 
235     private static final Pattern INSECURE_URI = Pattern.compile(".*[<>&\"].*");
236 
237     private static String sanitizeUri(String uri) {
238         // Decode the path.
239         try {
240             uri = URLDecoder.decode(uri, "UTF-8");
241         } catch (UnsupportedEncodingException e) {
242             throw new Error(e);
243         }
244 
245         if (uri.isEmpty() || uri.charAt(0) != '/') {
246             return null;
247         }
248 
249         // Convert file separators.
250         uri = uri.replace('/', File.separatorChar);
251 
252         // Simplistic dumb security check.
253         // You will have to do something serious in the production environment.
254         if (uri.contains(File.separator + '.') ||
255             uri.contains('.' + File.separator) ||
256             uri.charAt(0) == '.' || uri.charAt(uri.length() - 1) == '.' ||
257             INSECURE_URI.matcher(uri).matches()) {
258             return null;
259         }
260 
261         // Convert to absolute path.
262         return SystemPropertyUtil.get("user.dir") + File.separator + uri;
263     }
264 
265     private static final Pattern ALLOWED_FILE_NAME = Pattern.compile("[^-\\._]?[^<>&\\\"]*");
266 
267     private static void sendListing(ChannelHandlerContext ctx, File dir, String dirPath) {
268         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK);
269         response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=UTF-8");
270 
271         StringBuilder buf = new StringBuilder()
272             .append("<!DOCTYPE html>\r\n")
273             .append("<html><head><meta charset='utf-8' /><title>")
274             .append("Listing of: ")
275             .append(dirPath)
276             .append("</title></head><body>\r\n")
277 
278             .append("<h3>Listing of: ")
279             .append(dirPath)
280             .append("</h3>\r\n")
281 
282             .append("<ul>")
283             .append("<li><a href=\"../\">..</a></li>\r\n");
284 
285         for (File f: dir.listFiles()) {
286             if (f.isHidden() || !f.canRead()) {
287                 continue;
288             }
289 
290             String name = f.getName();
291             if (!ALLOWED_FILE_NAME.matcher(name).matches()) {
292                 continue;
293             }
294 
295             buf.append("<li><a href=\"")
296                .append(name)
297                .append("\">")
298                .append(name)
299                .append("</a></li>\r\n");
300         }
301 
302         buf.append("</ul></body></html>\r\n");
303         ByteBuf buffer = Unpooled.copiedBuffer(buf, CharsetUtil.UTF_8);
304         response.content().writeBytes(buffer);
305         buffer.release();
306 
307         // Close the connection as soon as the error message is sent.
308         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
309     }
310 
311     private static void sendRedirect(ChannelHandlerContext ctx, String newUri) {
312         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, FOUND);
313         response.headers().set(HttpHeaderNames.LOCATION, newUri);
314 
315         // Close the connection as soon as the error message is sent.
316         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
317     }
318 
319     private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
320         FullHttpResponse response = new DefaultFullHttpResponse(
321                 HTTP_1_1, status, Unpooled.copiedBuffer("Failure: " + status + "\r\n", CharsetUtil.UTF_8));
322         response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
323 
324         // Close the connection as soon as the error message is sent.
325         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
326     }
327 
328     /**
329      * When file timestamp is the same as what the browser is sending up, send a "304 Not Modified"
330      *
331      * @param ctx
332      *            Context
333      */
334     private static void sendNotModified(ChannelHandlerContext ctx) {
335         FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, NOT_MODIFIED);
336         setDateHeader(response);
337 
338         // Close the connection as soon as the error message is sent.
339         ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
340     }
341 
342     /**
343      * Sets the Date header for the HTTP response
344      *
345      * @param response
346      *            HTTP response
347      */
348     private static void setDateHeader(FullHttpResponse response) {
349         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
350         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
351 
352         Calendar time = new GregorianCalendar();
353         response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));
354     }
355 
356     /**
357      * Sets the Date and Cache headers for the HTTP Response
358      *
359      * @param response
360      *            HTTP response
361      * @param fileToCache
362      *            file to extract content type
363      */
364     private static void setDateAndCacheHeaders(HttpResponse response, File fileToCache) {
365         SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);
366         dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
367 
368         // Date header
369         Calendar time = new GregorianCalendar();
370         response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));
371 
372         // Add cache headers
373         time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
374         response.headers().set(HttpHeaderNames.EXPIRES, dateFormatter.format(time.getTime()));
375         response.headers().set(HttpHeaderNames.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
376         response.headers().set(
377                 HttpHeaderNames.LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified())));
378     }
379 
380     /**
381      * Sets the content type header for the HTTP Response
382      *
383      * @param response
384      *            HTTP response
385      * @param file
386      *            file to extract content type
387      */
388     private static void setContentTypeHeader(HttpResponse response, File file) {
389         MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
390         response.headers().set(HttpHeaderNames.CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath()));
391     }
392 }