/*
* RESTHeart - the Web API for MongoDB
* Copyright (C) SoftInstigate Srl
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.restheart.handlers.injectors;
import com.mongodb.util.JSONParseException;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.form.FormData;
import io.undertow.server.handlers.form.FormDataParser;
import io.undertow.server.handlers.form.FormParserFactory;
import io.undertow.util.HeaderValues;
import io.undertow.util.Headers;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Iterator;
import org.apache.tika.Tika;
import org.bson.BsonArray;
import org.bson.BsonDocument;
import org.bson.BsonString;
import org.bson.BsonValue;
import org.bson.json.JsonParseException;
import org.restheart.hal.Representation;
import org.restheart.hal.UnsupportedDocumentIdException;
import org.restheart.handlers.PipedHttpHandler;
import org.restheart.handlers.RequestContext;
import org.restheart.utils.ChannelReader;
import org.restheart.utils.HttpStatus;
import org.restheart.utils.JsonUtils;
import org.restheart.utils.ResponseHelper;
import org.restheart.utils.URLUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* injects the request body in RequestContext also check the Content-Type header
* in case body is not empty
*
* @author Andrea Di Cesare {@literal <andrea@softinstigate.com>}
*/
public class BodyInjectorHandler extends PipedHttpHandler {
static final Logger LOGGER
= LoggerFactory.getLogger(BodyInjectorHandler.class);
static final String PROPERTIES = "properties";
static final String FILE_METADATA = "metadata";
static final String _ID = "_id";
static final String CONTENT_TYPE = "contentType";
static final String FILENAME = "filename";
private static final String ERROR_INVALID_CONTENTTYPE
= "Content-Type must be either: "
+ Representation.HAL_JSON_MEDIA_TYPE
+ " or " + Representation.JSON_MEDIA_TYPE;
private static final String ERROR_INVALID_CONTENTTYPE_FILE
= "Content-Type must be either: "
+ Representation.APP_FORM_URLENCODED_TYPE
+ " or " + Representation.MULTIPART_FORM_DATA_TYPE;
private final FormParserFactory formParserFactory;
/**
* Creates a new instance of BodyInjectorHandler
*
* @param next
*/
public BodyInjectorHandler(PipedHttpHandler next) {
super(next);
this.formParserFactory = FormParserFactory.builder().build();
}
/**
*
* @param exchange
* @param context
* @throws Exception
*/
@Override
public void handleRequest(
final HttpServerExchange exchange,
final RequestContext context)
throws Exception {
if (context.isInError()) {
next(exchange, context);
return;
}
if (context.getMethod() == RequestContext.METHOD.GET
|| context.getMethod() == RequestContext.METHOD.OPTIONS
|| context.getMethod() == RequestContext.METHOD.DELETE) {
next(exchange, context);
return;
}
BsonValue content;
if ((isPutRequest(context) && isFileRequest(context))
|| (isPostRequest(context) && isFilesBucketRequest(context))) {
// check content type
if (unsupportedContentTypeForFiles(exchange
.getRequestHeaders()
.get(Headers.CONTENT_TYPE))) {
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_UNSUPPORTED_MEDIA_TYPE,
ERROR_INVALID_CONTENTTYPE_FILE);
next(exchange, context);
return;
}
FormDataParser parser
= this.formParserFactory.createParser(exchange);
if (parser == null) {
String errMsg = "There is no form parser registered "
+ "for the request content type";
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
errMsg);
next(exchange, context);
return;
}
FormData formData;
try {
formData = parser.parseBlocking();
} catch (IOException ioe) {
String errMsg = "Error parsing the multipart form: "
+ "data could not be read";
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
errMsg,
ioe);
next(exchange, context);
return;
}
try {
content = extractMetadata(formData);
} catch (JSONParseException | IllegalArgumentException ex) {
String errMsg = "Invalid data: "
+ "'properties' field is not a valid JSON";
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
errMsg,
ex);
next(exchange, context);
return;
}
final String fileField = extractFileField(formData);
if (fileField == null) {
String errMsg = "This request does not contain any binary file";
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
errMsg);
next(exchange, context);
return;
}
final Path path = formData.getFirst(fileField).getPath();
context.setFilePath(path);
injectContentTypeFromFile(content.asDocument(), path.toFile());
} else {
// get and parse the content
final String contentString
= ChannelReader.read(exchange.getRequestChannel());
context.setRawContent(contentString);
if (contentString != null
&& !contentString.isEmpty()) { // check content type
if (unsupportedContentType(exchange
.getRequestHeaders()
.get(Headers.CONTENT_TYPE))) {
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_UNSUPPORTED_MEDIA_TYPE,
ERROR_INVALID_CONTENTTYPE);
next(exchange, context);
return;
}
try {
content = JsonUtils.parse(contentString);
if (content != null
&& !content.isDocument()
&& !content.isArray()) {
throw new IllegalArgumentException(
"request data must be either a json object "
+ "or an array"
+ ", got " + (content == null
? " no data"
: "" + content.getBsonType().name()));
}
} catch (JsonParseException | IllegalArgumentException ex) {
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
"Invalid JSON",
ex);
next(exchange, context);
return;
}
} else {
content = null;
}
}
if (content == null) {
content = new BsonDocument();
} else if (content.isArray()) {
if (context.getType() != RequestContext.TYPE.COLLECTION
|| (context.getMethod() != RequestContext.METHOD.POST)) {
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
"request data can be an array only "
+ "for POST to collection resources "
+ "(bulk post)");
next(exchange, context);
return;
}
if (!content.asArray().stream().anyMatch(_doc -> {
if (_doc.isDocument()) {
BsonValue _id = _doc.asDocument().get(_ID);
try {
checkIdType(_doc.asDocument());
} catch (UnsupportedDocumentIdException udie) {
String errMsg = "the type of _id in request data"
+ " is not supported: "
+ (_id == null
? ""
: _id.getBsonType().name());
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
errMsg,
udie);
return false;
}
filterJsonContent(_doc.asDocument(), context);
return true;
} else {
String errMsg = "request data must be either "
+ "an json object or an array of objects";
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
errMsg);
return false;
}
})) {
// an error occurred
next(exchange, context);
return;
}
} else if (content.isDocument()) {
BsonDocument _content = content.asDocument();
BsonValue _id = _content.get(_ID);
try {
checkIdType(_content);
} catch (UnsupportedDocumentIdException udie) {
String errMsg = "the type of _id in request data "
+ "is not supported: "
+ (_id == null
? ""
: _id.getBsonType().name());
ResponseHelper.endExchangeWithMessage(
exchange,
context,
HttpStatus.SC_NOT_ACCEPTABLE,
errMsg,
udie);
next(exchange, context);
return;
}
filterJsonContent(_content, context);
}
context.setContent(content);
next(exchange, context);
}
private BsonValue checkIdType(BsonDocument doc)
throws UnsupportedDocumentIdException {
if (doc.containsKey(_ID)) {
BsonValue _id = doc.get(_ID);
URLUtils.checkId(_id);
return _id;
} else {
return null;
}
}
private static boolean isFilesBucketRequest(final RequestContext context) {
return context.getType() == RequestContext.TYPE.FILES_BUCKET;
}
private static boolean isFileRequest(final RequestContext context) {
return context.getType() == RequestContext.TYPE.FILE;
}
private static boolean isPostRequest(final RequestContext context) {
return context.getMethod() == RequestContext.METHOD.POST;
}
private static boolean isPutRequest(final RequestContext context) {
return context.getMethod() == RequestContext.METHOD.PUT;
}
/**
* Checks the _id in POST requests; it cannot be a string having a special
* meaning e.g _null, since the URI /db/coll/_null refers to the document
* with _id: null
*
* @param content
* @return null if ok, or the first not valid id
*/
public static String checkReservedId(BsonValue content) {
if (content == null) {
return null;
} else if (content.isDocument()) {
BsonValue id = content.asDocument().get("_id");
if (id == null || !id.isString()) {
return null;
}
String _id = id.asString().getValue();
if (RequestContext.MAX_KEY_ID.equalsIgnoreCase(_id)
|| RequestContext.MIN_KEY_ID.equalsIgnoreCase(_id)
|| RequestContext.NULL_KEY_ID.equalsIgnoreCase(_id)
|| RequestContext.TRUE_KEY_ID.equalsIgnoreCase(_id)
|| RequestContext.FALSE_KEY_ID.equalsIgnoreCase(_id)) {
return _id;
} else {
return null;
}
} else if (content.isArray()) {
BsonArray arrayContent = content.asArray();
Iterator<BsonValue> objs = arrayContent.getValues().iterator();
String ret = null;
while (objs.hasNext()) {
BsonValue obj = objs.next();
if (obj.isDocument()) {
ret = checkReservedId(obj);
if (ret != null) {
break;
}
} else {
LOGGER.warn("element of content array is not an object");
}
}
return ret;
}
LOGGER.warn("content is not an object nor an array");
return null;
}
/**
* Clean-up the JSON content, filtering out reserved keys
*
* @param content
* @param ctx
*/
private static void filterJsonContent(
final BsonDocument content,
final RequestContext ctx) {
filterOutReservedKeys(content, ctx);
}
/**
* Filter out reserved keys, removing them from request
*
* The _ prefix is reserved for RESTHeart-generated properties (_id is
* allowed)
*
* @param content
* @param context
*/
private static void filterOutReservedKeys(
final BsonDocument content,
final RequestContext context) {
final HashSet<String> keysToRemove = new HashSet<>();
content.keySet().stream()
.filter(key -> key.startsWith("_") && !key.equals(_ID))
.forEach(key -> {
keysToRemove.add(key);
});
keysToRemove.stream().map(keyToRemove -> {
content.remove(keyToRemove);
return keyToRemove;
}).forEach(keyToRemove -> {
context.addWarning("Reserved field "
+ keyToRemove
+ " was filtered out from the request");
});
}
private static void injectContentTypeFromFile(
final BsonDocument content,
final File file)
throws IOException {
if (content.get(CONTENT_TYPE) == null && file != null) {
final String contentType = detectMediaType(file);
if (contentType != null) {
content.append(CONTENT_TYPE,
new BsonString(contentType));
}
}
}
/**
* true is the content-type is unsupported
*
* @param contentTypes
* @return
*/
private static boolean unsupportedContentType(
final HeaderValues contentTypes) {
return contentTypes == null
|| contentTypes.isEmpty()
|| contentTypes.stream().noneMatch(ct -> ct.startsWith(Representation.HAL_JSON_MEDIA_TYPE)
|| ct.startsWith(Representation.JSON_MEDIA_TYPE));
}
/**
* true is the content-type is unsupported
*
* @param contentTypes
* @return
*/
private static boolean unsupportedContentTypeForFiles(
final HeaderValues contentTypes) {
return contentTypes == null
|| contentTypes.isEmpty()
|| contentTypes.stream().noneMatch(ct -> ct.startsWith(Representation.APP_FORM_URLENCODED_TYPE)
|| ct.startsWith(Representation.MULTIPART_FORM_DATA_TYPE));
}
/**
* Search the request for a field named 'metadata' (or 'properties') which
* must contain valid JSON
*
* @param formData
* @return the parsed BsonDocument from the form data or an empty
* BsonDocument
*/
protected static BsonDocument extractMetadata(
final FormData formData)
throws JSONParseException {
BsonDocument metadata = new BsonDocument();
final String metadataString;
metadataString = formData.getFirst(FILE_METADATA) != null
? formData.getFirst(FILE_METADATA).getValue()
: formData.getFirst(PROPERTIES) != null
? formData.getFirst(PROPERTIES).getValue()
: null;
if (metadataString != null) {
metadata = BsonDocument.parse(metadataString);
}
return metadata;
}
/**
* Find the name of the first file field in this request
*
* @param formData
* @return the first file field name or null
*/
private static String extractFileField(final FormData formData) {
String fileField = null;
for (String f : formData) {
if (formData.getFirst(f) != null && formData.getFirst(f).isFile()) {
fileField = f;
break;
}
}
return fileField;
}
/**
* Detect the file's mediatype
*
* @param file input file
* @return the content-type as a String
* @throws IOException
*/
public static String detectMediaType(File file) throws IOException {
return new Tika().detect(file);
}
}