001/* 002 * (C) Copyright 2015-2018 Nuxeo (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Florent Guillaume 018 * Nelson Silva 019 */ 020package org.nuxeo.ecm.liveconnect.google.drive; 021 022import static java.lang.Boolean.TRUE; 023 024import java.io.IOException; 025import java.io.InputStream; 026import java.io.StringReader; 027import java.net.URI; 028import java.util.ArrayList; 029import java.util.Collections; 030import java.util.HashMap; 031import java.util.List; 032import java.util.Map; 033import java.util.Optional; 034 035import javax.servlet.http.HttpServletRequest; 036 037import org.apache.commons.lang3.StringUtils; 038import org.nuxeo.ecm.core.api.Blob; 039import org.nuxeo.ecm.core.api.Blobs; 040import org.nuxeo.ecm.core.api.DocumentModel; 041import org.nuxeo.ecm.core.api.NuxeoException; 042import org.nuxeo.ecm.core.api.model.impl.ListProperty; 043import org.nuxeo.ecm.core.blob.BlobManager.UsageHint; 044import org.nuxeo.ecm.core.blob.ManagedBlob; 045import org.nuxeo.ecm.core.blob.SimpleManagedBlob; 046import org.nuxeo.ecm.core.blob.apps.AppLink; 047import org.nuxeo.ecm.core.model.Document; 048import org.nuxeo.ecm.liveconnect.core.AbstractLiveConnectBlobProvider; 049import org.nuxeo.ecm.liveconnect.core.CredentialFactory; 050import org.nuxeo.ecm.liveconnect.core.LiveConnectFile; 051import org.nuxeo.ecm.liveconnect.core.LiveConnectFileInfo; 052import org.nuxeo.ecm.liveconnect.core.OAuth2CredentialFactory; 053import org.nuxeo.ecm.liveconnect.google.drive.credential.ServiceAccountCredentialFactory; 054import org.nuxeo.ecm.platform.usermanager.UserManager; 055import org.nuxeo.runtime.api.Framework; 056import org.nuxeo.runtime.transaction.TransactionHelper; 057 058import com.google.api.client.auth.oauth2.Credential; 059import com.google.api.client.http.GenericUrl; 060import com.google.api.client.http.HttpResponse; 061import com.google.api.client.http.HttpResponseException; 062import com.google.api.client.http.HttpStatusCodes; 063import com.google.api.client.http.HttpTransport; 064import com.google.api.client.json.JsonFactory; 065import com.google.api.client.json.JsonObjectParser; 066import com.google.api.client.json.jackson2.JacksonFactory; 067import com.google.api.client.util.ObjectParser; 068import com.google.api.services.drive.Drive; 069import com.google.api.services.drive.DriveRequest; 070import com.google.api.services.drive.model.App; 071import com.google.api.services.drive.model.File; 072import com.google.api.services.drive.model.Revision; 073import com.google.api.services.drive.model.RevisionList; 074 075/** 076 * Provider for blobs getting information from Google Drive. 077 * 078 * @since 7.3 079 */ 080public class GoogleDriveBlobProvider extends AbstractLiveConnectBlobProvider<GoogleOAuth2ServiceProvider> { 081 082 public static final int PREFERRED_ICON_SIZE = 16; 083 084 // Service account details 085 public static final String SERVICE_ACCOUNT_ID_PROP = "serviceAccountId"; 086 087 public static final String SERVICE_ACCOUNT_P12_PATH_PROP = "serviceAccountP12Path"; 088 089 // ClientId for the file picker auth 090 public static final String CLIENT_ID_PROP = "clientId"; 091 092 public static final String DEFAULT_EXPORT_MIMETYPE = "application/pdf"; 093 094 // Blob conversion constants 095 protected static final String BLOB_CONVERSIONS_FACET = "BlobConversions"; 096 097 protected static final String BLOB_CONVERSIONS_PROPERTY = "blobconversions:conversions"; 098 099 protected static final String BLOB_CONVERSION_KEY = "key"; 100 101 protected static final String BLOB_CONVERSION_BLOB = "blob"; 102 103 protected static final ObjectParser JSON_PARSER = new JsonObjectParser(JacksonFactory.getDefaultInstance()); 104 105 private static final String GOOGLEDRIVE_DOCUMENT_TO_BE_UPDATED_PP = "googledrive_document_to_be_updated"; 106 107 private static final String APPLICATION_NAME = "Nuxeo/0"; 108 109 private static final String FILE_CACHE_NAME = "googleDrive"; 110 111 private String serviceAccountId; 112 113 private java.io.File serviceAccountP12File; 114 115 private String clientId; 116 117 @Override 118 public void initialize(String blobProviderId, Map<String, String> properties) throws IOException { 119 super.initialize(blobProviderId, properties); 120 // Validate service account configuration 121 serviceAccountId = properties.get(SERVICE_ACCOUNT_ID_PROP); 122 if (StringUtils.isBlank(serviceAccountId)) { 123 return; 124 } 125 String p12 = properties.get(SERVICE_ACCOUNT_P12_PATH_PROP); 126 if (StringUtils.isBlank(p12)) { 127 throw new NuxeoException("Missing value for property: " + SERVICE_ACCOUNT_P12_PATH_PROP); 128 } 129 serviceAccountP12File = new java.io.File(p12); 130 if (!serviceAccountP12File.exists()) { 131 throw new NuxeoException("No such file: " + p12 + " for property: " + SERVICE_ACCOUNT_P12_PATH_PROP); 132 } 133 134 clientId = properties.get(CLIENT_ID_PROP); 135 if (StringUtils.isBlank(clientId)) { 136 throw new NuxeoException("Missing value for property: " + CLIENT_ID_PROP); 137 } 138 } 139 140 @Override 141 protected String getCacheName() { 142 return FILE_CACHE_NAME; 143 } 144 145 @Override 146 public String getPageProviderNameForUpdate() { 147 return GOOGLEDRIVE_DOCUMENT_TO_BE_UPDATED_PP; 148 } 149 150 @Override 151 public URI getURI(ManagedBlob blob, UsageHint usage, HttpServletRequest servletRequest) throws IOException { 152 String url = null; 153 switch (usage) { 154 case STREAM: 155 url = getStreamUrl(blob); 156 break; 157 case DOWNLOAD: 158 url = getDownloadUrl(blob); 159 break; 160 case VIEW: 161 case EDIT: 162 url = getAlternateUrl(blob); 163 break; 164 case EMBED: 165 url = getEmbedUrl(blob); 166 break; 167 } 168 return url == null ? null : asURI(url); 169 } 170 171 // TODO remove unused hint from signature 172 @Override 173 public Map<String, URI> getAvailableConversions(ManagedBlob blob, UsageHint hint) throws IOException { 174 Map<String, String> exportLinks = getExportLinks(blob); 175 if (exportLinks == null) { 176 return Collections.emptyMap(); 177 } 178 Map<String, URI> conversions = new HashMap<>(); 179 for (String mimeType : exportLinks.keySet()) { 180 conversions.put(mimeType, asURI(exportLinks.get(mimeType))); 181 } 182 return conversions; 183 } 184 185 @Override 186 public InputStream getThumbnail(ManagedBlob blob) throws IOException { 187 String url = getThumbnailUrl(blob); 188 return getStream(blob, asURI(url)); 189 } 190 191 /** 192 * Gets the URL from which we can stream the content of the file. 193 * <p> 194 * Will return {@code null} if this is a native Google document. 195 */ 196 protected String getStreamUrl(ManagedBlob blob) throws IOException { 197 LiveConnectFileInfo fileInfo = toFileInfo(blob); 198 if (fileInfo.getRevisionId().isPresent()) { 199 Revision revision = getRevision(fileInfo); 200 return revision != null ? revision.getDownloadUrl() : null; 201 } else { 202 File file = getDriveFile(fileInfo); 203 return file.getDownloadUrl(); 204 } 205 } 206 207 /** 208 * Gets the URL to which we can redirect to let the user download the file. 209 */ 210 protected String getDownloadUrl(ManagedBlob blob) throws IOException { 211 LiveConnectFileInfo fileInfo = toFileInfo(blob); 212 String url = null; 213 if (fileInfo.getRevisionId().isPresent()) { 214 Revision revision = getRevision(fileInfo); 215 if (revision != null) { 216 url = revision.getDownloadUrl(); 217 if (StringUtils.isBlank(url)) { 218 url = revision.getExportLinks().get(DEFAULT_EXPORT_MIMETYPE); 219 } 220 // hack, without this we get a 401 on the returned URL... 221 if (url.endsWith("&gd=true")) { 222 url = url.substring(0, url.length() - "&gd=true".length()); 223 } 224 } 225 } else { 226 File file = getDriveFile(fileInfo); 227 url = file.getWebContentLink(); 228 if (url == null) { 229 // native Google document 230 url = file.getAlternateLink(); 231 } 232 } 233 return url; 234 } 235 236 // TODO remove 237 protected String getAlternateUrl(ManagedBlob blob) throws IOException { 238 LiveConnectFileInfo fileInfo = toFileInfo(blob); 239 // ignore revisionId 240 File file = getDriveFile(fileInfo); 241 return file.getAlternateLink(); 242 } 243 244 /** 245 * Gets the URL to which we can redirect to let the user see a preview of the file. 246 */ 247 protected String getEmbedUrl(ManagedBlob blob) throws IOException { 248 LiveConnectFileInfo fileInfo = toFileInfo(blob); 249 // ignore revisionId 250 File file = getDriveFile(fileInfo); 251 String url = file.getEmbedLink(); 252 if (url == null) { 253 // uploaded file, switch to preview 254 url = file.getAlternateLink(); 255 url = asURI(url).resolve("./preview").toString(); 256 } 257 return url; 258 } 259 260 /** 261 * Gets the URL from which we can stream a thumbnail. 262 */ 263 protected String getThumbnailUrl(ManagedBlob blob) throws IOException { 264 LiveConnectFileInfo fileInfo = toFileInfo(blob); 265 // ignore revisionId 266 File file = getDriveFile(fileInfo); 267 return file.getThumbnailLink(); 268 } 269 270 /** 271 * Gets the export link. 272 */ 273 protected Map<String, String> getExportLinks(ManagedBlob blob) throws IOException { 274 LiveConnectFileInfo fileInfo = toFileInfo(blob); 275 if (fileInfo.getRevisionId().isPresent()) { 276 Revision revision = getRevision(fileInfo); 277 return revision != null && TRUE.equals(revision.getPinned()) ? revision.getExportLinks() 278 : Collections.emptyMap(); 279 } else { 280 File file = getDriveFile(fileInfo); 281 return file.getExportLinks(); 282 } 283 } 284 285 @Override 286 public InputStream getStream(ManagedBlob blob) throws IOException { 287 URI uri = getURI(blob, UsageHint.STREAM, null); 288 return uri == null ? null : getStream(blob, uri); 289 } 290 291 @Override 292 public InputStream getConvertedStream(ManagedBlob blob, String mimeType, DocumentModel doc) throws IOException { 293 Blob conversion = retrieveBlobConversion(blob, mimeType, doc); 294 if (conversion != null) { 295 return conversion.getStream(); 296 } 297 298 Map<String, URI> conversions = getAvailableConversions(blob, UsageHint.STREAM); 299 URI uri = conversions.get(mimeType); 300 if (uri == null) { 301 return null; 302 } 303 return getStream(blob, uri); 304 } 305 306 protected InputStream getStream(ManagedBlob blob, URI uri) throws IOException { 307 LiveConnectFileInfo fileInfo = toFileInfo(blob); 308 return doGet(fileInfo, uri); 309 } 310 311 @Override 312 public List<AppLink> getAppLinks(String username, ManagedBlob blob) throws IOException { 313 List<AppLink> appLinks = new ArrayList<>(); 314 315 LiveConnectFileInfo fileInfo = toFileInfo(blob); 316 317 // application links do not work with revisions 318 if (fileInfo.getRevisionId().isPresent()) { 319 return appLinks; 320 } 321 322 // retrieve the service's user (email in this case) for this username 323 String user = getServiceUser(username); 324 325 // fetch a partial file response 326 File file = getPartialFile(user, fileInfo.getFileId(), "openWithLinks", "defaultOpenWithLink"); 327 if (file.isEmpty()) { 328 return appLinks; 329 } 330 331 // build the list of AppLinks 332 String defaultLink = file.getDefaultOpenWithLink(); 333 for (Map.Entry<String, String> entry : file.getOpenWithLinks().entrySet()) { 334 // build the AppLink 335 App app = getApp(user, entry.getKey()); 336 AppLink appLink = new AppLink(); 337 appLink.setAppName(app.getName()); 338 appLink.setLink(entry.getValue()); 339 340 // pick an application icon 341 List<App.Icons> icons = app.getIcons(); 342 if (icons != null) { 343 for (App.Icons icon : icons) { 344 if ("application".equals(icon.getCategory())) { 345 appLink.setIcon(icon.getIconUrl()); 346 // break if we've got one with our preferred size 347 if (icon.getSize() == PREFERRED_ICON_SIZE) { 348 break; 349 } 350 } 351 } 352 } 353 354 // add the default link first 355 if (defaultLink != null && defaultLink.equals(entry.getValue())) { 356 appLinks.add(0, appLink); 357 } else { 358 appLinks.add(appLink); 359 } 360 } 361 return appLinks; 362 } 363 364 protected String getServiceUser(String username) { 365 CredentialFactory credentialFactory = getCredentialFactory(); 366 if (credentialFactory instanceof OAuth2CredentialFactory) { 367 return getOAuth2Provider().getServiceUser(username); 368 } else { 369 UserManager userManager = Framework.getService(UserManager.class); 370 DocumentModel user = userManager.getUserModel(username); 371 if (user == null) { 372 return null; 373 } 374 return (String) user.getPropertyValue(userManager.getUserEmailField()); 375 } 376 } 377 378 protected App getApp(String user, String appId) throws IOException { 379 String cacheKey = "app_" + appId; 380 return executeAndCache(cacheKey, getService(user).apps().get(appId), App.class); 381 } 382 383 @Override 384 public ManagedBlob freezeVersion(ManagedBlob blob, Document doc) throws IOException { 385 LiveConnectFileInfo fileInfo = toFileInfo(blob); 386 if (fileInfo.getRevisionId().isPresent()) { 387 // already frozen 388 return null; 389 } 390 String user = fileInfo.getUser(); 391 String fileId = fileInfo.getFileId(); 392 // force update of Drive and Live Connect cache 393 putFileInCache(retrieveFile(fileInfo)); 394 // find current revision for that doc (from cache as previous line cached it) 395 File driveFile = getDriveFile(fileInfo); 396 String revisionId = driveFile.getHeadRevisionId(); 397 if (revisionId != null) { 398 // uploaded file, there is a head revision 399 fileInfo = new LiveConnectFileInfo(user, fileId, revisionId); 400 Revision revision = getRevision(fileInfo); 401 if (!TRUE.equals(revision.getPinned())) { 402 // pin the revision 403 Revision pinRevision = new Revision(); 404 pinRevision.setPinned(TRUE); 405 getService(user).revisions().patch(fileId, revisionId, pinRevision).executeUnparsed().ignore(); 406 } 407 } else { 408 // native Google document 409 // find last revision 410 List<Revision> list = getRevisionList(fileInfo).getItems(); 411 if (list.isEmpty()) { 412 return null; 413 } 414 Revision revision = list.get(list.size() - 1); 415 416 // native Google document revision cannot be pinned so we store a conversion of the blob 417 URI uri = asURI(revision.getExportLinks().get(DEFAULT_EXPORT_MIMETYPE)); 418 419 InputStream is = doGet(fileInfo, uri); 420 Blob conversion = Blobs.createBlob(is); 421 conversion.setFilename(blob.getFilename()); 422 conversion.setMimeType(DEFAULT_EXPORT_MIMETYPE); 423 424 fileInfo = new LiveConnectFileInfo(user, fileId, revision.getId()); 425 426 // store a conversion of this revision 427 storeBlobConversion(doc, buildBlobKey(fileInfo), conversion); 428 } 429 return toBlob(new GoogleDriveLiveConnectFile(fileInfo, driveFile)); 430 } 431 432 /** 433 * Store a conversion of the given blob 434 */ 435 @SuppressWarnings("unchecked") 436 protected void storeBlobConversion(Document doc, String blobKey, Blob blob) { 437 if (!doc.hasFacet(BLOB_CONVERSIONS_FACET)) { 438 doc.addFacet(BLOB_CONVERSIONS_FACET); 439 } 440 441 List<Map<String, Object>> conversions = (List<Map<String, Object>>) doc.getValue(BLOB_CONVERSIONS_PROPERTY); 442 Map<String, Object> conversion = new HashMap<>(); 443 conversion.put(BLOB_CONVERSION_KEY, blobKey); 444 conversion.put(BLOB_CONVERSION_BLOB, blob); 445 conversions.add(conversion); 446 doc.setValue(BLOB_CONVERSIONS_PROPERTY, conversions); 447 } 448 449 /** 450 * Retrieve a stored conversion of the given blob 451 */ 452 protected Blob retrieveBlobConversion(ManagedBlob blob, String mimeType, DocumentModel doc) { 453 if (doc == null || !doc.hasFacet(BLOB_CONVERSIONS_FACET)) { 454 return null; 455 } 456 457 boolean txWasActive = TransactionHelper.isTransactionActiveOrMarkedRollback(); 458 try { 459 if (!txWasActive) { 460 TransactionHelper.startTransaction(); 461 } 462 ListProperty conversions = (ListProperty) doc.getProperty(BLOB_CONVERSIONS_PROPERTY); 463 for (int i = 0; i < conversions.size(); i++) { 464 if (blob.getKey().equals(conversions.get(i).getValue(BLOB_CONVERSION_KEY))) { 465 String conversionXPath = String.format("%s/%d/%s", BLOB_CONVERSIONS_PROPERTY, i, 466 BLOB_CONVERSION_BLOB); 467 Blob conversion = (Blob) doc.getPropertyValue(conversionXPath); 468 if (conversion.getMimeType().equals(mimeType)) { 469 return conversion; 470 } 471 } 472 } 473 } finally { 474 if (!txWasActive) { 475 TransactionHelper.commitOrRollbackTransaction(); 476 } 477 } 478 return null; 479 } 480 481 @Override 482 protected boolean hasChanged(SimpleManagedBlob blob, LiveConnectFile file) { 483 return !blob.getFilename().equals(file.getFilename().replace('/', '-')) && super.hasChanged(blob, file); 484 } 485 486 @Override 487 protected CredentialFactory getCredentialFactory() { 488 GoogleOAuth2ServiceProvider provider = getOAuth2Provider(); 489 if (provider != null && provider.isEnabled()) { 490 // Web application configuration 491 return new OAuth2CredentialFactory(provider); 492 } else { 493 // Service account configuration 494 return new ServiceAccountCredentialFactory(serviceAccountId, serviceAccountP12File); 495 } 496 } 497 498 protected Drive getService(String user) throws IOException { 499 Credential credential = getCredential(user); 500 if (credential == null) { 501 throw new IOException("No credentials found for user " + user); 502 } 503 HttpTransport httpTransport = credential.getTransport(); 504 JsonFactory jsonFactory = credential.getJsonFactory(); 505 return new Drive.Builder(httpTransport, jsonFactory, credential) // 506 .setApplicationName(APPLICATION_NAME) // set 507 // application 508 // name 509 // to 510 // avoid 511 // a WARN 512 .build(); 513 } 514 515 @Override 516 protected LiveConnectFile retrieveFile(LiveConnectFileInfo fileInfo) throws IOException { 517 // First, invalidate the Drive file cache in order to force call to API 518 invalidateInCache(fileInfo); 519 // Second, retrieve it and cache it 520 return new GoogleDriveLiveConnectFile(fileInfo, getDriveFile(fileInfo)); 521 } 522 523 /** 524 * Retrieve a partial {@link File} resource. 525 */ 526 protected File getPartialFile(String user, String fileId, String... fields) throws IOException { 527 return getService(user).files().get(fileId).setFields(StringUtils.join(fields, ",")).execute(); 528 } 529 530 /** 531 * Retrieves a {@link File} resource and caches the unparsed response. 532 * 533 * @return a {@link File} resource 534 */ 535 // subclassed for mock 536 protected File getDriveFile(LiveConnectFileInfo fileInfo) throws IOException { 537 // ignore revisionId 538 String fileId = fileInfo.getFileId(); 539 String cacheKey = "file_" + fileId; 540 DriveRequest<File> request = getService(fileInfo.getUser()).files().get(fileId); 541 return executeAndCache(cacheKey, request, File.class); 542 } 543 544 /** 545 * Retrieves a {@link Revision} resource and caches the unparsed response. 546 * 547 * @return a {@link Revision} resource 548 */ 549 // subclassed for mock 550 protected Revision getRevision(LiveConnectFileInfo fileInfo) throws IOException { 551 Optional<String> revId = fileInfo.getRevisionId(); 552 if (!revId.isPresent()) { 553 throw new NullPointerException("null revisionId for " + fileInfo.getFileId()); 554 } 555 String fileId = fileInfo.getFileId(); 556 String revisionId = revId.get(); 557 String cacheKey = "rev_" + fileId + "_" + revisionId; 558 DriveRequest<Revision> request = getService(fileInfo.getUser()).revisions().get(fileId, revisionId); 559 try { 560 return executeAndCache(cacheKey, request, Revision.class); 561 } catch (HttpResponseException e) { 562 // return null if revision is not found 563 if (e.getStatusCode() == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { 564 return null; 565 } 566 throw e; 567 } 568 } 569 570 /** 571 * Executes a {@link DriveRequest} and caches the unparsed response. 572 */ 573 protected <T> T executeAndCache(String cacheKey, DriveRequest<T> request, Class<T> aClass) throws IOException { 574 String resource = getDriveFromCache(cacheKey); 575 576 if (resource == null) { 577 HttpResponse response = request.executeUnparsed(); 578 if (!response.isSuccessStatusCode()) { 579 return null; 580 } 581 resource = response.parseAsString(); 582 if (cacheKey != null) { 583 putDriveInCache(cacheKey, resource); 584 } 585 } 586 return JSON_PARSER.parseAndClose(new StringReader(resource), aClass); 587 } 588 589 /** 590 * Retrieves the list of {@link Revision} resources for a file. 591 * 592 * @return a list of {@link Revision} resources 593 */ 594 // subclassed for mock 595 protected RevisionList getRevisionList(LiveConnectFileInfo fileInfo) throws IOException { 596 return getService(fileInfo.getUser()).revisions().list(fileInfo.getFileId()).execute(); 597 } 598 599 /** 600 * Executes a GET request with the user's credentials. 601 */ 602 protected InputStream doGet(LiveConnectFileInfo fileInfo, URI url) throws IOException { 603 HttpResponse response = getService(fileInfo.getUser()).getRequestFactory() 604 .buildGetRequest(new GenericUrl(url)) 605 .execute(); 606 return response.getContent(); 607 } 608 609 public String getClientId() { 610 GoogleOAuth2ServiceProvider provider = getOAuth2Provider(); 611 return (provider != null && provider.isEnabled()) ? provider.getClientId() : clientId; 612 } 613 614 private String getDriveFromCache(String key) { 615 return getFromCache(key); 616 } 617 618 private void putDriveInCache(String key, String resource) { 619 putInCache(key, resource); 620 } 621 622}