package org.dcache.http; import com.google.common.base.CharMatcher; import com.google.common.collect.HashMultiset; import com.google.common.collect.Multiset; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelPromise; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.codec.http.QueryStringDecoder; import io.netty.handler.stream.ChunkedInput; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.channels.ClosedChannelException; import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; import diskCacheV111.util.CacheException; import diskCacheV111.util.FileCorruptedCacheException; import diskCacheV111.util.FsPath; import diskCacheV111.util.HttpByteRange; import diskCacheV111.vehicles.HttpProtocolInfo; import dmg.util.HttpException; import org.dcache.pool.movers.IoMode; import org.dcache.pool.movers.NettyTransferService; import org.dcache.vehicles.FileAttributes; import static io.netty.handler.codec.http.HttpHeaders.Names.*; import static io.netty.handler.codec.http.HttpHeaders.Values.BYTES; import static io.netty.handler.codec.http.HttpHeaders.is100ContinueExpected; import static io.netty.handler.codec.http.HttpResponseStatus.*; import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; import static java.util.Arrays.asList; import static org.dcache.util.Checksums.TO_RFC3230; import static org.dcache.util.StringMarkup.percentEncode; import static org.dcache.util.StringMarkup.quotedString; /** * HttpPoolRequestHandler - handle HTTP client - server communication. */ public class HttpPoolRequestHandler extends HttpRequestHandler { private static final Logger _logger = LoggerFactory.getLogger(HttpPoolRequestHandler.class); private static final String DIGEST = "Digest"; private static final String RANGE_SEPARATOR = "-"; private static final String RANGE_PRE_TOTAL = "/"; private static final String RANGE_SP = " "; private static final String BOUNDARY = "__AAAAAAAAAAAAAAAA__"; private static final String MULTIPART_TYPE = "multipart/byteranges; boundary=\"" + HttpPoolRequestHandler.BOUNDARY + "\""; private static final String TWO_HYPHENS = "--"; // See RFC 2045 for definition of 'tspecials' private static final CharMatcher TSPECIAL = CharMatcher.anyOf("()<>@,;:\\\"/[]?="); /** * The mover channels that were opened. */ private final Multiset<NettyTransferService<HttpProtocolInfo>.NettyMoverChannel> _files = HashMultiset.create(); /** * The server in the context of which this handler is executed */ private final NettyTransferService<HttpProtocolInfo> _server; private final int _chunkSize; /** * The file being uploaded. Even though we only keep the file open * for the processing of a single HTTP message, that one message may * have been split into several chunks. Hence we have to keep a * reference to the file in between channel events. */ private NettyTransferService<HttpProtocolInfo>.NettyMoverChannel _writeChannel; public HttpPoolRequestHandler(NettyTransferService<HttpProtocolInfo> server, int chunkSize) { _server = server; _chunkSize = chunkSize; } private static ByteBuf createMultipartFragmentMarker(long lower, long upper, long total) { return Unpooled.copiedBuffer(CRLF + TWO_HYPHENS + BOUNDARY + CRLF + CONTENT_RANGE + ": " + BYTES + RANGE_SP + lower + RANGE_SEPARATOR + upper + RANGE_PRE_TOTAL + total + CRLF + CRLF, StandardCharsets.UTF_8); } private static ByteBuf createMultipartEnd() { return Unpooled.copiedBuffer(CRLF + TWO_HYPHENS + BOUNDARY + TWO_HYPHENS + CRLF, StandardCharsets.UTF_8); } private static String contentDisposition(HttpProtocolInfo.Disposition disposition, String filename) { StringBuilder sb = new StringBuilder(); sb.append(disposition.toString().toLowerCase()); appendDispositionParm(sb, "filename", filename); // REVISIT consider more info: creation-date, last-modified-date, size return sb.toString(); } private static void appendDispositionParm(StringBuilder sb, String name, String value) { sb.append(';'); // See RFC 2183 part 2. for description of when and how to encode if(value.length() > 78 || !CharMatcher.ascii().matchesAllOf(value)) { appendUsingRfc2231Encoding(sb, name, "UTF-8", null, value); } else if(TSPECIAL.matchesAnyOf(value)) { appendAsQuotedString(sb, name, value); } else { sb.append(name).append("=").append(value); } } // RFC 822 defines quoted-string: a simple markup using backslash private static void appendAsQuotedString(StringBuilder sb, String name, String value) { sb.append(name).append("="); quotedString(sb, value); } private static void appendUsingRfc2231Encoding(StringBuilder sb, String name, String charSet, String language, String value) { sb.append(name).append("*="); if(charSet != null) { sb.append(charSet); } sb.append('\''); if(language != null) { sb.append(language); } sb.append('\''); percentEncode(sb, value); } /* RFC 2616: 9.6. The recipient of the entity MUST NOT ignore any * Content-* (e.g. Content-Range) headers that it does not * understand or implement and MUST return a 501 (Not Implemented) * response in such cases. */ private static void checkContentHeader(Collection<String> headerNames, Collection<String> excludes) throws HttpException { outer: for (String headerName: headerNames) { if (headerName.toLowerCase().startsWith("content-")) { for (String exclude: excludes) { if (exclude.equalsIgnoreCase(headerName)) { continue outer; } } throw new HttpException(NOT_IMPLEMENTED.code(), headerName + " is not implemented"); } } } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { _logger.debug("HTTP connection from {} established", ctx.channel().remoteAddress()); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { _logger.debug("HTTP connection from {} closed", ctx.channel().remoteAddress()); for (NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file: _files) { if (file == _writeChannel) { file.release(new FileCorruptedCacheException("Connection lost before end of file.")); } else { file.release(); } } _files.clear(); } public void exceptionCaught(ChannelHandlerContext ctx, Throwable t) { if (t instanceof ClosedChannelException) { _logger.info("Connection {} unexpectedly closed.", ctx.channel()); } else if (t instanceof Exception) { for (NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file : _files) { CacheException cause; if (file == _writeChannel) { cause = new FileCorruptedCacheException("Connection lost before end of file: " + t, t); } else { cause = new CacheException(t.toString(), t); } file.release(cause); } _files.clear(); ctx.close(); } else { Thread me = Thread.currentThread(); me.getUncaughtExceptionHandler().uncaughtException(me, t); ctx.close(); } } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception { if (event instanceof IdleStateEvent) { IdleStateEvent idleStateEvent = (IdleStateEvent) event; if (idleStateEvent.state() == IdleState.ALL_IDLE) { if (_logger.isInfoEnabled()) { _logger.info("Connection from {} id idle; disconnecting.", ctx.channel().remoteAddress()); } ctx.close(); } } } /** * Single GET operation. * * Finds the correct mover channel using the UUID in the * GET. Range queries are supported. The file will be sent to the * remote peer in chunks to avoid server side memory issues. */ @Override protected ChannelFuture doOnGet(ChannelHandlerContext context, HttpRequest request) { NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file; List<HttpByteRange> ranges; long fileSize; try { file = open(request, false); if (file.getIoMode() != IoMode.READ) { throw new HttpException(METHOD_NOT_ALLOWED.code(), "Resource is not open for reading"); } fileSize = file.size(); ranges = parseHttpRange(request, 0, fileSize - 1); } catch (HttpException e) { return context.writeAndFlush(createErrorResponse(e.getErrorCode(), e.getMessage())); } catch (URISyntaxException e) { return context.writeAndFlush(createErrorResponse(BAD_REQUEST, "URI not valid: " + e.getMessage())); } catch (IllegalArgumentException e) { return context.writeAndFlush(createErrorResponse(BAD_REQUEST, e.getMessage())); } catch (IOException e) { return context.writeAndFlush(createErrorResponse(INTERNAL_SERVER_ERROR, e.getMessage())); } if (ranges == null || ranges.isEmpty()) { /* * GET for a whole file */ context.write(new HttpGetResponse(fileSize, file)) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); context.write(read(file, 0, fileSize - 1)) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); return context.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); } else if (ranges.size() == 1) { /* RFC 2616: 14.16. A response to a request for a single range * MUST NOT be sent using the multipart/byteranges media type. */ HttpByteRange range = ranges.get(0); context.write(new HttpPartialContentResponse(range.getLower(), range.getUpper(), fileSize, buildDigest(file))) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); context.write(read(file, range.getLower(), range.getUpper())) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); return context.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); } else { /* * GET for multiple ranges */ long totalLen = 0; ByteBuf[] fragmentMarkers = new ByteBuf[ranges.size()]; for (int i = 0; i < ranges.size(); i++) { HttpByteRange range = ranges.get(i); long upper = range.getUpper(); long lower = range.getLower(); totalLen += upper - lower + 1; ByteBuf buffer = fragmentMarkers[i] = createMultipartFragmentMarker(lower, upper, fileSize); totalLen += buffer.readableBytes(); } ByteBuf endMarker = createMultipartEnd(); totalLen += endMarker.readableBytes(); context.write(new HttpMultipartResponse(buildDigest(file), totalLen)) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); for (int i = 0; i < ranges.size(); i++) { HttpByteRange range = ranges.get(i); context.write(fragmentMarkers[i]) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); context.write(read(file, range.getLower(), range.getUpper())) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); } return context.writeAndFlush(new DefaultLastHttpContent(endMarker)); } } @Override protected ChannelFuture doOnPut(ChannelHandlerContext context, HttpRequest request) { NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file = null; Exception exception = null; try { checkContentHeader(request.headers().names(), Collections.singletonList(CONTENT_LENGTH)); file = open(request, true); if (file.getIoMode() != IoMode.WRITE) { throw new HttpException(METHOD_NOT_ALLOWED.code(), "Resource is not open for writing"); } if (is100ContinueExpected(request)) { context.writeAndFlush(new DefaultFullHttpResponse(HTTP_1_1, CONTINUE)) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); } _writeChannel = file; file = null; return null; } catch (HttpException e) { exception = e; return context.writeAndFlush( createErrorResponse(HttpResponseStatus.valueOf(e.getErrorCode()), e.getMessage())); } catch (URISyntaxException e) { exception = e; return context.writeAndFlush( createErrorResponse(BAD_REQUEST, "URI is not valid: " + e.getMessage())); } catch (IllegalArgumentException e) { exception = e; return context.writeAndFlush(createErrorResponse(BAD_REQUEST, e.getMessage())); } catch (RuntimeException e) { exception = e; return context.writeAndFlush(createErrorResponse(INTERNAL_SERVER_ERROR, e.getMessage())); } finally { if (file != null) { file.release(exception); _files.remove(file); } } } @Override protected ChannelFuture doOnContent(ChannelHandlerContext context, HttpContent content) { if (_writeChannel != null) { try { ByteBuf data = content.content(); while (data.isReadable()) { data.readBytes(_writeChannel, data.readableBytes()); } if (content instanceof LastHttpContent) { checkContentHeader(((LastHttpContent) content).trailingHeaders().names(), Collections.singletonList(CONTENT_LENGTH)); context.channel().config().setAutoRead(false); NettyTransferService<HttpProtocolInfo>.NettyMoverChannel writeChannel = _writeChannel; _writeChannel = null; _files.remove(writeChannel); long size = writeChannel.size(); URI location = writeChannel.getProtocolInfo().getLocation(); ChannelPromise promise = context.newPromise(); Futures.addCallback(writeChannel.release(), new FutureCallback<Void>() { @Override public void onSuccess(Void result) { try { context.writeAndFlush(new HttpPutResponse(size, location), promise); } catch (IOException e) { context.writeAndFlush(createErrorResponse(INTERNAL_SERVER_ERROR, e.getMessage()), promise); } context.channel().config().setAutoRead(true); } @Override public void onFailure(Throwable t) { if (t instanceof FileCorruptedCacheException) { context.writeAndFlush(createErrorResponse(BAD_REQUEST, t.getMessage()), promise); } else if (t instanceof CacheException) { context.writeAndFlush(createErrorResponse(INTERNAL_SERVER_ERROR, t.getMessage()), promise); } else { context.writeAndFlush(createErrorResponse(INTERNAL_SERVER_ERROR, t.toString()), promise); } context.channel().config().setAutoRead(true); } }, MoreExecutors.directExecutor()); return promise; } } catch (IOException e) { _writeChannel.release(e); _files.remove(_writeChannel); _writeChannel = null; return context.writeAndFlush(createErrorResponse(INTERNAL_SERVER_ERROR, e.getMessage())); } catch (HttpException e) { _writeChannel.release(e); _files.remove(_writeChannel); _writeChannel = null; return context.writeAndFlush(createErrorResponse(HttpResponseStatus.valueOf(e.getErrorCode()), e.getMessage())); } } return null; } @Override protected ChannelFuture doOnHead(ChannelHandlerContext context, HttpRequest request) { try { NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file = open(request, false); context.write(new HttpGetResponse(file.size(), file)) .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); return context.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); } catch (IOException | IllegalArgumentException e) { return context.writeAndFlush(createErrorResponse(BAD_REQUEST, e.getMessage())); } catch (URISyntaxException e) { return context.writeAndFlush(createErrorResponse(BAD_REQUEST, "URI not valid: " + e.getMessage())); } catch (RuntimeException e) { return context.writeAndFlush(createErrorResponse(INTERNAL_SERVER_ERROR, e.getMessage())); } } /** * Get the mover channel for a certain HTTP request. The mover * channel is identified by UUID generated upon mover start and * sent back to the door as a part of the address info. * * @param request HttpRequest that was sent by the client * @param exclusive True if the mover channel exclusively is to be opened * in exclusive mode. False if the mover channel can be * shared with other requests. * @return Mover channel for specified UUID * @throws IllegalArgumentException Request did not include UUID or no * mover channel found for UUID in the request */ private NettyTransferService<HttpProtocolInfo>.NettyMoverChannel open(HttpRequest request, boolean exclusive) throws IllegalArgumentException, URISyntaxException { QueryStringDecoder queryStringDecoder = new QueryStringDecoder(request.getUri()); Map<String, List<String>> params = queryStringDecoder.parameters(); if (!params.containsKey(HttpTransferService.UUID_QUERY_PARAM)) { if(!request.getUri().equals("/favicon.ico")) { _logger.error("Received request without UUID in the query " + "string. Request-URI was {}", request.getUri()); } throw new IllegalArgumentException("Query string does not include any UUID."); } List<String> uuidList = params.get(HttpTransferService.UUID_QUERY_PARAM); if (uuidList.isEmpty()) { throw new IllegalArgumentException("UUID parameter does not include any value."); } UUID uuid = UUID.fromString(uuidList.get(0)); NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file = _server.openFile(uuid, exclusive); if (file == null) { throw new IllegalArgumentException("Request is no longer valid. " + "Please resubmit to door."); } URI uri = new URI(request.getUri()); FsPath requestedFile = FsPath.create(uri.getPath()); FsPath transferFile = FsPath.create(file.getProtocolInfo().getPath()); if (!requestedFile.equals(transferFile)) { _logger.warn("Received an illegal request for file {}, while serving {}", requestedFile, transferFile); throw new IllegalArgumentException("The file you specified does " + "not match the UUID you specified!"); } _files.add(file); return file; } /** * Read the resources requested in HTTP-request from the pool. Return a * ChunkedInput pointing to the requested portions of the file. * * Renew the keep-alive heartbeat, meaning that the last transferred time * will be updated, resetting the keep-alive timeout. * * @param file the mover channel to read from * @param lowerRange The lower delimiter of the requested byte range of the * file * @param upperRange The upper delimiter of the requested byte range of the * file * @return ChunkedInput View upon the file suitable for sending with * netty and representing the requested parts. */ private ChunkedInput read(NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file, long lowerRange, long upperRange) { /* need to count position 0 as well */ long length = (upperRange - lowerRange) + 1; return new ReusableChunkedNioFile(file, lowerRange, length, _chunkSize); } private static String buildDigest(NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file) { FileAttributes attributes = file.getFileAttributes(); return attributes.getChecksumsIfPresent().transform(TO_RFC3230).or(""); } private static class HttpGetResponse extends DefaultHttpResponse { public HttpGetResponse(long fileSize, NettyTransferService<HttpProtocolInfo>.NettyMoverChannel file) { super(HTTP_1_1, OK); HttpProtocolInfo protocolInfo = file.getProtocolInfo(); headers().add(ACCEPT_RANGES, BYTES); headers().add(CONTENT_LENGTH, fileSize); String digest = buildDigest(file); if(!digest.isEmpty()) { headers().add(DIGEST, digest); } headers().add("Content-Disposition", contentDisposition(protocolInfo.getDisposition(), FsPath.create(protocolInfo.getPath()).name())); if (protocolInfo.getLocation() != null) { headers().add(CONTENT_LOCATION, protocolInfo.getLocation()); } } } private static class HttpPartialContentResponse extends DefaultHttpResponse { public HttpPartialContentResponse(long lower, long upper, long total, String digest) { super(HTTP_1_1, PARTIAL_CONTENT); String contentRange = BYTES + RANGE_SP + lower + RANGE_SEPARATOR + upper + RANGE_PRE_TOTAL + total; headers().add(ACCEPT_RANGES, BYTES); headers().add(CONTENT_LENGTH, String.valueOf((upper - lower) + 1)); headers().add(CONTENT_RANGE, contentRange); if (!digest.isEmpty()) { headers().add(DIGEST, digest); } } } private static class HttpMultipartResponse extends DefaultHttpResponse { public HttpMultipartResponse(String digest, long totalBytes) { super(HTTP_1_1, PARTIAL_CONTENT); headers().add(ACCEPT_RANGES, BYTES); headers().add(CONTENT_LENGTH, totalBytes); headers().add(CONTENT_TYPE, MULTIPART_TYPE); if(!digest.isEmpty()) { headers().add(DIGEST, digest); } } } private static class HttpPutResponse extends HttpTextResponse { public HttpPutResponse(long size, URI location) throws IOException { /* RFC 2616: 9.6. If a new resource is created, the origin server * MUST inform the user agent via the 201 (Created) response. */ /* RFC 2616: 10.2.2. The newly created resource can be referenced * by the URI(s) returned in the entity of the response, with the * most specific URI for the resource given by a Location header * field. The response SHOULD include an entity containing a list * of resource characteristics and location(s) from which the user * or user agent can choose the one most appropriate. The entity * format is specified by the media type given in the Content-Type * header field. */ super(CREATED, size + " bytes uploaded\r\n"); /* RFC 2616: 14.30. For 201 (Created) responses, the Location is * that of the new resource which was created by the request. */ if (location != null) { headers().set(LOCATION, location); } } } }