/*
* (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others.
*
* Licensed 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.
*
* Contributors:
* Antoine Taillefer <ataillefer@nuxeo.com>
*/
package org.nuxeo.ecm.restapi.server.jaxrs;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.mail.MessagingException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.Response.Status.Family;
import javax.ws.rs.core.Response.StatusType;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.NumberUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.codehaus.jackson.map.ObjectMapper;
import org.nuxeo.ecm.automation.OperationContext;
import org.nuxeo.ecm.automation.jaxrs.io.operations.ExecutionRequest;
import org.nuxeo.ecm.automation.server.jaxrs.ResponseHelper;
import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchFileEntry;
import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchManager;
import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchManagerConstants;
import org.nuxeo.ecm.core.api.Blob;
import org.nuxeo.ecm.core.api.CoreSession;
import org.nuxeo.ecm.core.api.NuxeoException;
import org.nuxeo.ecm.webengine.WebException;
import org.nuxeo.ecm.webengine.forms.FormData;
import org.nuxeo.ecm.webengine.jaxrs.context.RequestContext;
import org.nuxeo.ecm.webengine.model.WebObject;
import org.nuxeo.ecm.webengine.model.impl.AbstractResource;
import org.nuxeo.ecm.webengine.model.impl.ResourceTypeImpl;
import org.nuxeo.runtime.api.Framework;
import org.nuxeo.runtime.transaction.TransactionHelper;
/**
* Batch upload endpoint.
* <p>
* Replaces the deprecated endpoints listed below:
* <ul>
* <li>POST /batch/upload, see org.nuxeo.ecm.automation.server.jaxrs.batch.BatchResource#doPost(HttpServletRequest), use
* POST /upload/{batchId}/{fileIdx} instead, see {@link #upload(HttpServletRequest, String, String)}</li>
* <li>GET /batch/files/{batchId}, see org.nuxeo.ecm.automation.server.jaxrs.batch.BatchResource#getFilesBatch(String),
* use GET /upload/{batchId} instead, see {@link #getBatchInfo(String)} instead</li>
* <li>GET /batch/drop/{batchId}, see org.nuxeo.ecm.automation.server.jaxrs.batch.BatchResource#dropBatch(String), use
* DELETE /upload/{batchId} instead, see {@link #dropBatch(String)}</li>
* </ul>
* Also provides new endpoints:
* <ul>
* <li>POST /upload, see {@link #initBatch()}</li>
* <li>GET /upload/{batchId}/{fileIdx}, see {@link #getFileInfo(String, String)}</li>
* </ul>
* Largely inspired by the excellent Google Drive REST API documentation about <a
* href="https://developers.google.com/drive/web/manage-uploads#resumable">resumable upload</a>.
*
* @since 7.4
*/
@WebObject(type = "upload")
public class BatchUploadObject extends AbstractResource<ResourceTypeImpl> {
protected static final Log log = LogFactory.getLog(BatchUploadObject.class);
protected static final String REQUEST_BATCH_ID = "batchId";
protected static final String REQUEST_FILE_IDX = "fileIdx";
protected static final String OPERATION_ID = "operationId";
public static final String UPLOAD_TYPE_NORMAL = "normal";
public static final String UPLOAD_TYPE_CHUNKED = "chunked";
@POST
public Response initBatch() throws IOException {
BatchManager bm = Framework.getService(BatchManager.class);
String batchId = bm.initBatch();
Map<String, String> result = new HashMap<String, String>();
result.put("batchId", batchId);
return buildResponse(Status.CREATED, result);
}
@POST
@Path("{batchId}/{fileIdx}")
public Response upload(@Context HttpServletRequest request, @PathParam(REQUEST_BATCH_ID) String batchId,
@PathParam(REQUEST_FILE_IDX) String fileIdx) throws IOException {
TransactionHelper.commitOrRollbackTransaction();
try {
return uploadNoTransaction(request, batchId, fileIdx);
} finally {
TransactionHelper.startTransaction();
}
}
protected Response uploadNoTransaction(@Context HttpServletRequest request,
@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx)
throws IOException {
BatchManager bm = Framework.getService(BatchManager.class);
if (!bm.hasBatch(batchId)) {
return buildEmptyResponse(Status.NOT_FOUND);
}
// Check file index parameter
if (!NumberUtils.isDigits(fileIdx)) {
return buildTextResponse(Status.BAD_REQUEST, "fileIdx request path parameter must be a number");
}
boolean isMultipart = false;
// Parameters are passed as request header, the request body is the stream
String contentType = request.getHeader("Content-Type");
String uploadType = request.getHeader("X-Upload-Type");
String contentLength = request.getHeader("Content-Length");
String uploadChunkIndex = request.getHeader("X-Upload-Chunk-Index");
String chunkCount = request.getHeader("X-Upload-Chunk-Count");
String fileName = request.getHeader("X-File-Name");
String fileSize = request.getHeader("X-File-Size");
String mimeType = request.getHeader("X-File-Type");
InputStream is = null;
BatchFileEntry fileEntry = null;
String uploadedSize = "0";
long contentLengthAsLong = -1;
if (contentLength != null) {
contentLengthAsLong = Long.valueOf(contentLength);
}
if (contentLengthAsLong > -1) {
uploadedSize = contentLength;
// Handle multipart case: mainly MSIE with jQueryFileupload
if (contentType != null && contentType.contains("multipart")) {
isMultipart = true;
FormData formData = new FormData(request);
Blob blob = formData.getFirstBlob();
if (blob != null) {
is = blob.getStream();
if (!UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
fileName = blob.getFilename();
}
mimeType = blob.getMimeType();
uploadedSize = String.valueOf(blob.getLength());
}
} else {
if (fileName != null) {
fileName = URLDecoder.decode(fileName, "UTF-8");
}
is = request.getInputStream();
}
if (UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
try {
log.debug(String.format("Uploading chunk [index=%s / total=%s] (%sb) for file %s",
uploadChunkIndex, chunkCount, uploadedSize, fileName));
bm.addStream(batchId, fileIdx, is, Integer.parseInt(chunkCount),
Integer.parseInt(uploadChunkIndex), fileName, mimeType, Long.parseLong(fileSize));
} catch (NumberFormatException e) {
return buildTextResponse(Status.BAD_REQUEST,
"X-Upload-Chunk-Index, X-Upload-Chunk-Count and X-File-Size headers must be numbers");
}
} else {
// Use non chunked mode by default if X-Upload-Type header is not provided
uploadType = UPLOAD_TYPE_NORMAL;
log.debug(String.format("Uploading file %s (%sb)", fileName, uploadedSize));
bm.addStream(batchId, fileIdx, is, fileName, mimeType);
}
} else {
fileEntry = bm.getFileEntry(batchId, fileIdx);
if (fileEntry == null) {
return buildEmptyResponse(Status.NOT_FOUND);
}
}
if (fileEntry == null && UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
fileEntry = bm.getFileEntry(batchId, fileIdx);
}
StatusType status = Status.CREATED;
Map<String, Object> result = new HashMap<>();
result.put("uploaded", "true");
result.put("batchId", batchId);
result.put("fileIdx", fileIdx);
result.put("uploadedSize", uploadedSize);
if (fileEntry != null && fileEntry.isChunked()) {
result.put("uploadType", UPLOAD_TYPE_CHUNKED);
result.put("uploadedChunkIds", fileEntry.getOrderedChunkIndexes());
result.put("chunkCount", fileEntry.getChunkCount());
if (!fileEntry.isChunksCompleted()) {
status = new ResumeIncompleteStatusType();
}
} else {
result.put("uploadType", UPLOAD_TYPE_NORMAL);
}
return buildResponse(status, result, isMultipart);
}
@GET
@Path("{batchId}")
public Response getBatchInfo(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException {
BatchManager bm = Framework.getService(BatchManager.class);
if (!bm.hasBatch(batchId)) {
return buildEmptyResponse(Status.NOT_FOUND);
}
List<BatchFileEntry> fileEntries = bm.getFileEntries(batchId);
if (CollectionUtils.isEmpty(fileEntries)) {
return buildEmptyResponse(Status.NO_CONTENT);
}
List<Map<String, Object>> result = new ArrayList<>();
for (BatchFileEntry fileEntry : fileEntries) {
result.add(getFileInfo(fileEntry));
}
return buildResponse(Status.OK, result);
}
@GET
@Path("{batchId}/{fileIdx}")
public Response getFileInfo(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx)
throws IOException {
BatchManager bm = Framework.getService(BatchManager.class);
if (!bm.hasBatch(batchId)) {
return buildEmptyResponse(Status.NOT_FOUND);
}
BatchFileEntry fileEntry = bm.getFileEntry(batchId, fileIdx);
if (fileEntry == null) {
return buildEmptyResponse(Status.NOT_FOUND);
}
StatusType status = Status.OK;
if (fileEntry.isChunked() && !fileEntry.isChunksCompleted()) {
status = new ResumeIncompleteStatusType();
}
Map<String, Object> result = getFileInfo(fileEntry);
return buildResponse(status, result);
}
@DELETE
@Path("{batchId}")
public Response cancel(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException {
BatchManager bm = Framework.getLocalService(BatchManager.class);
if (!bm.hasBatch(batchId)) {
return buildEmptyResponse(Status.NOT_FOUND);
}
bm.clean(batchId);
return buildEmptyResponse(Status.NO_CONTENT);
}
/**
* @since 8.4
*/
@DELETE
@Path("{batchId}/{fileIdx}")
public Response removeFile(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx)
throws IOException {
BatchManager bm = Framework.getLocalService(BatchManager.class);
if (!bm.removeFileEntry(batchId, fileIdx)) {
return buildEmptyResponse(Status.NOT_FOUND);
}
return buildEmptyResponse(Status.NO_CONTENT);
}
@Context
protected HttpServletRequest request;
@Context
protected HttpServletResponse response;
@POST
@Produces("application/json")
@Path("{batchId}/execute/{operationId}")
public Object execute(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(OPERATION_ID) String operationId,
ExecutionRequest xreq) throws UnsupportedEncodingException {
return executeBatch(batchId, null, operationId, request, xreq);
}
@POST
@Produces("application/json")
@Path("{batchId}/{fileIdx}/execute/{operationId}")
public Object execute(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx,
@PathParam(OPERATION_ID) String operationId, ExecutionRequest xreq)
throws UnsupportedEncodingException {
return executeBatch(batchId, fileIdx, operationId, request, xreq);
}
protected Object executeBatch(String batchId, String fileIdx, String operationId, HttpServletRequest request,
ExecutionRequest xreq) throws UnsupportedEncodingException {
if (!Boolean.parseBoolean(
RequestContext.getActiveContext(request).getRequest().getHeader(BatchManagerConstants.NO_DROP_FLAG))) {
RequestContext.getActiveContext(request).addRequestCleanupHandler(req -> {
BatchManager bm = Framework.getService(BatchManager.class);
bm.clean(batchId);
});
}
try {
CoreSession session = ctx.getCoreSession();
OperationContext ctx = xreq.createContext(request, response, session);
Map<String, Object> params = xreq.getParams();
BatchManager bm = Framework.getLocalService(BatchManager.class);
Object result;
if (StringUtils.isBlank(fileIdx)) {
result = bm.execute(batchId, operationId, session, ctx, params);
} else {
result = bm.execute(batchId, fileIdx, operationId, session, ctx, params);
}
return ResponseHelper.getResponse(result, request);
} catch (NuxeoException | MessagingException | IOException e) {
log.error("Error while executing automation batch ", e);
if (WebException.isSecurityError(e)) {
return buildJSONResponse(Status.FORBIDDEN, "{\"error\" : \"" + e.getMessage() + "\"}");
} else {
return buildJSONResponse(Status.INTERNAL_SERVER_ERROR, "{\"error\" : \"" + e.getMessage() + "\"}");
}
}
}
protected Response buildResponse(StatusType status, Object object) throws IOException {
return buildResponse(status, object, false);
}
protected Response buildResponse(StatusType status, Object object, boolean html) throws IOException {
ObjectMapper mapper = new ObjectMapper();
String result = mapper.writeValueAsString(object);
if (html) {
// For MSIE with iframe transport: we need to return HTML!
return buildHTMLResponse(status, result);
} else {
return buildJSONResponse(status, result);
}
}
protected Response buildJSONResponse(StatusType status, String message) throws UnsupportedEncodingException {
return buildResponse(status, MediaType.APPLICATION_JSON, message);
}
protected Response buildHTMLResponse(StatusType status, String message) throws UnsupportedEncodingException {
message = "<html>" + message + "</html>";
return buildResponse(status, MediaType.TEXT_HTML, message);
}
protected Response buildTextResponse(StatusType status, String message) throws UnsupportedEncodingException {
return buildResponse(status, MediaType.TEXT_PLAIN, message);
}
protected Response buildEmptyResponse(StatusType status) {
return Response.status(status).build();
}
protected Response buildResponse(StatusType status, String type, String message)
throws UnsupportedEncodingException {
return Response.status(status)
.header("Content-Length", message.getBytes("UTF-8").length)
.type(type + "; charset=UTF-8")
.entity(message)
.build();
}
protected Map<String, Object> getFileInfo(BatchFileEntry fileEntry) throws UnsupportedEncodingException {
Map<String, Object> info = new HashMap<>();
boolean chunked = fileEntry.isChunked();
String uploadType;
if (chunked) {
uploadType = UPLOAD_TYPE_CHUNKED;
} else {
uploadType = UPLOAD_TYPE_NORMAL;
}
info.put("name", fileEntry.getFileName());
info.put("size", fileEntry.getFileSize());
info.put("uploadType", uploadType);
if (chunked) {
info.put("uploadedChunkIds", fileEntry.getOrderedChunkIndexes());
info.put("chunkCount", fileEntry.getChunkCount());
}
return info;
}
public final class ResumeIncompleteStatusType implements StatusType {
@Override
public int getStatusCode() {
return 308;
}
@Override
public String getReasonPhrase() {
return "Resume Incomplete";
}
@Override
public Family getFamily() {
// Technically we don't use 308 Resume Incomplete as a redirection but it is the default family for 3xx
// status codes defined by Response$Status
return Family.REDIRECTION;
}
}
}