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