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 for (App.Icons icon : app.getIcons()) { 341 if ("application".equals(icon.getCategory())) { 342 appLink.setIcon(icon.getIconUrl()); 343 // break if we've got one with our preferred size 344 if (icon.getSize() == PREFERRED_ICON_SIZE) { 345 break; 346 } 347 } 348 } 349 350 // add the default link first 351 if (defaultLink != null && defaultLink.equals(entry.getValue())) { 352 appLinks.add(0, appLink); 353 } else { 354 appLinks.add(appLink); 355 } 356 } 357 return appLinks; 358 } 359 360 protected String getServiceUser(String username) { 361 CredentialFactory credentialFactory = getCredentialFactory(); 362 if (credentialFactory instanceof OAuth2CredentialFactory) { 363 return getOAuth2Provider().getServiceUser(username); 364 } else { 365 UserManager userManager = Framework.getLocalService(UserManager.class); 366 DocumentModel user = userManager.getUserModel(username); 367 if (user == null) { 368 return null; 369 } 370 return (String) user.getPropertyValue(userManager.getUserEmailField()); 371 } 372 } 373 374 protected App getApp(String user, String appId) throws IOException { 375 String cacheKey = "app_" + appId; 376 return executeAndCache(cacheKey, getService(user).apps().get(appId), App.class); 377 } 378 379 @Override 380 public ManagedBlob freezeVersion(ManagedBlob blob, Document doc) throws IOException { 381 LiveConnectFileInfo fileInfo = toFileInfo(blob); 382 if (fileInfo.getRevisionId().isPresent()) { 383 // already frozen 384 return null; 385 } 386 String user = fileInfo.getUser(); 387 String fileId = fileInfo.getFileId(); 388 // force update of Drive and Live Connect cache 389 putFileInCache(retrieveFile(fileInfo)); 390 // find current revision for that doc (from cache as previous line cached it) 391 File driveFile = getDriveFile(fileInfo); 392 String revisionId = driveFile.getHeadRevisionId(); 393 if (revisionId != null) { 394 // uploaded file, there is a head revision 395 fileInfo = new LiveConnectFileInfo(user, fileId, revisionId); 396 Revision revision = getRevision(fileInfo); 397 if (!TRUE.equals(revision.getPinned())) { 398 // pin the revision 399 Revision pinRevision = new Revision(); 400 pinRevision.setPinned(TRUE); 401 getService(user).revisions().patch(fileId, revisionId, pinRevision).executeUnparsed().ignore(); 402 } 403 } else { 404 // native Google document 405 // find last revision 406 List<Revision> list = getRevisionList(fileInfo).getItems(); 407 if (list.isEmpty()) { 408 return null; 409 } 410 Revision revision = list.get(list.size() - 1); 411 412 // native Google document revision cannot be pinned so we store a conversion of the blob 413 URI uri = asURI(revision.getExportLinks().get(DEFAULT_EXPORT_MIMETYPE)); 414 415 InputStream is = doGet(fileInfo, uri); 416 Blob conversion = Blobs.createBlob(is); 417 conversion.setFilename(blob.getFilename()); 418 conversion.setMimeType(DEFAULT_EXPORT_MIMETYPE); 419 420 fileInfo = new LiveConnectFileInfo(user, fileId, revision.getId()); 421 422 // store a conversion of this revision 423 storeBlobConversion(doc, buildBlobKey(fileInfo), conversion); 424 } 425 return toBlob(new GoogleDriveLiveConnectFile(fileInfo, driveFile)); 426 } 427 428 /** 429 * Store a conversion of the given blob 430 */ 431 @SuppressWarnings("unchecked") 432 protected void storeBlobConversion(Document doc, String blobKey, Blob blob) { 433 if (!doc.hasFacet(BLOB_CONVERSIONS_FACET)) { 434 doc.addFacet(BLOB_CONVERSIONS_FACET); 435 } 436 437 List<Map<String, Object>> conversions = (List<Map<String, Object>>) doc.getValue(BLOB_CONVERSIONS_PROPERTY); 438 Map<String, Object> conversion = new HashMap<>(); 439 conversion.put(BLOB_CONVERSION_KEY, blobKey); 440 conversion.put(BLOB_CONVERSION_BLOB, blob); 441 conversions.add(conversion); 442 doc.setValue(BLOB_CONVERSIONS_PROPERTY, conversions); 443 } 444 445 /** 446 * Retrieve a stored conversion of the given blob 447 */ 448 protected Blob retrieveBlobConversion(ManagedBlob blob, String mimeType, DocumentModel doc) { 449 if (doc == null || !doc.hasFacet(BLOB_CONVERSIONS_FACET)) { 450 return null; 451 } 452 453 boolean txWasActive = TransactionHelper.isTransactionActiveOrMarkedRollback(); 454 try { 455 if (!txWasActive) { 456 TransactionHelper.startTransaction(); 457 } 458 ListProperty conversions = (ListProperty) doc.getProperty(BLOB_CONVERSIONS_PROPERTY); 459 for (int i = 0; i < conversions.size(); i++) { 460 if (blob.getKey().equals(conversions.get(i).getValue(BLOB_CONVERSION_KEY))) { 461 String conversionXPath = String.format("%s/%d/%s", BLOB_CONVERSIONS_PROPERTY, i, BLOB_CONVERSION_BLOB); 462 Blob conversion = (Blob) doc.getPropertyValue(conversionXPath); 463 if (conversion.getMimeType().equals(mimeType)) { 464 return conversion; 465 } 466 } 467 } 468 } finally { 469 if (!txWasActive) { 470 TransactionHelper.commitOrRollbackTransaction(); 471 } 472 } 473 return null; 474 } 475 476 @Override 477 protected boolean hasChanged(SimpleManagedBlob blob, LiveConnectFile file) { 478 return !blob.getFilename().equals(file.getFilename().replace('/', '-')) && super.hasChanged(blob, file); 479 } 480 481 @Override 482 protected CredentialFactory getCredentialFactory() { 483 GoogleOAuth2ServiceProvider provider = getOAuth2Provider(); 484 if (provider != null && provider.isEnabled()) { 485 // Web application configuration 486 return new OAuth2CredentialFactory(provider); 487 } else { 488 // Service account configuration 489 return new ServiceAccountCredentialFactory(serviceAccountId, serviceAccountP12File); 490 } 491 } 492 493 protected Drive getService(String user) throws IOException { 494 Credential credential = getCredential(user); 495 if (credential == null) { 496 throw new IOException("No credentials found for user " + user); 497 } 498 HttpTransport httpTransport = credential.getTransport(); 499 JsonFactory jsonFactory = credential.getJsonFactory(); 500 return new Drive.Builder(httpTransport, jsonFactory, credential) // 501 .setApplicationName(APPLICATION_NAME) // set application name to avoid a WARN 502 .build(); 503 } 504 505 @Override 506 protected LiveConnectFile retrieveFile(LiveConnectFileInfo fileInfo) throws IOException { 507 // First, invalidate the Drive file cache in order to force call to API 508 invalidateInCache("file_" + fileInfo.getFileId()); 509 // Second, retrieve it and cache it 510 return new GoogleDriveLiveConnectFile(fileInfo, getDriveFile(fileInfo)); 511 } 512 513 /** 514 * Retrieve a partial {@link File} resource. 515 */ 516 protected File getPartialFile(String user, String fileId, String... fields) throws IOException { 517 return getService(user).files().get(fileId).setFields(StringUtils.join(fields, ",")).execute(); 518 } 519 520 /** 521 * Retrieves a {@link File} resource and caches the unparsed response. 522 * 523 * @return a {@link File} resource 524 */ 525 // subclassed for mock 526 protected File getDriveFile(LiveConnectFileInfo fileInfo) throws IOException { 527 // ignore revisionId 528 String fileId = fileInfo.getFileId(); 529 String cacheKey = "file_" + fileId; 530 DriveRequest<File> request = getService(fileInfo.getUser()).files().get(fileId); 531 return executeAndCache(cacheKey, request, File.class); 532 } 533 534 /** 535 * Retrieves a {@link Revision} resource and caches the unparsed response. 536 * 537 * @return a {@link Revision} resource 538 */ 539 // subclassed for mock 540 protected Revision getRevision(LiveConnectFileInfo fileInfo) throws IOException { 541 if (!fileInfo.getRevisionId().isPresent()) { 542 throw new NullPointerException("null revisionId for " + fileInfo.getFileId()); 543 } 544 String fileId = fileInfo.getFileId(); 545 String revisionId = fileInfo.getRevisionId().get(); 546 String cacheKey = "rev_" + fileId + "_" + revisionId; 547 DriveRequest<Revision> request = getService(fileInfo.getUser()).revisions().get(fileId, revisionId); 548 try { 549 return executeAndCache(cacheKey, request, Revision.class); 550 } catch (HttpResponseException e) { 551 // return null if revision is not found 552 if (e.getStatusCode() == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { 553 return null; 554 } 555 throw e; 556 } 557 } 558 559 /** 560 * Executes a {@link DriveRequest} and caches the unparsed response. 561 */ 562 protected <T> T executeAndCache(String cacheKey, DriveRequest<T> request, Class<T> aClass) throws IOException { 563 String resource = getDriveFromCache(cacheKey); 564 565 if (resource == null) { 566 HttpResponse response = request.executeUnparsed(); 567 if (!response.isSuccessStatusCode()) { 568 return null; 569 } 570 resource = response.parseAsString(); 571 if (cacheKey != null) { 572 putDriveInCache(cacheKey, resource); 573 } 574 } 575 return JSON_PARSER.parseAndClose(new StringReader(resource), aClass); 576 } 577 578 /** 579 * Retrieves the list of {@link Revision} resources for a file. 580 * 581 * @return a list of {@link Revision} resources 582 */ 583 // subclassed for mock 584 protected RevisionList getRevisionList(LiveConnectFileInfo fileInfo) throws IOException { 585 return getService(fileInfo.getUser()).revisions().list(fileInfo.getFileId()).execute(); 586 } 587 588 /** 589 * Executes a GET request with the user's credentials. 590 */ 591 protected InputStream doGet(LiveConnectFileInfo fileInfo, URI url) throws IOException { 592 HttpResponse response = getService(fileInfo.getUser()).getRequestFactory().buildGetRequest(new GenericUrl(url)).execute(); 593 return response.getContent(); 594 } 595 596 public String getClientId() { 597 GoogleOAuth2ServiceProvider provider = getOAuth2Provider(); 598 return (provider != null && provider.isEnabled()) ? provider.getClientId() : clientId; 599 } 600 601 private String getDriveFromCache(String key) { 602 return getFromCache(key); 603 } 604 605 private void putDriveInCache(String key, String resource) { 606 putInCache(key, resource); 607 } 608 609}