001/* 002 * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Antoine Taillefer <ataillefer@nuxeo.com> 018 */ 019package org.nuxeo.ecm.restapi.server.jaxrs; 020 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.UnsupportedEncodingException; 024import java.net.URLDecoder; 025import java.util.ArrayList; 026import java.util.HashMap; 027import java.util.List; 028import java.util.Map; 029 030import javax.mail.MessagingException; 031import javax.servlet.http.HttpServletRequest; 032import javax.servlet.http.HttpServletResponse; 033import javax.ws.rs.DELETE; 034import javax.ws.rs.GET; 035import javax.ws.rs.POST; 036import javax.ws.rs.Path; 037import javax.ws.rs.PathParam; 038import javax.ws.rs.Produces; 039import javax.ws.rs.core.Context; 040import javax.ws.rs.core.MediaType; 041import javax.ws.rs.core.Response; 042import javax.ws.rs.core.Response.Status; 043import javax.ws.rs.core.Response.Status.Family; 044import javax.ws.rs.core.Response.StatusType; 045 046import org.apache.commons.collections.CollectionUtils; 047import org.apache.commons.lang.StringUtils; 048import org.apache.commons.lang.math.NumberUtils; 049import org.apache.commons.logging.Log; 050import org.apache.commons.logging.LogFactory; 051import org.codehaus.jackson.map.ObjectMapper; 052import org.nuxeo.ecm.automation.OperationContext; 053import org.nuxeo.ecm.automation.jaxrs.io.operations.ExecutionRequest; 054import org.nuxeo.ecm.automation.server.jaxrs.ResponseHelper; 055import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchFileEntry; 056import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchManager; 057import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchManagerConstants; 058import org.nuxeo.ecm.core.api.Blob; 059import org.nuxeo.ecm.core.api.CoreSession; 060import org.nuxeo.ecm.core.api.NuxeoException; 061import org.nuxeo.ecm.webengine.WebException; 062import org.nuxeo.ecm.webengine.forms.FormData; 063import org.nuxeo.ecm.webengine.jaxrs.context.RequestContext; 064import org.nuxeo.ecm.webengine.model.WebObject; 065import org.nuxeo.ecm.webengine.model.impl.AbstractResource; 066import org.nuxeo.ecm.webengine.model.impl.ResourceTypeImpl; 067import org.nuxeo.runtime.api.Framework; 068import org.nuxeo.runtime.transaction.TransactionHelper; 069 070/** 071 * Batch upload endpoint. 072 * <p> 073 * Replaces the deprecated endpoints listed below: 074 * <ul> 075 * <li>POST /batch/upload, see org.nuxeo.ecm.automation.server.jaxrs.batch.BatchResource#doPost(HttpServletRequest), use 076 * POST /upload/{batchId}/{fileIdx} instead, see {@link #upload(HttpServletRequest, String, String)}</li> 077 * <li>GET /batch/files/{batchId}, see org.nuxeo.ecm.automation.server.jaxrs.batch.BatchResource#getFilesBatch(String), 078 * use GET /upload/{batchId} instead, see {@link #getBatchInfo(String)} instead</li> 079 * <li>GET /batch/drop/{batchId}, see org.nuxeo.ecm.automation.server.jaxrs.batch.BatchResource#dropBatch(String), use 080 * DELETE /upload/{batchId} instead, see {@link #dropBatch(String)}</li> 081 * </ul> 082 * Also provides new endpoints: 083 * <ul> 084 * <li>POST /upload, see {@link #initBatch()}</li> 085 * <li>GET /upload/{batchId}/{fileIdx}, see {@link #getFileInfo(String, String)}</li> 086 * </ul> 087 * Largely inspired by the excellent Google Drive REST API documentation about <a 088 * href="https://developers.google.com/drive/web/manage-uploads#resumable">resumable upload</a>. 089 * 090 * @since 7.4 091 */ 092@WebObject(type = "upload") 093public class BatchUploadObject extends AbstractResource<ResourceTypeImpl> { 094 095 protected static final Log log = LogFactory.getLog(BatchUploadObject.class); 096 097 protected static final String REQUEST_BATCH_ID = "batchId"; 098 099 protected static final String REQUEST_FILE_IDX = "fileIdx"; 100 101 protected static final String OPERATION_ID = "operationId"; 102 103 public static final String UPLOAD_TYPE_NORMAL = "normal"; 104 105 public static final String UPLOAD_TYPE_CHUNKED = "chunked"; 106 107 @POST 108 public Response initBatch() throws IOException { 109 BatchManager bm = Framework.getService(BatchManager.class); 110 String batchId = bm.initBatch(); 111 Map<String, String> result = new HashMap<String, String>(); 112 result.put("batchId", batchId); 113 return buildResponse(Status.CREATED, result); 114 } 115 116 @POST 117 @Path("{batchId}/{fileIdx}") 118 public Response upload(@Context HttpServletRequest request, @PathParam(REQUEST_BATCH_ID) String batchId, 119 @PathParam(REQUEST_FILE_IDX) String fileIdx) throws IOException { 120 TransactionHelper.commitOrRollbackTransaction(); 121 try { 122 return uploadNoTransaction(request, batchId, fileIdx); 123 } finally { 124 TransactionHelper.startTransaction(); 125 } 126 } 127 128 protected Response uploadNoTransaction(@Context HttpServletRequest request, 129 @PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx) 130 throws IOException { 131 132 BatchManager bm = Framework.getService(BatchManager.class); 133 if (!bm.hasBatch(batchId)) { 134 return buildEmptyResponse(Status.NOT_FOUND); 135 } 136 137 // Check file index parameter 138 if (!NumberUtils.isDigits(fileIdx)) { 139 return buildTextResponse(Status.BAD_REQUEST, "fileIdx request path parameter must be a number"); 140 } 141 142 boolean isMultipart = false; 143 144 // Parameters are passed as request header, the request body is the stream 145 String contentType = request.getHeader("Content-Type"); 146 String uploadType = request.getHeader("X-Upload-Type"); 147 String contentLength = request.getHeader("Content-Length"); 148 String uploadChunkIndex = request.getHeader("X-Upload-Chunk-Index"); 149 String chunkCount = request.getHeader("X-Upload-Chunk-Count"); 150 String fileName = request.getHeader("X-File-Name"); 151 String fileSize = request.getHeader("X-File-Size"); 152 String mimeType = request.getHeader("X-File-Type"); 153 InputStream is = null; 154 155 BatchFileEntry fileEntry = null; 156 String uploadedSize = "0"; 157 long contentLengthAsLong = -1; 158 if (contentLength != null) { 159 contentLengthAsLong = Long.valueOf(contentLength); 160 } 161 if (contentLengthAsLong > -1) { 162 uploadedSize = contentLength; 163 // Handle multipart case: mainly MSIE with jQueryFileupload 164 if (contentType != null && contentType.contains("multipart")) { 165 isMultipart = true; 166 FormData formData = new FormData(request); 167 Blob blob = formData.getFirstBlob(); 168 if (blob != null) { 169 is = blob.getStream(); 170 if (!UPLOAD_TYPE_CHUNKED.equals(uploadType)) { 171 fileName = blob.getFilename(); 172 } 173 // Don't change the mime-type if it was forced via the X-File-Type header 174 if (mimeType == null || mimeType.isEmpty()) { 175 mimeType = blob.getMimeType(); 176 } 177 uploadedSize = String.valueOf(blob.getLength()); 178 } 179 } else { 180 if (fileName != null) { 181 fileName = URLDecoder.decode(fileName, "UTF-8"); 182 } 183 is = request.getInputStream(); 184 } 185 186 if (UPLOAD_TYPE_CHUNKED.equals(uploadType)) { 187 try { 188 log.debug(String.format("Uploading chunk [index=%s / total=%s] (%sb) for file %s", 189 uploadChunkIndex, chunkCount, uploadedSize, fileName)); 190 bm.addStream(batchId, fileIdx, is, Integer.parseInt(chunkCount), 191 Integer.parseInt(uploadChunkIndex), fileName, mimeType, Long.parseLong(fileSize)); 192 } catch (NumberFormatException e) { 193 return buildTextResponse(Status.BAD_REQUEST, 194 "X-Upload-Chunk-Index, X-Upload-Chunk-Count and X-File-Size headers must be numbers"); 195 } 196 } else { 197 // Use non chunked mode by default if X-Upload-Type header is not provided 198 uploadType = UPLOAD_TYPE_NORMAL; 199 log.debug(String.format("Uploading file %s (%sb)", fileName, uploadedSize)); 200 bm.addStream(batchId, fileIdx, is, fileName, mimeType); 201 } 202 } else { 203 fileEntry = bm.getFileEntry(batchId, fileIdx); 204 if (fileEntry == null) { 205 return buildEmptyResponse(Status.NOT_FOUND); 206 } 207 } 208 209 if (fileEntry == null && UPLOAD_TYPE_CHUNKED.equals(uploadType)) { 210 fileEntry = bm.getFileEntry(batchId, fileIdx); 211 } 212 213 StatusType status = Status.CREATED; 214 Map<String, Object> result = new HashMap<>(); 215 result.put("uploaded", "true"); 216 result.put("batchId", batchId); 217 result.put("fileIdx", fileIdx); 218 result.put("uploadedSize", uploadedSize); 219 if (fileEntry != null && fileEntry.isChunked()) { 220 result.put("uploadType", UPLOAD_TYPE_CHUNKED); 221 result.put("uploadedChunkIds", fileEntry.getOrderedChunkIndexes()); 222 result.put("chunkCount", fileEntry.getChunkCount()); 223 if (!fileEntry.isChunksCompleted()) { 224 status = new ResumeIncompleteStatusType(); 225 } 226 } else { 227 result.put("uploadType", UPLOAD_TYPE_NORMAL); 228 } 229 return buildResponse(status, result, isMultipart); 230 } 231 232 @GET 233 @Path("{batchId}") 234 public Response getBatchInfo(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException { 235 BatchManager bm = Framework.getService(BatchManager.class); 236 if (!bm.hasBatch(batchId)) { 237 return buildEmptyResponse(Status.NOT_FOUND); 238 } 239 List<BatchFileEntry> fileEntries = bm.getFileEntries(batchId); 240 if (CollectionUtils.isEmpty(fileEntries)) { 241 return buildEmptyResponse(Status.NO_CONTENT); 242 } 243 List<Map<String, Object>> result = new ArrayList<>(); 244 for (BatchFileEntry fileEntry : fileEntries) { 245 result.add(getFileInfo(fileEntry)); 246 } 247 return buildResponse(Status.OK, result); 248 } 249 250 @GET 251 @Path("{batchId}/{fileIdx}") 252 public Response getFileInfo(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx) 253 throws IOException { 254 BatchManager bm = Framework.getService(BatchManager.class); 255 if (!bm.hasBatch(batchId)) { 256 return buildEmptyResponse(Status.NOT_FOUND); 257 } 258 BatchFileEntry fileEntry = bm.getFileEntry(batchId, fileIdx); 259 if (fileEntry == null) { 260 return buildEmptyResponse(Status.NOT_FOUND); 261 } 262 StatusType status = Status.OK; 263 if (fileEntry.isChunked() && !fileEntry.isChunksCompleted()) { 264 status = new ResumeIncompleteStatusType(); 265 } 266 Map<String, Object> result = getFileInfo(fileEntry); 267 return buildResponse(status, result); 268 } 269 270 @DELETE 271 @Path("{batchId}") 272 public Response cancel(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException { 273 BatchManager bm = Framework.getLocalService(BatchManager.class); 274 if (!bm.hasBatch(batchId)) { 275 return buildEmptyResponse(Status.NOT_FOUND); 276 } 277 bm.clean(batchId); 278 return buildEmptyResponse(Status.NO_CONTENT); 279 } 280 281 /** 282 * @since 8.4 283 */ 284 @DELETE 285 @Path("{batchId}/{fileIdx}") 286 public Response removeFile(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx) 287 throws IOException { 288 BatchManager bm = Framework.getLocalService(BatchManager.class); 289 if (!bm.removeFileEntry(batchId, fileIdx)) { 290 return buildEmptyResponse(Status.NOT_FOUND); 291 } 292 return buildEmptyResponse(Status.NO_CONTENT); 293 } 294 295 @Context 296 protected HttpServletRequest request; 297 298 @Context 299 protected HttpServletResponse response; 300 301 @POST 302 @Produces("application/json") 303 @Path("{batchId}/execute/{operationId}") 304 public Object execute(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(OPERATION_ID) String operationId, 305 ExecutionRequest xreq) throws UnsupportedEncodingException { 306 return executeBatch(batchId, null, operationId, request, xreq); 307 } 308 309 @POST 310 @Produces("application/json") 311 @Path("{batchId}/{fileIdx}/execute/{operationId}") 312 public Object execute(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx, 313 @PathParam(OPERATION_ID) String operationId, ExecutionRequest xreq) 314 throws UnsupportedEncodingException { 315 return executeBatch(batchId, fileIdx, operationId, request, xreq); 316 } 317 318 protected Object executeBatch(String batchId, String fileIdx, String operationId, HttpServletRequest request, 319 ExecutionRequest xreq) throws UnsupportedEncodingException { 320 321 if (!Boolean.parseBoolean( 322 RequestContext.getActiveContext(request).getRequest().getHeader(BatchManagerConstants.NO_DROP_FLAG))) { 323 RequestContext.getActiveContext(request).addRequestCleanupHandler(req -> { 324 BatchManager bm = Framework.getService(BatchManager.class); 325 bm.clean(batchId); 326 }); 327 } 328 329 try { 330 CoreSession session = ctx.getCoreSession(); 331 OperationContext ctx = xreq.createContext(request, response, session); 332 Map<String, Object> params = xreq.getParams(); 333 BatchManager bm = Framework.getLocalService(BatchManager.class); 334 Object result; 335 if (StringUtils.isBlank(fileIdx)) { 336 result = bm.execute(batchId, operationId, session, ctx, params); 337 } else { 338 result = bm.execute(batchId, fileIdx, operationId, session, ctx, params); 339 } 340 return ResponseHelper.getResponse(result, request); 341 } catch (NuxeoException | MessagingException | IOException e) { 342 log.error("Error while executing automation batch ", e); 343 if (WebException.isSecurityError(e)) { 344 return buildJSONResponse(Status.FORBIDDEN, "{\"error\" : \"" + e.getMessage() + "\"}"); 345 } else { 346 return buildJSONResponse(Status.INTERNAL_SERVER_ERROR, "{\"error\" : \"" + e.getMessage() + "\"}"); 347 } 348 } 349 } 350 351 protected Response buildResponse(StatusType status, Object object) throws IOException { 352 return buildResponse(status, object, false); 353 } 354 355 protected Response buildResponse(StatusType status, Object object, boolean html) throws IOException { 356 ObjectMapper mapper = new ObjectMapper(); 357 String result = mapper.writeValueAsString(object); 358 if (html) { 359 // For MSIE with iframe transport: we need to return HTML! 360 return buildHTMLResponse(status, result); 361 } else { 362 return buildJSONResponse(status, result); 363 } 364 } 365 366 protected Response buildJSONResponse(StatusType status, String message) throws UnsupportedEncodingException { 367 return buildResponse(status, MediaType.APPLICATION_JSON, message); 368 } 369 370 protected Response buildHTMLResponse(StatusType status, String message) throws UnsupportedEncodingException { 371 message = "<html>" + message + "</html>"; 372 return buildResponse(status, MediaType.TEXT_HTML, message); 373 } 374 375 protected Response buildTextResponse(StatusType status, String message) throws UnsupportedEncodingException { 376 return buildResponse(status, MediaType.TEXT_PLAIN, message); 377 } 378 379 protected Response buildEmptyResponse(StatusType status) { 380 return Response.status(status).build(); 381 } 382 383 protected Response buildResponse(StatusType status, String type, String message) 384 throws UnsupportedEncodingException { 385 return Response.status(status) 386 .header("Content-Length", message.getBytes("UTF-8").length) 387 .type(type + "; charset=UTF-8") 388 .entity(message) 389 .build(); 390 } 391 392 protected Map<String, Object> getFileInfo(BatchFileEntry fileEntry) throws UnsupportedEncodingException { 393 Map<String, Object> info = new HashMap<>(); 394 boolean chunked = fileEntry.isChunked(); 395 String uploadType; 396 if (chunked) { 397 uploadType = UPLOAD_TYPE_CHUNKED; 398 } else { 399 uploadType = UPLOAD_TYPE_NORMAL; 400 } 401 info.put("name", fileEntry.getFileName()); 402 info.put("size", fileEntry.getFileSize()); 403 info.put("uploadType", uploadType); 404 if (chunked) { 405 info.put("uploadedChunkIds", fileEntry.getOrderedChunkIndexes()); 406 info.put("chunkCount", fileEntry.getChunkCount()); 407 } 408 return info; 409 } 410 411 public final class ResumeIncompleteStatusType implements StatusType { 412 413 @Override 414 public int getStatusCode() { 415 return 308; 416 } 417 418 @Override 419 public String getReasonPhrase() { 420 return "Resume Incomplete"; 421 } 422 423 @Override 424 public Family getFamily() { 425 // Technically we don't use 308 Resume Incomplete as a redirection but it is the default family for 3xx 426 // status codes defined by Response$Status 427 return Family.REDIRECTION; 428 } 429 } 430 431}