001/*
002 * (C) Copyright 2015-2018 Nuxeo (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 *     Luís Duarte
019 *     Florent Guillaume
020 */
021package org.nuxeo.ecm.restapi.server.jaxrs;
022
023import java.io.File;
024import java.io.IOException;
025import java.io.InputStream;
026import java.io.UnsupportedEncodingException;
027import java.net.URLDecoder;
028import java.util.ArrayList;
029import java.util.Collections;
030import java.util.HashMap;
031import java.util.List;
032import java.util.Map;
033import java.util.Set;
034import java.util.stream.Collectors;
035
036import javax.mail.MessagingException;
037import javax.servlet.http.HttpServletRequest;
038import javax.servlet.http.HttpServletResponse;
039import javax.ws.rs.DELETE;
040import javax.ws.rs.GET;
041import javax.ws.rs.POST;
042import javax.ws.rs.Path;
043import javax.ws.rs.PathParam;
044import javax.ws.rs.Produces;
045import javax.ws.rs.core.Context;
046import javax.ws.rs.core.MediaType;
047import javax.ws.rs.core.Response;
048import javax.ws.rs.core.Response.Status;
049import javax.ws.rs.core.Response.Status.Family;
050import javax.ws.rs.core.Response.StatusType;
051
052import org.apache.commons.collections.CollectionUtils;
053import org.apache.commons.lang3.StringUtils;
054import org.apache.commons.lang3.math.NumberUtils;
055import org.apache.commons.logging.Log;
056import org.apache.commons.logging.LogFactory;
057import org.nuxeo.ecm.automation.OperationContext;
058import org.nuxeo.ecm.automation.jaxrs.io.operations.ExecutionRequest;
059import org.nuxeo.ecm.automation.server.jaxrs.ResponseHelper;
060import org.nuxeo.ecm.automation.server.jaxrs.batch.Batch;
061import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchFileEntry;
062import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchHandler;
063import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchManager;
064import org.nuxeo.ecm.automation.server.jaxrs.batch.BatchManagerConstants;
065import org.nuxeo.ecm.automation.server.jaxrs.batch.handler.BatchFileInfo;
066import org.nuxeo.ecm.core.api.Blob;
067import org.nuxeo.ecm.core.api.Blobs;
068import org.nuxeo.ecm.core.api.CoreSession;
069import org.nuxeo.ecm.core.api.NuxeoException;
070import org.nuxeo.ecm.core.api.impl.blob.FileBlob;
071import org.nuxeo.ecm.core.io.NginxConstants;
072import org.nuxeo.ecm.webengine.forms.FormData;
073import org.nuxeo.ecm.webengine.jaxrs.context.RequestContext;
074import org.nuxeo.ecm.webengine.model.WebObject;
075import org.nuxeo.ecm.webengine.model.exceptions.IllegalParameterException;
076import org.nuxeo.ecm.webengine.model.impl.AbstractResource;
077import org.nuxeo.ecm.webengine.model.impl.ResourceTypeImpl;
078import org.nuxeo.runtime.api.Framework;
079import org.nuxeo.runtime.transaction.TransactionHelper;
080
081import com.fasterxml.jackson.databind.JsonNode;
082import com.fasterxml.jackson.databind.ObjectMapper;
083
084/**
085 * Batch upload endpoint.
086 * <p>
087 * Provides the APIs listed below:
088 * <ul>
089 * <li>POST /upload, see {@link #initBatch()}</li>
090 * <li>POST /upload/{batchId}/{fileIdx}, see {@link #upload(HttpServletRequest, String, String)}</li>
091 * <li>GET /upload/{batchId}, see {@link #getBatchInfo(String)}</li>
092 * <li>GET /upload/{batchId}/{fileIdx}, see {@link #getFileInfo(String, String)}</li>
093 * <li>POST /upload/{batchId}/execute/{operationId}, see {@link #execute(String, String, ExecutionRequest)}</li>
094 * <li>POST /upload/{batchId}/{fileIdx}/execute/{operationId}, see
095 * {@link #execute(String, String, String, ExecutionRequest)}</li>
096 * <li>DELETE /upload/{batchId}, see {@link #cancel(String)}</li>
097 * <li>DELETE /upload/{batchId}/{fileIdx}, see {@link #removeFile(String, String)}</li>
098 * </ul>
099 * Largely inspired by the excellent Google Drive REST API documentation about
100 * <a href="https://developers.google.com/drive/web/manage-uploads#resumable">resumable upload</a>.
101 *
102 * @since 7.4
103 */
104@WebObject(type = "upload")
105public class BatchUploadObject extends AbstractResource<ResourceTypeImpl> {
106
107    protected static final Log log = LogFactory.getLog(BatchUploadObject.class);
108
109    protected static final String REQUEST_BATCH_ID = "batchId";
110
111    protected static final String REQUEST_FILE_IDX = "fileIdx";
112
113    protected static final String OPERATION_ID = "operationId";
114
115    protected static final String REQUEST_HANDLER_NAME = "handlerName";
116
117    public static final String UPLOAD_TYPE_NORMAL = "normal";
118
119    public static final String UPLOAD_TYPE_CHUNKED = "chunked";
120
121    public static final String KEY = "key";
122
123    public static final String NAME = "name";
124
125    public static final String MIMETYPE = "mimeType";
126
127    public static final String FILE_SIZE = "fileSize";
128
129    public static final String MD5 = "md5";
130
131    protected Map<String, String> mapWithName(String name) {
132        return Collections.singletonMap("name", name);
133    }
134
135    @GET
136    @Path("handlers")
137    public Response handlers() throws IOException {
138        BatchManager bm = Framework.getService(BatchManager.class);
139        Set<String> supportedHandlers = bm.getSupportedHandlers();
140        List<Map<String, String>> handlers = supportedHandlers.stream().map(this::mapWithName).collect(
141                Collectors.toList());
142        Map<String, Object> result = Collections.singletonMap("handlers", handlers);
143        return buildResponse(Status.OK, result);
144    }
145
146    @GET
147    @Path("handlers/{handlerName}")
148    public Response getHandlerInfo(@PathParam(REQUEST_HANDLER_NAME) String handlerName) throws IOException {
149        BatchManager bm = Framework.getService(BatchManager.class);
150        BatchHandler handler = bm.getHandler(handlerName);
151        if (handler == null) {
152            return Response.status(Status.NOT_FOUND).build();
153        }
154        Map<String, String> result = mapWithName(handler.getName());
155        return buildResponse(Status.OK, result);
156    }
157
158    @POST
159    @Path("new/{handlerName}")
160    public Response createNewBatch(@PathParam(REQUEST_HANDLER_NAME) String handlerName) throws IOException {
161        BatchManager bm = Framework.getService(BatchManager.class);
162        Batch batch = bm.initBatch(handlerName);
163        return getBatchExtraInfo(batch.getKey());
164    }
165
166    @POST
167    public Response initBatch() throws IOException {
168        BatchManager bm = Framework.getService(BatchManager.class);
169        String batchId = bm.initBatch();
170        Map<String, String> result = new HashMap<>();
171        result.put("batchId", batchId);
172        return buildResponse(Status.CREATED, result);
173    }
174
175    @POST
176    @Path("{batchId}/{fileIdx}")
177    public Response upload(@Context HttpServletRequest request, @PathParam(REQUEST_BATCH_ID) String batchId,
178            @PathParam(REQUEST_FILE_IDX) String fileIdx) throws IOException {
179        TransactionHelper.commitOrRollbackTransaction();
180        try {
181            return uploadNoTransaction(request, batchId, fileIdx);
182        } finally {
183            TransactionHelper.startTransaction();
184        }
185    }
186
187    protected Response uploadNoTransaction(@Context HttpServletRequest request,
188            @PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx)
189            throws IOException {
190        BatchManager bm = Framework.getService(BatchManager.class);
191
192        if (!bm.hasBatch(batchId)) {
193            return buildEmptyResponse(Status.NOT_FOUND);
194        }
195
196        // Check file index parameter
197        if (!NumberUtils.isDigits(fileIdx)) {
198            return buildTextResponse(Status.BAD_REQUEST, "fileIdx request path parameter must be a number");
199        }
200
201        // Parameters are passed as request header, the request body is the stream
202        String contentType = request.getHeader("Content-Type");
203        String uploadType = request.getHeader("X-Upload-Type");
204        // Use non chunked mode by default if X-Upload-Type header is not provided
205        if (!UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
206            uploadType = UPLOAD_TYPE_NORMAL;
207        }
208        String uploadChunkIndexHeader = request.getHeader("X-Upload-Chunk-Index");
209        String chunkCountHeader = request.getHeader("X-Upload-Chunk-Count");
210        String fileName = request.getHeader("X-File-Name");
211        String fileSizeHeader = request.getHeader("X-File-Size");
212        String mimeType = request.getHeader("X-File-Type");
213        String requestBodyFile = request.getHeader(NginxConstants.X_REQUEST_BODY_FILE_HEADER);
214        String contentMd5 = request.getHeader(NginxConstants.X_CONTENT_MD5_HEADER);
215
216        int chunkCount = -1;
217        int uploadChunkIndex = -1;
218        long fileSize = -1;
219        if (UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
220            try {
221                chunkCount = Integer.parseInt(chunkCountHeader);
222                uploadChunkIndex = Integer.parseInt(uploadChunkIndexHeader);
223                fileSize = Long.parseLong(fileSizeHeader);
224            } catch (NumberFormatException e) {
225                throw new IllegalParameterException(
226                        "X-Upload-Chunk-Index, X-Upload-Chunk-Count and X-File-Size headers must be numbers");
227            }
228        }
229
230        // TODO NXP-18247: should be set to the actual number of bytes uploaded instead of relying on the Content-Length
231        // header which is not necessarily set
232        long uploadedSize = getUploadedSize(request);
233        boolean isMultipart = contentType != null && contentType.contains("multipart");
234
235        // Handle multipart case: mainly MSIE with jQueryFileupload
236        if (isMultipart) {
237            FormData formData = new FormData(request);
238            Blob blob = formData.getFirstBlob();
239            if (blob == null) {
240                throw new NuxeoException("Cannot upload in multipart with no blobs");
241            }
242            if (!UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
243                fileName = blob.getFilename();
244            }
245            // Don't change the mime-type if it was forced via the X-File-Type header
246            if (StringUtils.isBlank(mimeType)) {
247                mimeType = blob.getMimeType();
248            }
249            uploadedSize = blob.getLength();
250            addBlob(uploadType, batchId, fileIdx, blob, fileName, mimeType, uploadedSize, chunkCount, uploadChunkIndex,
251                    fileSize);
252        } else if (Framework.isBooleanPropertyTrue(NginxConstants.X_ACCEL_ENABLED)
253                && StringUtils.isNotEmpty(requestBodyFile)) {
254            if (StringUtils.isNotEmpty(fileName)) {
255                fileName = URLDecoder.decode(fileName, "UTF-8");
256            }
257            File file = new File(requestBodyFile);
258            Blob blob = new FileBlob(file, true);
259
260            if (StringUtils.isNotEmpty(contentMd5)) {
261                blob.setDigest(contentMd5);
262            }
263
264            uploadedSize = file.length();
265            addBlob(uploadType, batchId, fileIdx, blob, fileName, mimeType, uploadedSize, chunkCount, uploadChunkIndex,
266                    fileSize);
267        } else {
268            if (StringUtils.isNotEmpty(fileName)) {
269                fileName = URLDecoder.decode(fileName, "UTF-8");
270            }
271            try (InputStream is = request.getInputStream()) {
272                Blob blob = Blobs.createBlob(is);
273                addBlob(uploadType, batchId, fileIdx, blob, fileName, mimeType, uploadedSize, chunkCount,
274                        uploadChunkIndex, fileSize);
275            }
276        }
277
278        StatusType status = Status.CREATED;
279        Map<String, Object> result = new HashMap<>();
280        result.put("uploaded", "true");
281        result.put("batchId", batchId);
282        result.put("fileIdx", fileIdx);
283        result.put("uploadType", uploadType);
284        result.put("uploadedSize", String.valueOf(uploadedSize));
285        if (UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
286            BatchFileEntry fileEntry = bm.getFileEntry(batchId, fileIdx);
287            if (fileEntry != null) {
288                result.put("uploadedChunkIds", fileEntry.getOrderedChunkIndexes());
289                result.put("chunkCount", fileEntry.getChunkCount());
290                if (!fileEntry.isChunksCompleted()) {
291                    status = new ResumeIncompleteStatusType();
292                }
293            }
294        }
295        return buildResponse(status, result, isMultipart);
296    }
297
298    protected long getUploadedSize(HttpServletRequest request) {
299        String contentLength = request.getHeader("Content-Length");
300        if (contentLength == null) {
301            return -1;
302        }
303        return Long.parseLong(contentLength);
304    }
305
306    protected void addBlob(String uploadType, String batchId, String fileIdx, Blob blob, String fileName,
307            String mimeType, long uploadedSize, int chunkCount, int uploadChunkIndex, long fileSize) {
308        BatchManager bm = Framework.getService(BatchManager.class);
309        String uploadedSizeDisplay = uploadedSize > -1 ? uploadedSize + "b" : "unknown size";
310        Batch batch = bm.getBatch(batchId);
311        if (UPLOAD_TYPE_CHUNKED.equals(uploadType)) {
312            if (log.isDebugEnabled()) {
313                log.debug(String.format("Uploading chunk [index=%d / total=%d] (%s) for file %s", uploadChunkIndex,
314                        chunkCount, uploadedSizeDisplay, fileName));
315            }
316            batch.addChunk(fileIdx, blob, chunkCount, uploadChunkIndex, fileName, mimeType, fileSize);
317        } else {
318            if (log.isDebugEnabled()) {
319                log.debug(String.format("Uploading file %s (%s)", fileName, uploadedSizeDisplay));
320            }
321            batch.addFile(fileIdx, blob, fileName, mimeType);
322        }
323    }
324
325    @GET
326    @Path("{batchId}")
327    public Response getBatchInfo(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException {
328        BatchManager bm = Framework.getService(BatchManager.class);
329        if (!bm.hasBatch(batchId)) {
330            return buildEmptyResponse(Status.NOT_FOUND);
331        }
332        List<BatchFileEntry> fileEntries = bm.getFileEntries(batchId);
333        if (CollectionUtils.isEmpty(fileEntries)) {
334            return buildEmptyResponse(Status.NO_CONTENT);
335        }
336        List<Map<String, Object>> result = new ArrayList<>();
337        for (BatchFileEntry fileEntry : fileEntries) {
338            result.add(getFileInfo(fileEntry));
339        }
340        return buildResponse(Status.OK, result);
341    }
342
343    @GET
344    @Path("{batchId}/{fileIdx}")
345    public Response getFileInfo(@PathParam(REQUEST_BATCH_ID) String batchId,
346            @PathParam(REQUEST_FILE_IDX) String fileIdx) throws IOException {
347        BatchManager bm = Framework.getService(BatchManager.class);
348        if (!bm.hasBatch(batchId)) {
349            return buildEmptyResponse(Status.NOT_FOUND);
350        }
351        BatchFileEntry fileEntry = bm.getFileEntry(batchId, fileIdx);
352        if (fileEntry == null) {
353            return buildEmptyResponse(Status.NOT_FOUND);
354        }
355        StatusType status = Status.OK;
356        if (fileEntry.isChunked() && !fileEntry.isChunksCompleted()) {
357            status = new ResumeIncompleteStatusType();
358        }
359        Map<String, Object> result = getFileInfo(fileEntry);
360        return buildResponse(status, result);
361    }
362
363    @DELETE
364    @Path("{batchId}")
365    public Response cancel(@PathParam(REQUEST_BATCH_ID) String batchId) {
366        BatchManager bm = Framework.getService(BatchManager.class);
367        if (!bm.hasBatch(batchId)) {
368            return buildEmptyResponse(Status.NOT_FOUND);
369        }
370        bm.clean(batchId);
371        return buildEmptyResponse(Status.NO_CONTENT);
372    }
373
374    /**
375     * @since 8.4
376     */
377    @DELETE
378    @Path("{batchId}/{fileIdx}")
379    public Response removeFile(@PathParam(REQUEST_BATCH_ID) String batchId,
380            @PathParam(REQUEST_FILE_IDX) String fileIdx) {
381        BatchManager bm = Framework.getService(BatchManager.class);
382        if (!bm.removeFileEntry(batchId, fileIdx)) {
383            return buildEmptyResponse(Status.NOT_FOUND);
384        }
385        return buildEmptyResponse(Status.NO_CONTENT);
386    }
387
388    @Context
389    protected HttpServletRequest request;
390
391    @Context
392    protected HttpServletResponse response;
393
394    @POST
395    @Produces(MediaType.APPLICATION_JSON)
396    @Path("{batchId}/execute/{operationId}")
397    public Object execute(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(OPERATION_ID) String operationId,
398            ExecutionRequest xreq) {
399        return executeBatch(batchId, null, operationId, request, xreq);
400    }
401
402    @POST
403    @Produces(MediaType.APPLICATION_JSON)
404    @Path("{batchId}/{fileIdx}/execute/{operationId}")
405    public Object execute(@PathParam(REQUEST_BATCH_ID) String batchId, @PathParam(REQUEST_FILE_IDX) String fileIdx,
406            @PathParam(OPERATION_ID) String operationId, ExecutionRequest xreq) {
407        return executeBatch(batchId, fileIdx, operationId, request, xreq);
408    }
409
410    @GET
411    @Path("{batchId}/info")
412    public Response getBatchExtraInfo(@PathParam(REQUEST_BATCH_ID) String batchId) throws IOException {
413        BatchManager bm = Framework.getService(BatchManager.class);
414        if (!bm.hasBatch(batchId)) {
415            return buildEmptyResponse(Status.NOT_FOUND);
416        }
417        Batch batch = bm.getBatch(batchId);
418        Map<String, Object> properties = batch.getProperties();
419        List<BatchFileEntry> fileEntries = batch.getFileEntries();
420
421        List<Map<String, Object>> fileInfos = new ArrayList<>();
422        if (!CollectionUtils.isEmpty(fileEntries)) {
423            fileEntries.stream().map(this::getFileInfo).forEach(fileInfos::add);
424        }
425
426        Map<String, Object> result = new HashMap<>();
427        result.put("provider", batch.getHandlerName());
428        if (properties != null && !properties.isEmpty()) {
429            result.put("extraInfo", properties);
430        }
431
432        result.put("fileEntries", fileInfos);
433        result.put("batchId", batch.getKey());
434        return buildResponse(Status.OK, result);
435    }
436
437    @POST
438    @Path("{batchId}/{fileIdx}/complete")
439    public Response uploadCompleted(@PathParam(REQUEST_BATCH_ID) String batchId,
440            @PathParam(REQUEST_FILE_IDX) String fileIdx, String body) throws IOException {
441        BatchManager bm = Framework.getService(BatchManager.class);
442        JsonNode jsonNode = new ObjectMapper().readTree(body);
443
444        Batch batch = bm.getBatch(batchId);
445        if (batch == null) {
446            return buildEmptyResponse(Status.NOT_FOUND);
447        }
448
449        String key = jsonNode.hasNonNull(KEY) ? jsonNode.get(KEY).asText(null) : null;
450        String filename = jsonNode.hasNonNull(NAME) ? jsonNode.get(NAME).asText() : null;
451        String mimeType = jsonNode.hasNonNull(MIMETYPE) ? jsonNode.get(MIMETYPE).asText(null) : null;
452        Long length = jsonNode.hasNonNull(FILE_SIZE) ? jsonNode.get(FILE_SIZE).asLong() : -1L;
453        String md5 = jsonNode.hasNonNull(MD5) ? jsonNode.get(MD5).asText() : null;
454
455        BatchFileInfo batchFileInfo = new BatchFileInfo(key, filename, mimeType, length, md5);
456
457        BatchHandler handler = bm.getHandler(batch.getHandlerName());
458        if (!handler.completeUpload(batchId, fileIdx, batchFileInfo)) {
459            return Response.status(Status.CONFLICT).build();
460        }
461
462        Map<String, Object> result = new HashMap<>();
463        result.put("uploaded", "true");
464        result.put("batchId", batchId);
465        result.put("fileIdx", fileIdx);
466        return buildResponse(Status.OK, result);
467    }
468
469    protected Object executeBatch(String batchId, String fileIdx, String operationId, HttpServletRequest request,
470            ExecutionRequest xreq) {
471        BatchManager bm = Framework.getService(BatchManager.class);
472
473        if (!bm.hasBatch(batchId)) {
474            return buildEmptyResponse(Status.NOT_FOUND);
475        }
476
477        if (!Boolean.parseBoolean(
478                RequestContext.getActiveContext(request).getRequest().getHeader(BatchManagerConstants.NO_DROP_FLAG))) {
479            RequestContext.getActiveContext(request).addRequestCleanupHandler(req -> bm.clean(batchId));
480        }
481
482        try {
483            CoreSession session = ctx.getCoreSession();
484            Object result;
485            try (OperationContext ctx = xreq.createContext(request, response, session)) {
486                Map<String, Object> params = xreq.getParams();
487                if (StringUtils.isBlank(fileIdx)) {
488                    result = bm.execute(batchId, operationId, session, ctx, params);
489                } else {
490                    result = bm.execute(batchId, fileIdx, operationId, session, ctx, params);
491                }
492            }
493            return ResponseHelper.getResponse(result, request);
494        } catch (MessagingException | IOException e) {
495            log.error("Error while executing automation batch ", e);
496            throw new NuxeoException(e);
497        }
498    }
499
500    protected Response buildResponse(StatusType status, Object object) throws IOException {
501        return buildResponse(status, object, false);
502    }
503
504    protected Response buildResponse(StatusType status, Object object, boolean html) throws IOException {
505        ObjectMapper mapper = new ObjectMapper();
506        String result = mapper.writeValueAsString(object);
507        if (html) {
508            // For MSIE with iframe transport: we need to return HTML!
509            return buildHTMLResponse(status, result);
510        } else {
511            return buildJSONResponse(status, result);
512        }
513    }
514
515    protected Response buildJSONResponse(StatusType status, String message) throws UnsupportedEncodingException {
516        return buildResponse(status, MediaType.APPLICATION_JSON, message);
517    }
518
519    protected Response buildHTMLResponse(StatusType status, String message) throws UnsupportedEncodingException {
520        message = "<html>" + message + "</html>";
521        return buildResponse(status, MediaType.TEXT_HTML, message);
522    }
523
524    protected Response buildTextResponse(StatusType status, String message) throws UnsupportedEncodingException {
525        return buildResponse(status, MediaType.TEXT_PLAIN, message);
526    }
527
528    protected Response buildEmptyResponse(StatusType status) {
529        return Response.status(status).build();
530    }
531
532    protected Response buildResponse(StatusType status, String type, String message)
533            throws UnsupportedEncodingException {
534        return Response.status(status)
535                       .header("Content-Length", message.getBytes("UTF-8").length)
536                       .type(type + "; charset=UTF-8")
537                       .entity(message)
538                       .build();
539    }
540
541    protected Map<String, Object> getFileInfo(BatchFileEntry fileEntry) {
542        Map<String, Object> info = new HashMap<>();
543        boolean chunked = fileEntry.isChunked();
544        String uploadType;
545        if (chunked) {
546            uploadType = UPLOAD_TYPE_CHUNKED;
547        } else {
548            uploadType = UPLOAD_TYPE_NORMAL;
549        }
550        info.put("name", fileEntry.getFileName());
551        info.put("size", fileEntry.getFileSize());
552        info.put("uploadType", uploadType);
553        if (chunked) {
554            info.put("uploadedChunkIds", fileEntry.getOrderedChunkIndexes());
555            info.put("chunkCount", fileEntry.getChunkCount());
556        }
557        return info;
558    }
559
560    public final class ResumeIncompleteStatusType implements StatusType {
561
562        @Override
563        public int getStatusCode() {
564            return 308;
565        }
566
567        @Override
568        public String getReasonPhrase() {
569            return "Resume Incomplete";
570        }
571
572        @Override
573        public Family getFamily() {
574            // Technically we don't use 308 Resume Incomplete as a redirection but it is the default family for 3xx
575            // status codes defined by Response$Status
576            return Family.REDIRECTION;
577        }
578    }
579
580}