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}