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}