001/*
002 * (C) Copyright 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
018 *     Thomas Roger
019 */
020
021package org.nuxeo.wopi.jaxrs;
022
023import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
024import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
025import static javax.ws.rs.core.Response.Status.CONFLICT;
026import static javax.ws.rs.core.Response.Status.OK;
027import static javax.ws.rs.core.Response.Status.PRECONDITION_FAILED;
028import static org.nuxeo.ecm.core.api.CoreSession.SOURCE;
029import static org.nuxeo.wopi.Constants.ACCESS_TOKEN_PARAMETER;
030import static org.nuxeo.wopi.Constants.ACTION_EDIT;
031import static org.nuxeo.wopi.Constants.ACTION_VIEW;
032import static org.nuxeo.wopi.Constants.BASE_FILE_NAME;
033import static org.nuxeo.wopi.Constants.BREADCRUMB_BRAND_NAME;
034import static org.nuxeo.wopi.Constants.BREADCRUMB_BRAND_URL;
035import static org.nuxeo.wopi.Constants.BREADCRUMB_FOLDER_NAME;
036import static org.nuxeo.wopi.Constants.BREADCRUMB_FOLDER_URL;
037import static org.nuxeo.wopi.Constants.CLOSE_URL;
038import static org.nuxeo.wopi.Constants.DOWNLOAD_URL;
039import static org.nuxeo.wopi.Constants.FILES_ENDPOINT_PATH;
040import static org.nuxeo.wopi.Constants.FILE_VERSION_URL;
041import static org.nuxeo.wopi.Constants.HOST_EDIT_URL;
042import static org.nuxeo.wopi.Constants.HOST_VIEW_URL;
043import static org.nuxeo.wopi.Constants.IS_ANONYMOUS_USER;
044import static org.nuxeo.wopi.Constants.LICENSE_CHECK_FOR_EDIT_IS_ENABLED;
045import static org.nuxeo.wopi.Constants.NAME;
046import static org.nuxeo.wopi.Constants.NOTIFICATION_DOCUMENT_ID_CODEC_NAME;
047import static org.nuxeo.wopi.Constants.OPERATION_CHECK_FILE_INFO;
048import static org.nuxeo.wopi.Constants.OPERATION_GET_FILE;
049import static org.nuxeo.wopi.Constants.OPERATION_GET_LOCK;
050import static org.nuxeo.wopi.Constants.OPERATION_GET_SHARE_URL;
051import static org.nuxeo.wopi.Constants.OPERATION_LOCK;
052import static org.nuxeo.wopi.Constants.OPERATION_PUT_FILE;
053import static org.nuxeo.wopi.Constants.OPERATION_PUT_RELATIVE_FILE;
054import static org.nuxeo.wopi.Constants.OPERATION_REFRESH_LOCK;
055import static org.nuxeo.wopi.Constants.OPERATION_RENAME_FILE;
056import static org.nuxeo.wopi.Constants.OPERATION_UNLOCK;
057import static org.nuxeo.wopi.Constants.OPERATION_UNLOCK_AND_RELOCK;
058import static org.nuxeo.wopi.Constants.OWNER_ID;
059import static org.nuxeo.wopi.Constants.READ_ONLY;
060import static org.nuxeo.wopi.Constants.SHARE_URL;
061import static org.nuxeo.wopi.Constants.SHARE_URL_READ_ONLY;
062import static org.nuxeo.wopi.Constants.SHARE_URL_READ_WRITE;
063import static org.nuxeo.wopi.Constants.SIGNOUT_URL;
064import static org.nuxeo.wopi.Constants.SIZE;
065import static org.nuxeo.wopi.Constants.SUPPORTED_SHARE_URL_TYPES;
066import static org.nuxeo.wopi.Constants.SUPPORTS_EXTENDED_LOCK_LENGTH;
067import static org.nuxeo.wopi.Constants.SUPPORTS_GET_LOCK;
068import static org.nuxeo.wopi.Constants.SUPPORTS_LOCKS;
069import static org.nuxeo.wopi.Constants.SUPPORTS_RENAME;
070import static org.nuxeo.wopi.Constants.SUPPORTS_UPDATE;
071import static org.nuxeo.wopi.Constants.URL;
072import static org.nuxeo.wopi.Constants.USER_CAN_NOT_WRITE_RELATIVE;
073import static org.nuxeo.wopi.Constants.USER_CAN_RENAME;
074import static org.nuxeo.wopi.Constants.USER_CAN_WRITE;
075import static org.nuxeo.wopi.Constants.USER_FRIENDLY_NAME;
076import static org.nuxeo.wopi.Constants.USER_ID;
077import static org.nuxeo.wopi.Constants.VERSION;
078import static org.nuxeo.wopi.Constants.WOPI_BASE_URL_PROPERTY;
079import static org.nuxeo.wopi.Constants.WOPI_SOURCE;
080import static org.nuxeo.wopi.Headers.FILE_CONVERSION;
081import static org.nuxeo.wopi.Headers.ITEM_VERSION;
082import static org.nuxeo.wopi.Headers.LOCK;
083import static org.nuxeo.wopi.Headers.MAX_EXPECTED_SIZE;
084import static org.nuxeo.wopi.Headers.OLD_LOCK;
085import static org.nuxeo.wopi.Headers.OVERRIDE;
086import static org.nuxeo.wopi.Headers.RELATIVE_TARGET;
087import static org.nuxeo.wopi.Headers.REQUESTED_NAME;
088import static org.nuxeo.wopi.Headers.SUGGESTED_TARGET;
089import static org.nuxeo.wopi.Headers.URL_TYPE;
090import static org.nuxeo.wopi.Operation.PUT;
091
092import java.io.IOException;
093import java.io.InputStream;
094import java.io.Serializable;
095import java.util.Arrays;
096import java.util.HashMap;
097import java.util.Map;
098import java.util.function.Supplier;
099
100import javax.servlet.http.HttpServletRequest;
101import javax.servlet.http.HttpServletResponse;
102import javax.ws.rs.GET;
103import javax.ws.rs.HeaderParam;
104import javax.ws.rs.POST;
105import javax.ws.rs.Path;
106import javax.ws.rs.Produces;
107import javax.ws.rs.core.Context;
108import javax.ws.rs.core.HttpHeaders;
109import javax.ws.rs.core.MediaType;
110import javax.ws.rs.core.Response;
111
112import org.apache.commons.io.FilenameUtils;
113import org.apache.commons.lang3.ArrayUtils;
114import org.apache.commons.lang3.StringUtils;
115import org.apache.logging.log4j.LogManager;
116import org.apache.logging.log4j.Logger;
117import org.nuxeo.common.Environment;
118import org.nuxeo.ecm.core.api.Blob;
119import org.nuxeo.ecm.core.api.Blobs;
120import org.nuxeo.ecm.core.api.CoreInstance;
121import org.nuxeo.ecm.core.api.CoreSession;
122import org.nuxeo.ecm.core.api.DocumentLocation;
123import org.nuxeo.ecm.core.api.DocumentModel;
124import org.nuxeo.ecm.core.api.DocumentRef;
125import org.nuxeo.ecm.core.api.NuxeoException;
126import org.nuxeo.ecm.core.api.NuxeoPrincipal;
127import org.nuxeo.ecm.core.api.impl.DocumentLocationImpl;
128import org.nuxeo.ecm.core.api.security.SecurityConstants;
129import org.nuxeo.ecm.core.io.download.DownloadService;
130import org.nuxeo.ecm.platform.types.adapter.TypeInfo;
131import org.nuxeo.ecm.platform.ui.web.auth.NXAuthConstants;
132import org.nuxeo.ecm.platform.url.DocumentViewImpl;
133import org.nuxeo.ecm.platform.url.api.DocumentView;
134import org.nuxeo.ecm.platform.url.api.DocumentViewCodecManager;
135import org.nuxeo.ecm.platform.web.common.vh.VirtualHostHelper;
136import org.nuxeo.ecm.webengine.model.WebObject;
137import org.nuxeo.ecm.webengine.model.impl.DefaultObject;
138import org.nuxeo.runtime.api.Framework;
139import org.nuxeo.wopi.FileInfo;
140import org.nuxeo.wopi.Helpers;
141import org.nuxeo.wopi.Operation;
142import org.nuxeo.wopi.exception.BadRequestException;
143import org.nuxeo.wopi.exception.ConflictException;
144import org.nuxeo.wopi.exception.NotImplementedException;
145import org.nuxeo.wopi.exception.PreConditionFailedException;
146import org.nuxeo.wopi.lock.LockHelper;
147
148/**
149 * Implementation of the Files endpoint.
150 * <p>
151 * See <a href="https://wopirest.readthedocs.io/en/latest/endpoints.html#files-endpoint">Files endpoint</a>.
152 *
153 * @since 10.3
154 */
155@WebObject(type = "wopiFiles")
156public class FilesEndpoint extends DefaultObject {
157
158    private static final Logger log = LogManager.getLogger(FilesEndpoint.class);
159
160    @Context
161    protected HttpServletRequest request;
162
163    @Context
164    protected HttpServletResponse response;
165
166    @Context
167    protected HttpHeaders httpHeaders;
168
169    protected CoreSession session;
170
171    protected DocumentModel doc;
172
173    protected Blob blob;
174
175    protected String xpath;
176
177    protected String fileId;
178
179    protected String baseURL;
180
181    protected String wopiBaseURL;
182
183    @Override
184    public void initialize(Object... args) {
185        if (args == null || args.length != 4) {
186            throw new IllegalArgumentException("Invalid args: " + args);
187        }
188        session = (CoreSession) args[0];
189        doc = (DocumentModel) args[1];
190        blob = (Blob) args[2];
191        xpath = (String) args[3];
192        fileId = FileInfo.computeFileId(doc, xpath);
193        baseURL = VirtualHostHelper.getBaseURL(request);
194        wopiBaseURL = Framework.getProperty(WOPI_BASE_URL_PROPERTY, baseURL);
195    }
196
197    /**
198     * Implements the CheckFileInfo operation.
199     * <p>
200     * See <a href="https://wopirest.readthedocs.io/en/latest/files/CheckFileInfo.html">CheckFileInfo</a>.
201     */
202    @GET
203    @Produces(MediaType.APPLICATION_JSON)
204    public Response checkFileInfo() {
205        logRequest(OPERATION_CHECK_FILE_INFO);
206        Map<String, Serializable> checkFileInfoMap = buildCheckFileInfoMap();
207        logResponse(OPERATION_CHECK_FILE_INFO, OK.getStatusCode(), checkFileInfoMap);
208        return Response.ok(checkFileInfoMap).build();
209    }
210
211    /**
212     * Implements the GetFile operation.
213     * <p>
214     * See <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/files/GetFile.html">GetFile</a>.
215     */
216    @GET
217    @Path("contents")
218    public Object getFile(@HeaderParam(MAX_EXPECTED_SIZE) String maxExpectedSizeHeader) {
219        int maxExpectedSize = getMaxExpectedSize(maxExpectedSizeHeader);
220        logRequest(OPERATION_GET_FILE, MAX_EXPECTED_SIZE, maxExpectedSizeHeader);
221
222        long blobLength = blob.getLength();
223        if (blobLength > maxExpectedSize) {
224            logCondition(() -> "Blob length " + blobLength + " > max expected size " + maxExpectedSize);
225            logResponse(OPERATION_GET_FILE, PRECONDITION_FAILED.getStatusCode());
226            throw new PreConditionFailedException();
227        }
228
229        String versionLabel = doc.getVersionLabel();
230        response.addHeader(ITEM_VERSION, versionLabel);
231        logResponse(OPERATION_GET_FILE, OK.getStatusCode(), ITEM_VERSION, versionLabel);
232        return blob;
233    }
234
235    @POST
236    public Object doPost(@HeaderParam(OVERRIDE) Operation operation) {
237        switch (operation) {
238        case GET_LOCK:
239            return getLock();
240        case GET_SHARE_URL:
241            return getShareUrl();
242        case LOCK:
243            return lockOrUnlockAndRelock();
244        case PUT_RELATIVE:
245            return putRelativeFile();
246        case REFRESH_LOCK:
247            return refreshLock();
248        case RENAME_FILE:
249            return renameFile();
250        case UNLOCK:
251            return unlock();
252        default:
253            throw new BadRequestException();
254        }
255    }
256
257    /**
258     * Implements the Lock and UnlockAndRelock operations.
259     * <p>
260     * See <a href="https://wopirest.readthedocs.io/en/latest/files/Lock.html">Lock</a> and
261     * <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/files/UnlockAndRelock.html">UnlockAndRelock</a>.
262     */
263    protected Object lockOrUnlockAndRelock() {
264        String lock = getHeader(OPERATION_LOCK, LOCK);
265        String oldLock = getHeader(OPERATION_LOCK, OLD_LOCK, true);
266        return StringUtils.isEmpty(oldLock) ? lock(lock) : unlockAndRelock(lock, oldLock);
267    }
268
269    protected Object lock(String lock) {
270        logRequest(OPERATION_LOCK, LOCK, lock);
271        boolean isLocked = doc.isLocked();
272        // document not locked or no WOPI lock for this file id
273        if (!isLocked || !LockHelper.isLocked(fileId)) {
274            logCondition("Document isn't locked or there is no WOPI lock for this file id");
275            checkWritePropertiesPermission(OPERATION_LOCK);
276            // lock if needed
277            if (!isLocked) {
278                logCondition("Document isn't locked"); // NOSONAR
279                logNuxeoAction("Locking document");
280                doc.setLock();
281            }
282            LockHelper.addLock(fileId, lock);
283
284            String versionLabel = doc.getVersionLabel();
285            response.addHeader(ITEM_VERSION, versionLabel);
286            logResponse(OPERATION_LOCK, OK.getStatusCode(), ITEM_VERSION, versionLabel);
287            return Response.ok().build();
288        }
289
290        logCondition("Document is locked and there is a WOPI lock for this file id");
291        String currentLock = getCurrentLock(OPERATION_LOCK);
292        if (lock.equals(currentLock)) {
293            logCondition(() -> LOCK + " header is equal to current WOPI lock"); // NOSONAR
294            // refresh lock
295            LockHelper.refreshLock(fileId);
296            String versionLabel = doc.getVersionLabel();
297            response.addHeader(ITEM_VERSION, versionLabel);
298            logResponse(OPERATION_LOCK, OK.getStatusCode(), ITEM_VERSION, versionLabel);
299            return Response.ok().build();
300        } else {
301            logCondition(() -> LOCK + " header is not equal to current WOPI lock"); // NOSONAR
302            return buildConflictResponse(OPERATION_LOCK, currentLock);
303        }
304    }
305
306    protected Object unlockAndRelock(String lock, String oldLock) {
307        logRequest(OPERATION_UNLOCK_AND_RELOCK, LOCK, lock, OLD_LOCK, oldLock);
308        boolean isLocked = doc.isLocked();
309        // document not locked
310        if (!isLocked) {
311            logCondition("Document isn't locked");
312            // cannot unlock and relock
313            buildConflictResponse(OPERATION_UNLOCK_AND_RELOCK, "");
314        }
315
316        logCondition("Document is locked");
317        String currentLock = getCurrentLock(OPERATION_UNLOCK_AND_RELOCK);
318        if (oldLock.equals(currentLock)) {
319            logCondition(() -> OLD_LOCK + " header is equal to current WOPI lock");
320            // unlock and relock
321            LockHelper.updateLock(fileId, lock);
322            logResponse(OPERATION_UNLOCK_AND_RELOCK, OK.getStatusCode());
323            return Response.ok().build();
324        } else {
325            logCondition(() -> OLD_LOCK + " header is not equal to current WOPI lock");
326            return buildConflictResponse(OPERATION_UNLOCK_AND_RELOCK, currentLock);
327        }
328    }
329
330    /**
331     * Returns the WOPI lock if not null and throws a {@link ConflictException} otherwise.
332     * <p>
333     * Must be called to check that a locked document is not locked by Nuxeo.
334     */
335    protected String getCurrentLock(String operation) {
336        String currentLock = LockHelper.getLock(fileId);
337        if (currentLock == null) {
338            logCondition("Current WOPI lock not found");
339            // locked by Nuxeo
340            logResponse(operation, CONFLICT.getStatusCode());
341            throw new ConflictException();
342        }
343        return currentLock;
344    }
345
346    /**
347     * Builds a conflict response with the given WOPI lock as a header.
348     * <p>
349     * Must be called in case of "lock mismatch", for instance when a document is locked by another WOPI client.
350     */
351    protected Response buildConflictResponse(String operation, String lock) {
352        response.addHeader(LOCK, lock);
353        logResponse(operation, CONFLICT.getStatusCode(), LOCK, lock);
354        return Response.status(CONFLICT).build();
355    }
356
357    /**
358     * Implements the GetLock operation.
359     * <p>
360     * See <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/files/GetLock.html">GetLock</a>.
361     */
362    protected Object getLock() {
363        logRequest(OPERATION_GET_LOCK);
364
365        if (!doc.isLocked()) {
366            logCondition("Document isn't locked");
367            String lockHeader = "";
368            response.addHeader(LOCK, lockHeader);
369            logResponse(OPERATION_GET_LOCK, OK.getStatusCode(), LOCK, lockHeader);
370            return Response.ok().build();
371        }
372
373        String currentLock = getCurrentLock(OPERATION_GET_LOCK);
374        response.addHeader(LOCK, currentLock);
375        logResponse(OPERATION_GET_LOCK, OK.getStatusCode(), LOCK, currentLock);
376        return Response.ok().build();
377    }
378
379    protected Object unlockOrRefresh(String operation, String lock, boolean unlock) {
380        if (!doc.isLocked()) {
381            logCondition("Document isn't locked");
382            // not locked
383            buildConflictResponse(operation, "");
384        }
385
386        String currentLock = getCurrentLock(operation);
387        if (lock.equals(currentLock)) {
388            logCondition(() -> LOCK + " header is equal to current WOPI lock");
389            checkWritePropertiesPermission(operation);
390            if (unlock) {
391                // remove WOPI lock
392                LockHelper.removeLock(fileId);
393                if (!LockHelper.isLocked(doc.getRepositoryName(), doc.getId())) {
394                    logCondition("Found no WOPI lock");
395                    // no more WOPI lock on the document, unlock the doc
396                    // use a privileged session since the document might have been locked by another user
397                    logNuxeoAction("Unlocking document with a privileged session");
398                    CoreInstance.doPrivileged(doc.getRepositoryName(), privilegedSession -> { // NOSONAR
399                        return privilegedSession.removeLock(doc.getRef());
400                    });
401                }
402                String versionLabel = doc.getVersionLabel();
403                response.addHeader(ITEM_VERSION, versionLabel);
404                logResponse(operation, OK.getStatusCode(), ITEM_VERSION, versionLabel);
405            } else {
406                // refresh lock
407                LockHelper.refreshLock(fileId);
408                logResponse(operation, OK.getStatusCode());
409            }
410            return Response.ok().build();
411        } else {
412            logCondition(() -> LOCK + " header is not equal to current WOPI lock");
413            return buildConflictResponse(operation, currentLock);
414        }
415    }
416
417    /**
418     * Implements the PutRelativeFile operation.
419     * <p>
420     * We do not handle any conflict or overwrite here. Nuxeo can have more than one document with the same title and
421     * blob file name.
422     * <p>
423     * See
424     * <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/files/PutRelativeFile.html">PutRelativeFile</a>.
425     */
426    public Object putRelativeFile() {
427        String suggestedTarget = getHeader(OPERATION_PUT_RELATIVE_FILE, SUGGESTED_TARGET, true);
428        if (suggestedTarget != null) {
429            suggestedTarget = Helpers.readUTF7String(suggestedTarget);
430        }
431        String relativeTarget = getHeader(OPERATION_PUT_RELATIVE_FILE, RELATIVE_TARGET, true);
432        if (relativeTarget != null) {
433            relativeTarget = Helpers.readUTF7String(relativeTarget);
434        }
435        String fileConversion = getHeader(OPERATION_PUT_RELATIVE_FILE, FILE_CONVERSION, true);
436        logRequest(OPERATION_PUT_RELATIVE_FILE, SUGGESTED_TARGET, suggestedTarget, RELATIVE_TARGET, relativeTarget,
437                FILE_CONVERSION, fileConversion);
438
439        // exactly one should be empty
440        if (StringUtils.isEmpty(suggestedTarget) == StringUtils.isEmpty(relativeTarget)) {
441            logCondition(() -> SUGGESTED_TARGET + " and " + RELATIVE_TARGET
442                    + " headers are both present or not present, yet they are mutually exclusive");
443            logResponse(OPERATION_PUT_RELATIVE_FILE, SC_NOT_IMPLEMENTED);
444            throw new NotImplementedException();
445        }
446
447        final String newFileName;
448        if (StringUtils.isNotEmpty(suggestedTarget)) {
449            logCondition(() -> SUGGESTED_TARGET + " header is present");
450            newFileName = suggestedTarget.startsWith(".")
451                    ? FilenameUtils.getBaseName(blob.getFilename()) + suggestedTarget
452                    : suggestedTarget;
453        } else {
454            newFileName = relativeTarget;
455        }
456
457        // handle either new file creation or binary file conversion
458        DocumentModel newDoc = null;
459        if (StringUtils.isEmpty(fileConversion)) {
460            logCondition(() -> FILE_CONVERSION + " header is not present, handling new file creation");
461            newDoc = createSiblingCopyFromRequestBody(newFileName);
462        } else {
463            logCondition(() -> FILE_CONVERSION + " header is present, handling file conversion");
464            newDoc = createVersionFromRequestBody(newFileName);
465        }
466
467        String token = Helpers.getJWTToken(request);
468        String newFileId = FileInfo.computeFileId(newDoc, xpath);
469        String wopiSrc = String.format("%s%s%s?%s=%s", wopiBaseURL, FILES_ENDPOINT_PATH, newFileId,
470                ACCESS_TOKEN_PARAMETER, token);
471        String hostViewUrl = Helpers.getWOPIURL(baseURL, ACTION_VIEW, newDoc, xpath);
472        String hostEditUrl = Helpers.getWOPIURL(baseURL, ACTION_EDIT, newDoc, xpath);
473
474        Map<String, Serializable> map = new HashMap<>();
475        map.put(NAME, newFileName);
476        map.put(URL, wopiSrc);
477        map.put(HOST_VIEW_URL, hostViewUrl);
478        map.put(HOST_EDIT_URL, hostEditUrl);
479        logResponse(OPERATION_PUT_RELATIVE_FILE, OK.getStatusCode(), map);
480        return Response.ok(map).type(MediaType.APPLICATION_JSON).build();
481    }
482
483    protected DocumentModel createSiblingCopyFromRequestBody(String filename) {
484        DocumentRef parentRef = doc.getParentRef();
485        if (!session.exists(parentRef) || !session.hasPermission(parentRef, SecurityConstants.ADD_CHILDREN)) {
486            logCondition(() -> "Either the parent document doesn't exist or the current user isn't granted "
487                    + SecurityConstants.ADD_CHILDREN + " access");
488            logResponse(OPERATION_PUT_RELATIVE_FILE, SC_NOT_IMPLEMENTED);
489            throw new NotImplementedException();
490        }
491
492        DocumentModel parent = session.getDocument(parentRef);
493        DocumentModel newDoc = session.createDocumentModel(parent.getPathAsString(), filename, doc.getType());
494        newDoc.copyContent(doc);
495        newDoc.setPropertyValue("dc:title", filename);
496
497        Blob newBlob = createBlobFromRequestBody(filename, null);
498        newDoc.setPropertyValue(xpath, (Serializable) newBlob);
499        newDoc = session.createDocument(newDoc);
500        String newDocId = newDoc.getId();
501        logNuxeoAction(() -> "Created new document " + newDocId + " as a child of " + parent.getId() + " with filename "
502                + filename);
503        return newDoc;
504    }
505
506    protected DocumentModel createVersionFromRequestBody(String filename) {
507        Blob newBlob = createBlobFromRequestBody(filename, null);
508        doc.setPropertyValue(xpath, (Serializable) newBlob);
509        doc.putContextData(SOURCE, WOPI_SOURCE);
510        doc = session.saveDocument(doc);
511        logNuxeoAction(() -> "Created a version of document " + doc.getId() + " with filename " + filename);
512        return doc;
513    }
514
515    /**
516     * Implements the RenameFile operation.
517     * <p>
518     * See <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/files/RenameFile.html">RenameFile</a>.
519     */
520    public Object renameFile() {
521        checkWritePropertiesPermission(OPERATION_RENAME_FILE);
522
523        String requestedName = Helpers.readUTF7String(getHeader(OPERATION_RENAME_FILE, REQUESTED_NAME));
524        if (!doc.isLocked()) {
525            logCondition("Document isn't locked");
526            logRequest(OPERATION_RENAME_FILE, REQUESTED_NAME, requestedName);
527            return renameBlob(requestedName);
528        }
529
530        String currentLock = getCurrentLock(OPERATION_RENAME_FILE);
531        String lock = getHeader(OPERATION_RENAME_FILE, LOCK);
532        logRequest(OPERATION_RENAME_FILE, REQUESTED_NAME, requestedName, LOCK, lock);
533        if (lock.equals(currentLock)) {
534            logCondition(() -> LOCK + " header is equal to current WOPI lock");
535            return renameBlob(requestedName);
536        } else {
537            logCondition(() -> LOCK + " header is not equal to current WOPI lock");
538            return buildConflictResponse(OPERATION_RENAME_FILE, currentLock);
539        }
540    }
541
542    /**
543     * Renames the blob with the {@code requestedName}.
544     *
545     * @return the expected JSON response for the RenameFile operation.
546     */
547    protected Response renameBlob(String requestedName) {
548        String extension = FilenameUtils.getExtension(blob.getFilename());
549        String fullFilename = requestedName + (extension != null ? "." + extension : "");
550        logNuxeoAction(() -> "Renaming blob to " + fullFilename);
551        blob.setFilename(fullFilename);
552        doc.setPropertyValue(xpath, (Serializable) blob);
553        doc.putContextData(SOURCE, WOPI_SOURCE);
554        session.saveDocument(doc);
555
556        Map<String, Serializable> map = new HashMap<>();
557        map.put(NAME, requestedName);
558        logResponse(OPERATION_RENAME_FILE, OK.getStatusCode(), map);
559        return Response.ok(map).type(MediaType.APPLICATION_JSON).build();
560    }
561
562    /**
563     * Implements the GetShareUrl operation.
564     * <p>
565     * See <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/files/GetShareUrl.html">GetShareUrl</a>.
566     */
567    public Object getShareUrl() {
568        String urlType = getHeader(OPERATION_GET_SHARE_URL, URL_TYPE, true);
569        logRequest(OPERATION_GET_SHARE_URL, URL_TYPE, urlType);
570
571        if (!SHARE_URL_READ_ONLY.equals(urlType) && !SHARE_URL_READ_WRITE.equals(urlType)) {
572            logCondition(
573                    () -> URL_TYPE + " header should be either " + SHARE_URL_READ_ONLY + " or " + SHARE_URL_READ_WRITE);
574            logResponse(OPERATION_GET_SHARE_URL, SC_NOT_IMPLEMENTED);
575            throw new NotImplementedException();
576        }
577
578        String shareURL = Helpers.getWOPIURL(baseURL, urlType.equals(SHARE_URL_READ_ONLY) ? ACTION_VIEW : ACTION_EDIT,
579                doc, xpath);
580
581        Map<String, Serializable> map = new HashMap<>();
582        map.put(SHARE_URL, shareURL);
583        logResponse(OPERATION_GET_SHARE_URL, OK.getStatusCode(), map);
584        return Response.ok(map).type(MediaType.APPLICATION_JSON).build();
585    }
586
587    @POST
588    @Path("contents")
589    public Object doPostContents(@HeaderParam(OVERRIDE) Operation operation) {
590        if (PUT.equals(operation)) {
591            return putFile();
592        }
593        logCondition(() -> "Invalid value " + operation + " for " + OVERRIDE + " header, should be " + PUT.name());
594        throw new BadRequestException();
595    }
596
597    /**
598     * Implements the PutFile operation.
599     * <p>
600     * See <a href="https://wopi.readthedocs.io/projects/wopirest/en/latest/files/PutFile.html">PutFile</a>.
601     */
602    public Object putFile() {
603        checkWritePropertiesPermission(OPERATION_PUT_FILE);
604
605        if (!doc.isLocked()) {
606            logRequest(OPERATION_PUT_FILE);
607            logCondition("Document isn't locked");
608            if (blob.getLength() == 0) {
609                logCondition("Blob is empty");
610                return updateBlob();
611            }
612            logCondition("Blob is not empty");
613            buildConflictResponse(OPERATION_PUT_FILE, "");
614        }
615
616        String currentLock = getCurrentLock(OPERATION_PUT_FILE);
617        String lock = getHeader(OPERATION_PUT_FILE, LOCK);
618        logRequest(OPERATION_PUT_FILE, LOCK, lock);
619        if (lock.equals(currentLock)) {
620            logCondition(() -> LOCK + " header is equal to current WOPI lock");
621            return updateBlob();
622        } else {
623            logCondition(() -> LOCK + " header is not equal to current WOPI lock");
624            return buildConflictResponse(OPERATION_PUT_FILE, currentLock);
625        }
626    }
627
628    /**
629     * Updates the document's blob from a new one.
630     *
631     * @return the expected response for the PutFile operation, with the 'X-WOPI-ItemVersion' header set.
632     */
633    protected Response updateBlob() {
634        logNuxeoAction("Updating blob");
635        Blob newBlob = createBlobFromRequestBody(blob.getFilename(), blob.getMimeType());
636        doc.setPropertyValue(xpath, (Serializable) newBlob);
637        doc.putContextData(SOURCE, WOPI_SOURCE);
638        doc = session.saveDocument(doc);
639
640        String versionLabel = doc.getVersionLabel();
641        response.addHeader(ITEM_VERSION, versionLabel);
642        logResponse(OPERATION_PUT_FILE, OK.getStatusCode(), ITEM_VERSION, versionLabel);
643        return Response.ok().build();
644    }
645
646    /**
647     * Creates a new blob from the request body, given a {@code filename} and an optional {@code mimeType}.
648     *
649     * @return the new blob
650     */
651    protected Blob createBlobFromRequestBody(String filename, String mimeType) {
652        try (InputStream is = request.getInputStream()) {
653            Blob newBlob = Blobs.createBlob(is);
654            newBlob.setFilename(filename);
655            newBlob.setMimeType(mimeType);
656            return newBlob;
657        } catch (IOException e) {
658            throw new NuxeoException(e);
659        }
660    }
661
662    /**
663     * Implements the Unlock operation.
664     * <p>
665     * See <a href="https://wopirest.readthedocs.io/en/latest/files/Unlock.html">Unlock</a>.
666     */
667    protected Object unlock() {
668        String lock = getHeader(OPERATION_UNLOCK, LOCK);
669        logRequest(OPERATION_UNLOCK, LOCK, lock);
670        return unlockOrRefresh(OPERATION_UNLOCK, lock, true);
671    }
672
673    /**
674     * Implements the RefreshLock operation.
675     * <p>
676     * See <a href="https://wopirest.readthedocs.io/en/latest/files/RefreshLock.html">RefreshLock</a>.
677     */
678    protected Object refreshLock() {
679        String lock = getHeader(OPERATION_REFRESH_LOCK, LOCK);
680        logRequest(OPERATION_REFRESH_LOCK, LOCK, lock);
681        return unlockOrRefresh(OPERATION_REFRESH_LOCK, lock, false);
682    }
683
684    protected int getMaxExpectedSize(String maxExpectedSizeHeader) {
685        if (!StringUtils.isEmpty(maxExpectedSizeHeader)) {
686            try {
687                return Integer.parseInt(maxExpectedSizeHeader, 10);
688            } catch (NumberFormatException e) {
689                // do nothing
690            }
691        }
692        return Integer.MAX_VALUE;
693    }
694
695    protected String getHeader(String operation, String headerName) {
696        return getHeader(operation, headerName, false);
697    }
698
699    protected String getHeader(String operation, String headerName, boolean nullable) {
700        String header = Helpers.getHeader(httpHeaders, headerName);
701        if (StringUtils.isEmpty(header) && !nullable) {
702            logCondition(() -> "Header " + headerName + " is not present yet not nullable");
703            logResponse(operation, BAD_REQUEST.getStatusCode());
704            throw new BadRequestException();
705        }
706        return header;
707    }
708
709    protected void checkWritePropertiesPermission(String operation) {
710        if (!session.hasPermission(doc.getRef(), SecurityConstants.WRITE_PROPERTIES)) {
711            logCondition("Write permission check failed");
712            // cannot rename blob
713            logResponse(operation, CONFLICT.getStatusCode());
714            throw new ConflictException();
715        }
716    }
717
718    protected Map<String, Serializable> buildCheckFileInfoMap() {
719        Map<String, Serializable> map = new HashMap<>();
720        addRequiredProperties(map);
721        addHostCapabilitiesProperties(map);
722        addUserMetadataProperties(map);
723        addUserPermissionsProperties(map);
724        addFileURLProperties(map);
725        addBreadcrumbProperties(map);
726        return map;
727    }
728
729    protected void addRequiredProperties(Map<String, Serializable> map) {
730        NuxeoPrincipal principal = session.getPrincipal();
731        map.put(BASE_FILE_NAME, blob.getFilename());
732        map.put(OWNER_ID, doc.getPropertyValue("dc:creator"));
733        map.put(SIZE, blob.getLength());
734        map.put(USER_ID, principal.getName());
735        map.put(VERSION, doc.getVersionLabel());
736    }
737
738    protected void addHostCapabilitiesProperties(Map<String, Serializable> map) {
739        map.put(SUPPORTS_EXTENDED_LOCK_LENGTH, true);
740        map.put(SUPPORTS_GET_LOCK, true);
741        map.put(SUPPORTS_LOCKS, true);
742        map.put(SUPPORTS_RENAME, true);
743        map.put(SUPPORTS_UPDATE, true);
744        map.put(SUPPORTED_SHARE_URL_TYPES, (Serializable) Arrays.asList(SHARE_URL_READ_ONLY, SHARE_URL_READ_WRITE));
745    }
746
747    protected void addUserMetadataProperties(Map<String, Serializable> map) {
748        NuxeoPrincipal principal = session.getPrincipal();
749        map.put(IS_ANONYMOUS_USER, principal.isAnonymous());
750        map.put(LICENSE_CHECK_FOR_EDIT_IS_ENABLED, true);
751        map.put(USER_FRIENDLY_NAME, Helpers.principalFullName(principal));
752    }
753
754    protected void addUserPermissionsProperties(Map<String, Serializable> map) {
755        boolean hasAddChildren = session.exists(doc.getParentRef())
756                && session.hasPermission(doc.getParentRef(), SecurityConstants.ADD_CHILDREN);
757        boolean hasWriteProperties = session.hasPermission(doc.getRef(), SecurityConstants.WRITE_PROPERTIES);
758        map.put(READ_ONLY, !hasWriteProperties);
759        map.put(USER_CAN_RENAME, hasWriteProperties);
760        map.put(USER_CAN_WRITE, hasWriteProperties);
761        map.put(USER_CAN_NOT_WRITE_RELATIVE, !hasAddChildren);
762    }
763
764    protected void addFileURLProperties(Map<String, Serializable> map) {
765        String docURL = getDocumentURL(doc);
766        if (docURL != null) {
767            map.put(CLOSE_URL, docURL);
768            map.put(FILE_VERSION_URL, docURL);
769        }
770        String downloadURL = baseURL
771                + Framework.getService(DownloadService.class).getDownloadUrl(doc, xpath, blob.getFilename());
772        map.put(DOWNLOAD_URL, downloadURL);
773        map.put(HOST_EDIT_URL, Helpers.getWOPIURL(baseURL, ACTION_EDIT, doc, xpath));
774        map.put(HOST_VIEW_URL, Helpers.getWOPIURL(baseURL, ACTION_VIEW, doc, xpath));
775        String signoutURL = baseURL + NXAuthConstants.LOGOUT_PAGE;
776        map.put(SIGNOUT_URL, signoutURL);
777    }
778
779    protected void addBreadcrumbProperties(Map<String, Serializable> map) {
780        map.put(BREADCRUMB_BRAND_NAME, Framework.getProperty(Environment.PRODUCT_NAME));
781        map.put(BREADCRUMB_BRAND_URL, baseURL);
782
783        DocumentRef parentRef = doc.getParentRef();
784        if (session.exists(parentRef)) {
785            DocumentModel parent = session.getDocument(parentRef);
786            map.put(BREADCRUMB_FOLDER_NAME, parent.getTitle());
787            String url = getDocumentURL(parent);
788            if (url != null) {
789                map.put(BREADCRUMB_FOLDER_URL, url);
790            }
791        }
792    }
793
794    protected String getDocumentURL(DocumentModel doc) {
795        TypeInfo adapter = doc.getAdapter(TypeInfo.class);
796        if (adapter != null) {
797            DocumentLocation docLoc = new DocumentLocationImpl(doc);
798            DocumentView docView = new DocumentViewImpl(docLoc, adapter.getDefaultView());
799            return Framework.getService(DocumentViewCodecManager.class)
800                            .getUrlFromDocumentView(NOTIFICATION_DOCUMENT_ID_CODEC_NAME, docView, true, baseURL);
801        }
802        return null;
803    }
804
805    protected void logRequest(String operation, String... headers) {
806        log.debug("Request: repository={} docId={} xpath={} user={} fileId={} operation={}{}", doc::getRepositoryName,
807                doc::getId, () -> xpath, session::getPrincipal, () -> fileId, () -> operation,
808                () -> getHeaderString(headers));
809    }
810
811    protected void logCondition(String condition) {
812        logCondition(() -> condition);
813    }
814
815    protected void logCondition(Supplier<String> condition) {
816        log.debug("Condition: repository={} docId={} xpath={} user={} fileId={} {}", doc::getRepositoryName, doc::getId,
817                () -> xpath, session::getPrincipal, () -> fileId, condition::get);
818    }
819
820    protected void logNuxeoAction(String action) {
821        logNuxeoAction(() -> action);
822    }
823
824    protected void logNuxeoAction(Supplier<String> action) {
825        log.debug("Nuxeo action: repository={} docId={} xpath={} user={} fileId={} {}", doc::getRepositoryName,
826                doc::getId, () -> xpath, session::getPrincipal, () -> fileId, action::get);
827    }
828
829    protected void logResponse(String operation, int status, String... headers) {
830        logResponse(operation, status, null, headers);
831    }
832
833    protected void logResponse(String operation, int status, Object entity, String... headers) {
834        log.debug("Response: repository={} docId={} xpath={} user={} fileId={} operation={} status={}{}{}",
835                doc::getRepositoryName, doc::getId, () -> xpath, session::getPrincipal, () -> fileId, () -> operation,
836                () -> status, () -> getEntityString(entity), () -> getHeaderString(headers));
837    }
838
839    protected String getHeaderString(String... headers) {
840        if (ArrayUtils.isEmpty(headers)) {
841            return "";
842        }
843        Map<String, String> headerMap = new HashMap<>();
844        for (int i = 0; i < headers.length; i += 2) {
845            headerMap.put(headers[i], headers[i + 1]);
846        }
847        return " headers=" + headerMap;
848    }
849
850    protected String getEntityString(Object entity) {
851        return entity == null ? "" : " body=" + entity.toString();
852    }
853
854}