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}