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}