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.servlet.http.HttpServletRequest; 029import javax.ws.rs.DELETE; 030import javax.ws.rs.GET; 031import javax.ws.rs.POST; 032import javax.ws.rs.Path; 033import javax.ws.rs.PathParam; 034import javax.ws.rs.core.Context; 035import javax.ws.rs.core.MediaType; 036import javax.ws.rs.core.Response; 037import javax.ws.rs.core.Response.Status; 038import javax.ws.rs.core.Response.Status.Family; 039import javax.ws.rs.core.Response.StatusType; 040 041import org.apache.commons.collections.CollectionUtils; 042import org.apache.commons.logging.Log; 043import org.apache.commons.logging.LogFactory; 044import org.codehaus.jackson.map.ObjectMapper; 045import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchFileEntry; 046import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchManager; 047import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchResource; 048import org.nuxeo.ecm.core.api.Blob; 049import org.nuxeo.ecm.webengine.forms.FormData; 050import org.nuxeo.ecm.webengine.model.WebObject; 051import org.nuxeo.ecm.webengine.model.impl.AbstractResource; 052import org.nuxeo.ecm.webengine.model.impl.ResourceTypeImpl; 053import org.nuxeo.runtime.api.Framework; 054 055/** 056 * Batch upload endpoint. 057 * <p> 058 * Replaces the deprecated endpoints listed below: 059 * <ul> 060 * <li>POST /batch/upload, see {@link BatchResource#doPost(HttpServletRequest)}, use POST /upload/{batchId}/{fileIdx} 061 * instead, see {@link #upload(HttpServletRequest, String, String)}</li> 062 * <li>GET /batch/files/{batchId}, see {@link BatchResource#getFilesBatch(String)}, use GET /upload/{batchId} instead, 063 * see {@link #getBatchInfo(String)} instead</li> 064 * <li>GET /batch/drop/{batchId}, see {@link BatchResource#dropBatch(String)}, use DELETE /upload/{batchId} instead, see 065 * {@link #dropBatch(String)}</li> 066 * </ul> 067 * Also provides new endpoints: 068 * <ul> 069 * <li>POST /upload, see {@link #initBatch()}</li> 070 * <li>GET /upload/{batchId}/{fileIdx}, see {@link #getFileInfo(String, String)}</li> 071 * </ul> 072 * Largely inspired by the excellent Google Drive REST API documentation about <a 073 * href="https://developers.google.com/drive/web/manage-uploads#resumable">resumable upload</a>. 074 * 075 * @since 7.4 076 */ 077@WebObject(type = "upload") 078public class BatchUploadObject extends AbstractResource<ResourceTypeImpl> { 079 080 protected static final Log log = LogFactory.getLog(BatchUploadObject.class); 081 082 protected static final String REQUEST_BATCH_ID = "batchId"; 083 084 protected static final String REQUEST_FILE_IDX = "fileIdx"; 085 086 public static final String UPLOAD_TYPE_NORMAL = "normal"; 087 088 public static final String UPLOAD_TYPE_CHUNKED = "chunked"; 089 090 @POST 091 public Response initBatch() throws IOException { 092 BatchManager bm = Framework.getService(BatchManager.class); 093 String batchId = bm.initBatch(); 094 Map<String, String> result = new HashMap<String, String>(); 095 result.put("batchId", batchId); 096 return buildResponse(Status.OK, result); 097 } 098 099 @POST 100 @Path("{batchId}/{fileIdx}") 101 public Response upload(@Context HttpServletRequest request, @PathParam(REQUEST_BATCH_ID) String batchId, 102 @PathParam(REQUEST_FILE_IDX) String fileIdx) throws IOException { 103 104 BatchManager bm = Framework.getService(BatchManager.class); 105 if (!bm.hasBatch(batchId)) { 106 return buildEmptyResponse(Status.NOT_FOUND); 107 } 108 109 boolean isMultipart = false; 110 111 // Parameters are passed as request header, the request body is the stream 112 String contentType = request.getHeader("Content-Type"); 113 String uploadType = request.getHeader("X-Upload-Type"); 114 String contentLength = request.getHeader("Content-Length"); 115 String uploadChunkIdx = request.getHeader("X-Upload-Chunk-Index"); 116 String chunkCount = request.getHeader("X-Upload-Chunk-Count"); 117 String fileName = request.getHeader("X-File-Name"); 118 String fileSize = request.getHeader("X-File-Size"); 119 String mimeType = request.getHeader("X-File-Type"); 120 InputStream is = null; 121 122 String uploadedSize = contentLength; 123 // Handle multipart case: mainly MSIE with jQueryFileupload 124 if (contentType != null && contentType.contains("multipart")) { 125 isMultipart = true; 126 FormData formData = new FormData(request); 127 Blob blob = formData.getFirstBlob(); 128 if (blob != null) { 129 is = blob.getStream(); 130 if (!UPLOAD_TYPE_CHUNKED.equals(uploadType)) { 131 fileName = blob.getFilename(); 132 } 133 mimeType = blob.getMimeType(); 134 uploadedSize = String.valueOf(blob.getLength()); 135 } 136 } else { 137 if (fileName != null) { 138 fileName = URLDecoder.decode(fileName, "UTF-8"); 139 } 140 is = request.getInputStream(); 141 } 142 143 if (UPLOAD_TYPE_CHUNKED.equals(uploadType)) { 144 log.debug(String.format("Uploading chunk [index=%s / total=%s] (%sb) for file %s", uploadChunkIdx, 145 chunkCount, uploadedSize, fileName)); 146 bm.addStream(batchId, fileIdx, is, Integer.valueOf(chunkCount), Integer.valueOf(uploadChunkIdx), fileName, 147 mimeType, Long.valueOf(fileSize)); 148 } else { 149 // Use non chunked mode by default if X-Upload-Type header is not provided 150 uploadType = UPLOAD_TYPE_NORMAL; 151 log.debug(String.format("Uploading file %s (%sb)", fileName, uploadedSize)); 152 bm.addStream(batchId, fileIdx, is, fileName, mimeType); 153 } 154 155 StatusType status = Status.CREATED; 156 Map<String, String> result = new HashMap<String, String>(); 157 result.put("batchId", batchId); 158 result.put("fileIdx", fileIdx); 159 result.put("uploadType", uploadType); 160 result.put("uploadedSize", uploadedSize); 161 if (UPLOAD_TYPE_CHUNKED.equals(uploadType)) { 162 result.put("uploadedChunkId", uploadChunkIdx); 163 result.put("chunkCount", chunkCount); 164 BatchFileEntry fileEntry = bm.getFileEntry(batchId, fileIdx); 165 if (!fileEntry.isChunksCompleted()) { 166 status = new ResumeIncompleteStatusType(); 167 } 168 } 169 return buildResponse(status, result, isMultipart); 170 } 171 172 @GET 173 @Path("{batchId}") 174 public Response getBatchInfo(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException { 175 BatchManager bm = Framework.getService(BatchManager.class); 176 if (!bm.hasBatch(batchId)) { 177 return buildEmptyResponse(Status.NOT_FOUND); 178 } 179 List<BatchFileEntry> fileEntries = bm.getFileEntries(batchId); 180 if (CollectionUtils.isEmpty(fileEntries)) { 181 return buildEmptyResponse(Status.NO_CONTENT); 182 } 183 List<Map<String, Object>> result = new ArrayList<>(); 184 for (BatchFileEntry fileEntry : fileEntries) { 185 result.add(getFileInfo(fileEntry)); 186 } 187 return buildResponse(Status.OK, result); 188 } 189 190 @GET 191 @Path("{batchId}/{fileIdx}") 192 public Response getFileInfo(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx) 193 throws IOException { 194 BatchManager bm = Framework.getService(BatchManager.class); 195 if (!bm.hasBatch(batchId)) { 196 return buildEmptyResponse(Status.NOT_FOUND); 197 } 198 BatchFileEntry fileEntry = bm.getFileEntry(batchId, fileIdx); 199 if (fileEntry == null) { 200 return buildEmptyResponse(Status.NOT_FOUND); 201 } 202 StatusType status = Status.OK; 203 if (fileEntry.isChunked() && !fileEntry.isChunksCompleted()) { 204 status = new ResumeIncompleteStatusType(); 205 } 206 Map<String, Object> result = getFileInfo(fileEntry); 207 return buildResponse(status, result); 208 } 209 210 @DELETE 211 @Path("{batchId}") 212 public Response dropBatch(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException { 213 BatchManager bm = Framework.getLocalService(BatchManager.class); 214 if (!bm.hasBatch(batchId)) { 215 return buildEmptyResponse(Status.NOT_FOUND); 216 } 217 bm.clean(batchId); 218 Map<String, String> result = new HashMap<String, String>(); 219 result.put("batchId", batchId); 220 result.put("dropped", "true"); 221 return buildResponse(Status.OK, result); 222 } 223 224 protected Response buildResponse(StatusType status, Object object) throws IOException { 225 return buildResponse(status, object, false); 226 } 227 228 protected Response buildResponse(StatusType status, Object object, boolean html) throws IOException { 229 ObjectMapper mapper = new ObjectMapper(); 230 String result = mapper.writeValueAsString(object); 231 if (html) { 232 // For MSIE with iframe transport: we need to return HTML! 233 return buildHTMLResponse(status, result); 234 } else { 235 return buildJSONResponse(status, result); 236 } 237 } 238 239 protected Response buildJSONResponse(StatusType status, String message) { 240 return Response.status(status).header("Content-Length", message.length()).type(MediaType.APPLICATION_JSON).entity( 241 message).build(); 242 } 243 244 protected Response buildHTMLResponse(StatusType status, String message) { 245 message = "<html>" + message + "</html>"; 246 return Response.status(status).header("Content-Length", message.length()).type(MediaType.TEXT_HTML_TYPE).entity( 247 message).build(); 248 } 249 250 protected Response buildEmptyResponse(StatusType status) { 251 return Response.status(status).build(); 252 } 253 254 protected Map<String, Object> getFileInfo(BatchFileEntry fileEntry) throws UnsupportedEncodingException { 255 Map<String, Object> info = new HashMap<>(); 256 boolean chunked = fileEntry.isChunked(); 257 String uploadType; 258 if (chunked) { 259 uploadType = UPLOAD_TYPE_CHUNKED; 260 } else { 261 uploadType = UPLOAD_TYPE_NORMAL; 262 } 263 info.put("name", fileEntry.getFileName()); 264 info.put("size", fileEntry.getFileSize()); 265 info.put("uploadType", uploadType); 266 if (chunked) { 267 info.put("uploadedChunkIds", fileEntry.getOrderedChunkIds()); 268 info.put("chunkCount", fileEntry.getChunkCount()); 269 } 270 return info; 271 } 272 273 public final class ResumeIncompleteStatusType implements StatusType { 274 275 @Override 276 public int getStatusCode() { 277 return 308; 278 } 279 280 @Override 281 public String getReasonPhrase() { 282 return "Resume Incomplete"; 283 } 284 285 @Override 286 public Family getFamily() { 287 // Technically we don't use 308 Resume Incomplete as a redirection but it is the default family for 3xx 288 // status codes defined by Response$Status 289 return Family.REDIRECTION; 290 } 291 } 292 293}