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}