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}