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