/*
* Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
* license agreements. See the NOTICE file distributed with this work for
* additional information regarding copyright ownership. Crate licenses
* this file to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may
* obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* However, if you have executed another commercial license agreement
* with Crate these terms will supersede the license and you may use the
* software solely pursuant to the terms of the relevant commercial agreement.
*/
package io.crate.http.netty;
import io.crate.blob.BlobService;
import io.crate.blob.DigestBlob;
import io.crate.blob.RemoteDigestBlob;
import io.crate.blob.exceptions.DigestMismatchException;
import io.crate.blob.exceptions.DigestNotFoundException;
import io.crate.blob.exceptions.MissingHTTPEndpointException;
import io.crate.blob.v2.BlobIndex;
import io.crate.blob.v2.BlobIndicesService;
import io.crate.blob.v2.BlobShard;
import io.crate.blob.v2.BlobsDisabledException;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.common.logging.Loggers;
import org.elasticsearch.common.util.concurrent.EsRejectedExecutionException;
import org.elasticsearch.index.IndexNotFoundException;
import org.jboss.netty.buffer.ChannelBuffer;
import org.jboss.netty.buffer.ChannelBuffers;
import org.jboss.netty.channel.*;
import org.jboss.netty.handler.codec.http.*;
import org.jboss.netty.handler.codec.http.HttpResponseStatus;
import org.jboss.netty.util.CharsetUtil;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.ClosedChannelException;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.jboss.netty.channel.Channels.succeededFuture;
import static org.jboss.netty.channel.Channels.write;
import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*;
import static org.jboss.netty.handler.codec.http.HttpResponseStatus.*;
import static org.jboss.netty.handler.codec.http.HttpVersion.HTTP_1_1;
public class HttpBlobHandler extends SimpleChannelUpstreamHandler implements LifeCycleAwareChannelHandler {
private static final String SCHEME = "http://";
private static final String CACHE_CONTROL_VALUE = "max-age=315360000";
private static final String EXPIRES_VALUE = "Thu, 31 Dec 2037 23:59:59 GMT";
private static final String BLOBS_ENDPOINT = "/_blobs";
public static final Pattern BLOBS_PATTERN = Pattern.compile(String.format(Locale.ENGLISH, "^%s/([^_/][^/]*)/([0-9a-f]{40})$", BLOBS_ENDPOINT));
private static final Logger LOGGER = Loggers.getLogger(HttpBlobHandler.class);
private static final ChannelBuffer CONTINUE = ChannelBuffers.copiedBuffer(
"HTTP/1.1 100 Continue\r\n\r\n", CharsetUtil.US_ASCII);
private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("^bytes=(\\d+)-(\\d*)$");
private final Matcher blobsMatcher = BLOBS_PATTERN.matcher("");
private final BlobService blobService;
private final BlobIndicesService blobIndicesService;
private HttpMessage currentMessage;
private ChannelHandlerContext ctx;
private RemoteDigestBlob digestBlob;
public HttpBlobHandler(BlobService blobService, BlobIndicesService blobIndicesService) {
this.blobService = blobService;
this.blobIndicesService = blobIndicesService;
}
private boolean possibleRedirect(HttpRequest request, String index, String digest) {
HttpMethod method = request.getMethod();
if (method.equals(HttpMethod.GET) ||
method.equals(HttpMethod.HEAD) ||
(method.equals(HttpMethod.PUT) &&
HttpHeaders.is100ContinueExpected(request))) {
String redirectAddress;
try {
redirectAddress = blobService.getRedirectAddress(index, digest);
} catch (MissingHTTPEndpointException ex) {
simpleResponse(HttpResponseStatus.BAD_GATEWAY);
return true;
}
if (redirectAddress != null) {
LOGGER.trace("redirectAddress: {}", redirectAddress);
sendRedirect(SCHEME + redirectAddress);
return true;
}
}
return false;
}
@Override
public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
Object msg = e.getMessage();
if (msg instanceof HttpRequest) {
HttpRequest request = (HttpRequest) msg;
currentMessage = request;
String uri = request.getUri();
if (!uri.startsWith(BLOBS_ENDPOINT)) {
reset();
ctx.sendUpstream(e);
return;
}
Matcher matcher = blobsMatcher.reset(uri);
if (!matcher.matches()) {
simpleResponse(HttpResponseStatus.NOT_FOUND);
reset();
return;
}
handleBlobRequest(request, matcher);
} else if (msg instanceof HttpChunk) {
if (currentMessage == null) {
// the chunk is probably from a regular non-blob request.
ctx.sendUpstream(e);
return;
}
HttpChunk chunk = (HttpChunk) msg;
writeToFile(chunk.getContent(), chunk.isLast(), false);
if (chunk.isLast()) {
reset();
}
} else {
// Neither HttpMessage or HttpChunk
ctx.sendUpstream(e);
}
}
private void handleBlobRequest(HttpRequest request, Matcher matcher) throws IOException {
digestBlob = null;
String index = matcher.group(1);
String digest = matcher.group(2);
LOGGER.trace("matches index:{} digest:{}", index, digest);
LOGGER.trace("HTTPMessage:%n{}", request);
index = BlobIndex.fullIndexName(index);
if (possibleRedirect(request, index, digest)) {
reset();
return;
}
if (request.getMethod().equals(HttpMethod.GET)) {
get(request, index, digest);
reset();
} else if (request.getMethod().equals(HttpMethod.HEAD)) {
head(index, digest);
reset();
} else if (request.getMethod().equals(HttpMethod.PUT)) {
put(request, index, digest);
} else if (request.getMethod().equals(HttpMethod.DELETE)) {
delete(index, digest);
reset();
} else {
simpleResponse(HttpResponseStatus.METHOD_NOT_ALLOWED);
reset();
}
}
private void reset() {
currentMessage = null;
}
private void sendRedirect(String newUri) {
HttpResponse response = prepareResponse(TEMPORARY_REDIRECT);
response.headers().add(HttpHeaders.Names.LOCATION, newUri);
sendResponse(response);
}
private HttpResponse prepareResponse(HttpResponseStatus status) {
HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status);
HttpHeaders.setContentLength(response, 0);
if (currentMessage == null || !HttpHeaders.isKeepAlive(currentMessage)) {
response.headers().set(CONNECTION, "close");
}
return response;
}
private void simpleResponse(HttpResponseStatus status) {
sendResponse(prepareResponse(status));
}
private void simpleResponse(HttpResponseStatus status, String body) {
if (body == null) {
simpleResponse(status);
return;
}
HttpResponse response = prepareResponse(status);
if (!body.endsWith("\n")) {
body += "\n";
}
HttpHeaders.setContentLength(response, body.length());
response.setContent(ChannelBuffers.copiedBuffer(body, CharsetUtil.UTF_8));
sendResponse(response);
}
private void sendResponse(HttpResponse response) {
ChannelFuture cf = ctx.getChannel().write(response);
if (currentMessage != null && !HttpHeaders.isKeepAlive(currentMessage)) {
cf.addListener(ChannelFutureListener.CLOSE);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception {
Throwable ex = e.getCause();
if (ex instanceof ClosedChannelException) {
LOGGER.trace("channel closed: {}", ex.toString());
return;
} else if (ex instanceof IOException) {
String message = ex.getMessage();
if (message != null && message.contains("Connection reset by peer")) {
LOGGER.debug(message);
} else {
LOGGER.warn(message, e);
}
return;
}
HttpResponseStatus status;
String body = null;
if (ex instanceof DigestMismatchException || ex instanceof BlobsDisabledException
|| ex instanceof IllegalArgumentException) {
status = HttpResponseStatus.BAD_REQUEST;
body = String.format(Locale.ENGLISH, "Invalid request sent: %s", ex.getMessage());
} else if (ex instanceof DigestNotFoundException || ex instanceof IndexNotFoundException) {
status = HttpResponseStatus.NOT_FOUND;
} else if (ex instanceof EsRejectedExecutionException) {
status = TOO_MANY_REQUESTS;
body = String.format(Locale.ENGLISH, "Rejected execution: %s", ex.getMessage());
} else {
status = HttpResponseStatus.INTERNAL_SERVER_ERROR;
body = String.format(Locale.ENGLISH, "Unhandled exception: %s", ex);
}
if (body != null) {
LOGGER.debug(body);
}
simpleResponse(status, body);
}
private void head(String index, String digest) throws IOException {
// this method only supports local mode, which is ok, since there
// should be a redirect upfront if data is not local
BlobShard blobShard = localBlobShard(index, digest);
long length = blobShard.blobContainer().getFile(digest).length();
if (length < 1) {
simpleResponse(HttpResponseStatus.NOT_FOUND);
return;
}
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
HttpHeaders.setContentLength(response, length);
setDefaultGetHeaders(response);
sendResponse(response);
}
private void get(HttpRequest request, String index, final String digest) throws IOException {
String range = request.headers().get(RANGE);
if (range != null) {
partialContentResponse(range, request, index, digest);
} else {
fullContentResponse(request, index, digest);
}
}
private BlobShard localBlobShard(String index, String digest) {
return blobIndicesService.localBlobShard(index, digest);
}
private void partialContentResponse(String range, HttpRequest request, String index, final String digest)
throws IOException {
assert range != null : "Getting partial response but no byte-range is not present.";
Matcher matcher = CONTENT_RANGE_PATTERN.matcher(range);
if (!matcher.matches()) {
LOGGER.warn("Invalid byte-range: {}; returning full content", range);
fullContentResponse(request, index, digest);
return;
}
BlobShard blobShard = localBlobShard(index, digest);
final RandomAccessFile raf = blobShard.blobContainer().getRandomAccessFile(digest);
long start;
long end;
try {
try {
start = Long.parseLong(matcher.group(1));
if (start > raf.length()) {
LOGGER.warn("416 Requested Range not satisfiable");
simpleResponse(HttpResponseStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
raf.close();
return;
}
end = raf.length() - 1;
if (!matcher.group(2).equals("")) {
end = Long.parseLong(matcher.group(2));
}
} catch (NumberFormatException ex) {
LOGGER.error("Couldn't parse Range Header", ex);
start = 0;
end = raf.length();
}
HttpResponse response = prepareResponse(PARTIAL_CONTENT);
HttpHeaders.setContentLength(response, end - start + 1);
response.headers().set(CONTENT_RANGE, "bytes " + start + "-" + end + "/" + raf.length());
setDefaultGetHeaders(response);
ctx.getChannel().write(response);
ChannelFuture writeFuture = transferFile(digest, raf, start, end - start + 1);
if (!HttpHeaders.isKeepAlive(request)) {
writeFuture.addListener(ChannelFutureListener.CLOSE);
}
} catch (Throwable t) {
/*
* Make sure RandomAccessFile is closed when exception is raised.
* In case of success, the ChannelFutureListener in "transferFile" will take care
* that the resources are released.
*/
raf.close();
throw t;
}
}
private void fullContentResponse(HttpRequest request, String index, final String digest) throws IOException {
BlobShard blobShard = localBlobShard(index, digest);
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
final RandomAccessFile raf = blobShard.blobContainer().getRandomAccessFile(digest);
try {
HttpHeaders.setContentLength(response, raf.length());
setDefaultGetHeaders(response);
LOGGER.trace("HttpResponse: {}", response);
Channel channel = ctx.getChannel();
channel.write(response);
ChannelFuture writeFuture;
writeFuture = transferFile(digest, raf, 0, raf.length());
if (!HttpHeaders.isKeepAlive(request)) {
writeFuture.addListener(ChannelFutureListener.CLOSE);
}
} catch (Throwable t) {
/*
* Make sure RandomAccessFile is closed when exception is raised.
* In case of success, the ChannelFutureListener in "transferFile" will take care
* that the resources are released.
*/
raf.close();
throw t;
}
}
private ChannelFuture transferFile(final String digest, RandomAccessFile raf, long position, long count)
throws IOException {
final FileRegion region = new DefaultFileRegion(raf.getChannel(), position, count);
ChannelFuture writeFuture = ctx.getChannel().write(region);
writeFuture.addListener(new ChannelFutureProgressListener() {
@Override
public void operationProgressed(ChannelFuture future, long amount, long current, long total) throws Exception {
LOGGER.debug("{}: {} / {} (+{})", digest, current, total, amount);
}
@Override
public void operationComplete(ChannelFuture future) throws Exception {
region.releaseExternalResources();
LOGGER.trace("file transfer completed");
}
});
return writeFuture;
}
private void setDefaultGetHeaders(HttpResponse response) {
response.headers().set(ACCEPT_RANGES, "bytes");
response.headers().set(EXPIRES, EXPIRES_VALUE);
response.headers().set(CACHE_CONTROL, CACHE_CONTROL_VALUE);
}
private void put(HttpRequest request, String index, String digest) throws IOException {
if (digestBlob != null) {
throw new IllegalStateException(
"received new PUT Request " + HttpRequest.class.getSimpleName() +
"with existing " + DigestBlob.class.getSimpleName());
}
// shortcut check if the file existsLocally locally, so we can immediatly return
// if (blobService.existsLocally(digest)) {
// simpleResponse(HttpResponseStatus.CONFLICT, null);
//}
// TODO: Respond with 413 Request Entity Too Large
digestBlob = blobService.newBlob(index, digest);
if (request.isChunked()) {
writeToFile(request.getContent(), false, HttpHeaders.is100ContinueExpected(request));
} else {
writeToFile(request.getContent(), true, HttpHeaders.is100ContinueExpected(request));
reset();
}
}
private void delete(String index, String digest) throws IOException {
digestBlob = blobService.newBlob(index, digest);
if (digestBlob.delete()) {
// 204 for success
simpleResponse(HttpResponseStatus.NO_CONTENT);
} else {
simpleResponse(HttpResponseStatus.NOT_FOUND);
}
}
protected void writeToFile(ChannelBuffer input, boolean last, final boolean continueExpected) throws
IOException {
if (digestBlob == null) {
throw new IllegalStateException("digestBlob is null in writeToFile");
}
RemoteDigestBlob.Status status = digestBlob.addContent(input, last);
HttpResponseStatus exitStatus = null;
switch (status) {
case FULL:
exitStatus = HttpResponseStatus.CREATED;
break;
case PARTIAL:
// tell the client to continue
if (continueExpected) {
write(ctx, succeededFuture(ctx.getChannel()), CONTINUE.duplicate());
}
return;
case MISMATCH:
exitStatus = HttpResponseStatus.BAD_REQUEST;
break;
case EXISTS:
exitStatus = HttpResponseStatus.CONFLICT;
break;
case FAILED:
exitStatus = HttpResponseStatus.INTERNAL_SERVER_ERROR;
break;
}
assert exitStatus != null : "exitStatus should not be null";
LOGGER.trace("writeToFile exit status http:{} blob: {}", exitStatus, status);
simpleResponse(exitStatus);
}
public void beforeAdd(ChannelHandlerContext ctx) throws Exception {
this.ctx = ctx;
}
public void afterAdd(ChannelHandlerContext ctx) throws Exception {
// noop
}
public void beforeRemove(ChannelHandlerContext ctx) throws Exception {
// noop
}
public void afterRemove(ChannelHandlerContext ctx) throws Exception {
// noop
}
}