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}