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