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}