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