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