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