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