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