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.getLocalService(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}