1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
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
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
171
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
201 ctx.write(response);
202
203
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
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
217 lastContentFuture = sendFileFuture;
218 }
219
220 sendFileFuture.addListener(ctx.channel(), (channel, future) ->
221 System.err.println(channel + " Transfer complete."));
222
223
224 if (!keepAlive) {
225
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
242 uri = URLDecoder.decode(uri, UTF_8);
243
244 if (uri.isEmpty() || uri.charAt(0) != '/') {
245 return null;
246 }
247
248
249 uri = uri.replace('/', File.separatorChar);
250
251
252
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
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
330
331
332
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
344
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
352
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
362 flushPromise.addListener(ctx, ChannelFutureListeners.CLOSE);
363 }
364 }
365
366
367
368
369
370
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
382
383
384
385
386
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
393 Calendar time = new GregorianCalendar();
394 response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime()));
395
396
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
406
407
408
409
410
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 }