001/*
002 * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and contributors.
003 *
004 * All rights reserved. This program and the accompanying materials
005 * are made available under the terms of the GNU Lesser General Public License
006 * (LGPL) version 2.1 which accompanies this distribution, and is available at
007 * http://www.gnu.org/licenses/lgpl-2.1.html
008 *
009 * This library is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
012 * Lesser General Public License for more details.
013 *
014 * Contributors:
015 *     Florent Guillaume
016 *     Nelson Silva
017 */
018package org.nuxeo.ecm.liveconnect.google.drive;
019
020import static java.lang.Boolean.TRUE;
021
022import java.io.IOException;
023import java.io.InputStream;
024import java.io.StringReader;
025import java.net.URI;
026import java.net.URISyntaxException;
027import java.util.ArrayList;
028import java.util.Collections;
029import java.util.HashMap;
030import java.util.List;
031import java.util.Map;
032
033import javax.servlet.http.HttpServletRequest;
034
035import com.google.api.client.http.HttpResponseException;
036import com.google.api.client.http.HttpStatusCodes;
037import org.apache.commons.lang.StringUtils;
038import org.apache.commons.logging.Log;
039import org.apache.commons.logging.LogFactory;
040import org.nuxeo.ecm.core.api.Blob;
041import org.nuxeo.ecm.core.api.Blobs;
042import org.nuxeo.ecm.core.api.DocumentModel;
043import org.nuxeo.ecm.core.api.NuxeoException;
044import org.nuxeo.ecm.core.api.model.impl.ListProperty;
045import org.nuxeo.ecm.core.blob.AbstractBlobProvider;
046import org.nuxeo.ecm.core.blob.BlobManager.BlobInfo;
047import org.nuxeo.ecm.core.blob.BlobManager.UsageHint;
048import org.nuxeo.ecm.core.blob.ManagedBlob;
049import org.nuxeo.ecm.core.blob.SimpleManagedBlob;
050import org.nuxeo.ecm.core.blob.apps.AppLink;
051import org.nuxeo.ecm.core.cache.Cache;
052import org.nuxeo.ecm.core.cache.CacheService;
053import org.nuxeo.ecm.core.model.Document;
054import org.nuxeo.ecm.liveconnect.google.drive.credential.CredentialFactory;
055import org.nuxeo.ecm.liveconnect.google.drive.credential.OAuth2CredentialFactory;
056import org.nuxeo.ecm.liveconnect.google.drive.credential.ServiceAccountCredentialFactory;
057import org.nuxeo.ecm.liveconnect.update.BatchUpdateBlobProvider;
058import org.nuxeo.ecm.platform.oauth2.providers.OAuth2ServiceProvider;
059import org.nuxeo.ecm.platform.oauth2.providers.OAuth2ServiceProviderRegistry;
060import org.nuxeo.ecm.platform.usermanager.UserManager;
061import org.nuxeo.runtime.api.Framework;
062
063import com.google.api.client.auth.oauth2.Credential;
064import com.google.api.client.http.GenericUrl;
065import com.google.api.client.http.HttpResponse;
066import com.google.api.client.http.HttpTransport;
067import com.google.api.client.json.JsonFactory;
068import com.google.api.client.json.JsonObjectParser;
069import com.google.api.client.json.jackson2.JacksonFactory;
070import com.google.api.client.util.ObjectParser;
071import com.google.api.services.drive.Drive;
072import com.google.api.services.drive.DriveRequest;
073import com.google.api.services.drive.model.App;
074import com.google.api.services.drive.model.File;
075import com.google.api.services.drive.model.Revision;
076import com.google.api.services.drive.model.RevisionList;
077import org.nuxeo.runtime.transaction.TransactionHelper;
078
079/**
080 * Provider for blobs getting information from Google Drive.
081 *
082 * @since 7.3
083 */
084public class GoogleDriveBlobProvider extends AbstractBlobProvider implements BatchUpdateBlobProvider {
085
086    private static final String GOOGLEDRIVE_DOCUMENT_TO_BE_UPDATED_PP = "googledrive_document_to_be_updated";
087
088    public static final int PREFERRED_ICON_SIZE = 16;
089
090    private static final Log log = LogFactory.getLog(GoogleDriveBlobProvider.class);
091
092    /**
093     * Information about a file stored in Google Drive.
094     */
095    public static class FileInfo {
096        public final String user;
097
098        public final String fileId;
099
100        public final String revisionId;
101
102        public FileInfo(String user, String fileId, String revisionId) {
103            this.user = user;
104            this.fileId = fileId;
105            this.revisionId = revisionId;
106        }
107    }
108
109    private static final String APPLICATION_NAME = "Nuxeo/0";
110
111    private static final String FILE_CACHE_NAME = "googleDrive";
112
113    // Service account details
114    public static final String SERVICE_ACCOUNT_ID_PROP = "serviceAccountId";
115
116    public static final String SERVICE_ACCOUNT_P12_PATH_PROP = "serviceAccountP12Path";
117
118    // ClientId for the file picker auth
119    public static final String CLIENT_ID_PROP = "clientId";
120
121    public static final String DEFAULT_EXPORT_MIMETYPE = "application/pdf";
122
123    // Blob conversion constants
124    protected static final String BLOB_CONVERSIONS_FACET = "BlobConversions";
125
126    protected static final String BLOB_CONVERSIONS_PROPERTY = "blobconversions:conversions";
127
128    protected static final String BLOB_CONVERSION_KEY = "key";
129
130    protected static final String BLOB_CONVERSION_BLOB = "blob";
131
132    protected static final ObjectParser JSON_PARSER = new JsonObjectParser(JacksonFactory.getDefaultInstance());
133
134    private String serviceAccountId;
135
136    private java.io.File serviceAccountP12File;
137
138    private String clientId;
139
140    /** resource cache */
141    private Cache cache;
142
143    @Override
144    public void initialize(String blobProviderId, Map<String, String> properties) throws IOException {
145        super.initialize(blobProviderId, properties);
146        // Validate service account configuration
147        serviceAccountId = properties.get(SERVICE_ACCOUNT_ID_PROP);
148        if (StringUtils.isBlank(serviceAccountId)) {
149            return;
150        }
151        String p12 = properties.get(SERVICE_ACCOUNT_P12_PATH_PROP);
152        if (StringUtils.isBlank(p12)) {
153            throw new NuxeoException("Missing value for property: " + SERVICE_ACCOUNT_P12_PATH_PROP);
154        }
155        serviceAccountP12File = new java.io.File(p12);
156        if (!serviceAccountP12File.exists()) {
157            throw new NuxeoException("No such file: " + p12 + " for property: " + SERVICE_ACCOUNT_P12_PATH_PROP);
158        }
159
160        clientId = properties.get(CLIENT_ID_PROP);
161        if (StringUtils.isBlank(clientId)) {
162            throw new NuxeoException("Missing value for property: " + CLIENT_ID_PROP);
163        }
164    }
165
166    @Override
167    public void close() {
168    }
169
170    @Override
171    public Blob readBlob(BlobInfo blobInfo) {
172        return new SimpleManagedBlob(blobInfo);
173    }
174
175    @Override
176    public boolean supportsUserUpdate() {
177        return supportsUserUpdateDefaultTrue();
178    }
179
180    @Override
181    public String writeBlob(Blob blob, Document doc) {
182        throw new UnsupportedOperationException("Writing a blob to Google Drive is not supported");
183    }
184
185    @Override
186    public URI getURI(ManagedBlob blob, UsageHint usage, HttpServletRequest servletRequest) throws IOException {
187        String url = null;
188        switch (usage) {
189        case STREAM:
190            url = getStreamUrl(blob);
191            break;
192        case DOWNLOAD:
193            url = getDownloadUrl(blob);
194            break;
195        case VIEW:
196        case EDIT:
197            url = getAlternateUrl(blob);
198            break;
199        case EMBED:
200            url = getEmbedUrl(blob);
201            break;
202        }
203        return url == null ? null : asURI(url);
204    }
205
206    // TODO remove unused hint from signature
207    @Override
208    public Map<String, URI> getAvailableConversions(ManagedBlob blob, UsageHint hint) throws IOException {
209        Map<String, String> exportLinks = getExportLinks(blob);
210        if (exportLinks == null) {
211            return Collections.emptyMap();
212        }
213        Map<String, URI> conversions = new HashMap<>();
214        for (String mimeType : exportLinks.keySet()) {
215            conversions.put(mimeType, asURI(exportLinks.get(mimeType)));
216        }
217        return conversions;
218    }
219
220    @Override
221    public InputStream getThumbnail(ManagedBlob blob) throws IOException {
222        String url = getThumbnailUrl(blob);
223        return getStream(blob, asURI(url));
224    }
225
226    /**
227     * Gets the URL from which we can stream the content of the file.
228     * <p>
229     * Will return {@code null} if this is a native Google document.
230     */
231    protected String getStreamUrl(ManagedBlob blob) throws IOException {
232        FileInfo fileInfo = getFileInfo(blob);
233        if (fileInfo.revisionId == null) {
234            File file = getFile(fileInfo);
235            return file.getDownloadUrl();
236        } else {
237            Revision revision = getRevision(fileInfo);
238            return revision != null ? revision.getDownloadUrl() : null;
239        }
240    }
241
242    /**
243     * Gets the URL to which we can redirect to let the user download the file.
244     */
245    protected String getDownloadUrl(ManagedBlob blob) throws IOException {
246        FileInfo fileInfo = getFileInfo(blob);
247        String url = null;
248        if (fileInfo.revisionId == null) {
249            File file = getFile(fileInfo);
250            url = file.getWebContentLink();
251            if (url == null) {
252                // native Google document
253                url = file.getAlternateLink();
254            }
255        } else {
256            Revision revision = getRevision(fileInfo);
257            if (revision != null) {
258                url = revision.getDownloadUrl();
259                if (StringUtils.isBlank(url)) {
260                    url = revision.getExportLinks().get(DEFAULT_EXPORT_MIMETYPE);
261                }
262                // hack, without this we get a 401 on the returned URL...
263                if (url.endsWith("&gd=true")) {
264                    url = url.substring(0, url.length() - "&gd=true".length());
265                }
266            }
267        }
268        return url;
269    }
270
271    // TODO remove
272    protected String getAlternateUrl(ManagedBlob blob) throws IOException {
273        FileInfo fileInfo = getFileInfo(blob);
274        // ignore revisionId
275        File file = getFile(fileInfo);
276        return file.getAlternateLink();
277    }
278
279    /**
280     * Gets the URL to which we can redirect to let the user see a preview of the file.
281     */
282    protected String getEmbedUrl(ManagedBlob blob) throws IOException {
283        FileInfo fileInfo = getFileInfo(blob);
284        // ignore revisionId
285        File file = getFile(fileInfo);
286        String url = file.getEmbedLink();
287        if (url == null) {
288            // uploaded file, switch to preview
289            url = file.getAlternateLink();
290            url = asURI(url).resolve("./preview").toString();
291        }
292        return url;
293    }
294
295    /**
296     * Gets the URL from which we can stream a thumbnail.
297     */
298    protected String getThumbnailUrl(ManagedBlob blob) throws IOException {
299        FileInfo fileInfo = getFileInfo(blob);
300        // ignore revisionId
301        File file = getFile(fileInfo);
302        return file.getThumbnailLink();
303    }
304
305    /**
306     * Gets the export link.
307     */
308    protected Map<String, String> getExportLinks(ManagedBlob blob) throws IOException {
309        FileInfo fileInfo = getFileInfo(blob);
310        if (fileInfo.revisionId == null) {
311            File file = getFile(fileInfo);
312            return file.getExportLinks();
313        } else {
314            Revision revision = getRevision(fileInfo);
315            return revision != null && TRUE.equals(revision.getPinned()) ?
316                revision.getExportLinks() : Collections.emptyMap();
317        }
318    }
319
320    @Override
321    public InputStream getStream(ManagedBlob blob) throws IOException {
322        URI uri = getURI(blob, UsageHint.STREAM, null);
323        return uri == null ? null : getStream(blob, uri);
324    }
325
326    @Override
327    public InputStream getConvertedStream(ManagedBlob blob, String mimeType, DocumentModel doc) throws IOException {
328        Blob conversion = retrieveBlobConversion(blob, mimeType, doc);
329        if (conversion != null) {
330            return conversion.getStream();
331        }
332
333        Map<String, URI> conversions = getAvailableConversions(blob, UsageHint.STREAM);
334        URI uri = conversions.get(mimeType);
335        if (uri == null) {
336            return null;
337        }
338        return getStream(blob, uri);
339    }
340
341    protected InputStream getStream(ManagedBlob blob, URI uri) throws IOException {
342        FileInfo fileInfo = getFileInfo(blob);
343        return doGet(fileInfo.user, uri);
344    }
345
346    @Override
347    public List<AppLink> getAppLinks(String username, ManagedBlob blob) throws IOException {
348        List<AppLink> appLinks = new ArrayList<>();
349
350        FileInfo fileInfo = getFileInfo(blob);
351
352        // application links do not work with revisions
353        if (fileInfo.revisionId != null) {
354            return appLinks;
355        }
356
357        // retrieve the service's user (email in this case) for this username
358        String user = getServiceUser(username);
359
360        // fetch a partial file response
361        File file = getPartialFile(user, fileInfo.fileId, "openWithLinks", "defaultOpenWithLink");
362        if (file.isEmpty()) {
363            return appLinks;
364        }
365
366        // build the list of AppLinks
367        String defaultLink = file.getDefaultOpenWithLink();
368        for (Map.Entry<String, String> entry : file.getOpenWithLinks().entrySet()) {
369            // build the AppLink
370            App app = getApp(user, entry.getKey());
371            AppLink appLink = new AppLink();
372            appLink.setAppName(app.getName());
373            appLink.setLink(entry.getValue());
374
375            // pick an application icon
376            for (com.google.api.services.drive.model.App.Icons icon : app.getIcons()) {
377                if ("application".equals(icon.getCategory())) {
378                    appLink.setIcon(icon.getIconUrl());
379                    // break if we've got one with our preferred size
380                    if (icon.getSize() == PREFERRED_ICON_SIZE) {
381                        break;
382                    }
383                }
384            }
385
386            // add the default link first
387            if (defaultLink != null && defaultLink.equals(entry.getValue())) {
388                appLinks.add(0, appLink);
389            } else {
390                appLinks.add(appLink);
391            }
392        }
393        return appLinks;
394    }
395
396    protected String getServiceUser(String username) {
397        CredentialFactory credentialFactory = getCredentialFactory();
398        if (credentialFactory instanceof OAuth2CredentialFactory) {
399            OAuth2ServiceProvider provider = ((OAuth2CredentialFactory) credentialFactory).getProvider();
400            return ((GoogleOAuth2ServiceProvider) provider).getServiceUser(username);
401        } else {
402            UserManager userManager = Framework.getLocalService(UserManager.class);
403            DocumentModel user = userManager.getUserModel(username);
404            if (user == null) {
405                return null;
406            }
407            return (String) user.getPropertyValue(userManager.getUserEmailField());
408        }
409    }
410
411    protected App getApp(String user, String appId) throws IOException {
412        String cacheKey = "app_" + appId;
413        return executeAndCache(cacheKey, getService(user).apps().get(appId), App.class);
414    }
415
416    @Override
417    public ManagedBlob freezeVersion(ManagedBlob blob, Document doc) throws IOException {
418        FileInfo fileInfo = getFileInfo(blob);
419        if (fileInfo.revisionId != null) {
420            // already frozen
421            return null;
422        }
423        String user = fileInfo.user;
424        String fileId = fileInfo.fileId;
425        // find current revision for that doc
426        File file = getFile(fileInfo);
427        String revisionId = file.getHeadRevisionId();
428        if (revisionId != null) {
429            // uploaded file, there is a head revision
430            fileInfo = new FileInfo(user, fileId, revisionId);
431            Revision revision = getRevision(fileInfo);
432            if (!TRUE.equals(revision.getPinned())) {
433                // pin the revision
434                Revision pinRevision = new Revision();
435                pinRevision.setPinned(TRUE);
436                getService(user).revisions().patch(fileId, revisionId, pinRevision).executeUnparsed().ignore();
437            }
438        } else {
439            // native Google document
440            // find last revision
441            List<Revision> list = getRevisionList(fileInfo).getItems();
442            if (list.isEmpty()) {
443                return null;
444            }
445            Revision revision = list.get(list.size() - 1);
446
447            // native Google document revision cannot be pinned so we store a conversion of the blob
448            URI uri = asURI(revision.getExportLinks().get(DEFAULT_EXPORT_MIMETYPE));
449
450            InputStream is = doGet(user, uri);
451            Blob conversion = Blobs.createBlob(is);
452            conversion.setFilename(blob.getFilename());
453            conversion.setMimeType(DEFAULT_EXPORT_MIMETYPE);
454
455            fileInfo = new FileInfo(user, fileId, revision.getId());
456
457            // store a conversion of this revision
458            storeBlobConversion(doc, getKey(fileInfo), conversion);
459        }
460        return getBlob(fileInfo);
461    }
462
463    /**
464     * Store a conversion of the given blob
465     */
466    @SuppressWarnings("unchecked")
467    protected void storeBlobConversion(Document doc, String blobKey, Blob blob) {
468        if (!doc.hasFacet(BLOB_CONVERSIONS_FACET)) {
469            doc.addFacet(BLOB_CONVERSIONS_FACET);
470        }
471
472        List<Map<String, Object>> conversions = (List<Map<String, Object>>) doc.getValue(BLOB_CONVERSIONS_PROPERTY);
473        Map<String, Object> conversion = new HashMap<>();
474        conversion.put(BLOB_CONVERSION_KEY, blobKey);
475        conversion.put(BLOB_CONVERSION_BLOB, blob);
476        conversions.add(conversion);
477        doc.setValue(BLOB_CONVERSIONS_PROPERTY, conversions);
478    }
479
480    /**
481     * Retrieve a stored conversion of the given blob
482     */
483    protected Blob retrieveBlobConversion(ManagedBlob blob, String mimeType, DocumentModel doc) {
484        if (doc == null || !doc.hasFacet(BLOB_CONVERSIONS_FACET)) {
485            return null;
486        }
487
488        boolean txWasActive = TransactionHelper.isTransactionActiveOrMarkedRollback();
489        try {
490            if (!txWasActive) {
491                TransactionHelper.startTransaction();
492            }
493            ListProperty conversions = (ListProperty) doc.getProperty(BLOB_CONVERSIONS_PROPERTY);
494            for (int i = 0; i < conversions.size(); i++) {
495                if (blob.getKey().equals(conversions.get(i).getValue(BLOB_CONVERSION_KEY))) {
496                    String conversionXPath = String.format("%s/%d/%s", BLOB_CONVERSIONS_PROPERTY, i, BLOB_CONVERSION_BLOB);
497                    Blob conversion = (Blob) doc.getPropertyValue(conversionXPath);
498                    if (conversion.getMimeType().equals(mimeType)) {
499                        return conversion;
500                    }
501                }
502            }
503        } finally {
504            if (!txWasActive) {
505                TransactionHelper.commitOrRollbackTransaction();
506            }
507        }
508        return null;
509    }
510
511    /**
512     * Gets the blob for a Google Drive file.
513     *
514     * @param fileInfo the file info
515     * @return the blob
516     */
517    protected ManagedBlob getBlob(FileInfo fileInfo) throws IOException {
518        String key = getKey(fileInfo);
519        File file = getFile(fileInfo);
520        String filename = file.getTitle().replace("/", "-");
521        BlobInfo blobInfo = new BlobInfo();
522        blobInfo.key = key;
523        blobInfo.mimeType = file.getMimeType();
524        blobInfo.encoding = null; // TODO extract from mimeType
525        blobInfo.filename = filename;
526        blobInfo.length = file.getFileSize();
527        // etag for native Google documents and md5 for everything else
528        String digest = getDigest(file);
529        blobInfo.digest = digest;
530        return new SimpleManagedBlob(blobInfo);
531    }
532
533    protected String getDigest(File file) {
534        String digest = file.getMd5Checksum();
535        if (digest == null) {
536            digest = file.getEtag();
537        }
538        return digest;
539    }
540
541    protected boolean isDigestChanged(Blob blob, File file) {
542        final String digest = blob.getDigest();
543        String md5CheckSum = file.getMd5Checksum();
544        String eTag = file.getEtag();
545        if (md5CheckSum != null) {
546            return !md5CheckSum.equals(digest);
547        } else {
548            return eTag != null && !eTag.equals(digest);
549        }
550    }
551
552    protected boolean isFilenameChanged(Blob blob, File file) {
553        return !file.getTitle().replace("/", "-").equals(blob.getFilename());
554    }
555
556    protected boolean isChanged(Blob blob, File file) {
557        return isFilenameChanged(blob, file) || isDigestChanged(blob, file);
558    }
559
560    /** Adds the prefix to the key. */
561    protected String getKey(FileInfo fileInfo) {
562        return blobProviderId + ':' + fileInfo.user + ':' + fileInfo.fileId
563                + (fileInfo.revisionId == null ? "" : ':' + fileInfo.revisionId);
564    }
565
566    /** Removes the prefix from the key. */
567    protected FileInfo getFileInfo(ManagedBlob blob) {
568        String key = blob.getKey();
569        int colon = key.indexOf(':');
570        if (colon < 0) {
571            throw new IllegalArgumentException(key);
572        }
573        String suffix = key.substring(colon + 1);
574        String[] parts = suffix.split(":");
575        if (parts.length < 2 || parts.length > 3) {
576            throw new IllegalArgumentException(key);
577        }
578        return new FileInfo(parts[0], parts[1], parts.length < 3 ? null : parts[2]);
579    }
580
581    protected Credential getCredential(String user) throws IOException {
582        return getCredentialFactory().build(user);
583    }
584
585    protected CredentialFactory getCredentialFactory() {
586        OAuth2ServiceProvider provider = Framework.getLocalService(OAuth2ServiceProviderRegistry.class).getProvider(
587                blobProviderId);
588        if (provider != null && provider.isEnabled()) {
589            // Web application configuration
590            return new OAuth2CredentialFactory(provider);
591        } else {
592            // Service account configuration
593            return new ServiceAccountCredentialFactory(serviceAccountId, serviceAccountP12File);
594        }
595    }
596
597    protected Drive getService(String user) throws IOException {
598        Credential credential = getCredential(user);
599        if (credential == null) {
600            throw new IOException("No credentials found for user " + user);
601        }
602        HttpTransport httpTransport = credential.getTransport();
603        JsonFactory jsonFactory = credential.getJsonFactory();
604        return new Drive.Builder(httpTransport, jsonFactory, credential) //
605        .setApplicationName(APPLICATION_NAME) // set application name to avoid a WARN
606        .build();
607    }
608
609    /**
610     * Retrieve a partial {@link File} resource.
611     */
612    protected File getPartialFile(String user, String fileId, String... fields) throws IOException {
613        return getService(user).files().get(fileId).setFields(StringUtils.join(fields, ",")).execute();
614    }
615
616    /**
617     * Retrieves a {@link File} resource and caches the unparsed response.
618     *
619     * @return a {@link File} resource
620     */
621    // subclassed for mock
622    protected File getFile(FileInfo fileInfo) throws IOException {
623        // ignore revisionId
624        String cacheKey = "file_" + fileInfo.fileId;
625        DriveRequest<File> request = getService(fileInfo.user).files().get(fileInfo.fileId);
626        return executeAndCache(cacheKey, request, File.class);
627    }
628
629    /**
630     * Retrieves a {@link Revision} resource and caches the unparsed response.
631     *
632     * @return a {@link Revision} resource
633     */
634    // subclassed for mock
635    protected Revision getRevision(FileInfo fileInfo) throws IOException {
636        if (fileInfo.revisionId == null) {
637            throw new NullPointerException("null revisionId for " + fileInfo.fileId);
638        }
639        String cacheKey = "rev_" + fileInfo.fileId + "_" + fileInfo.revisionId;
640        DriveRequest<Revision> request = getService(fileInfo.user).revisions().get(fileInfo.fileId,
641                fileInfo.revisionId);
642        try {
643            return executeAndCache(cacheKey, request, Revision.class);
644        } catch (HttpResponseException e) {
645            // return null if revision is not found
646            if (e.getStatusCode() == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
647                return null;
648            }
649            throw e;
650        }
651    }
652
653    /**
654     * Executes a {@link DriveRequest} and caches the unparsed response.
655     */
656    protected <T> T executeAndCache(String cacheKey, DriveRequest<T> request, Class<T> aClass) throws IOException {
657        String resource = (String) getCache().get(cacheKey);
658
659        if (resource == null) {
660            HttpResponse response = request.executeUnparsed();
661            if (!response.isSuccessStatusCode()) {
662                return null;
663            }
664            resource = response.parseAsString();
665            if (cacheKey != null) {
666                getCache().put(cacheKey, resource);
667            }
668        }
669        return JSON_PARSER.parseAndClose(new StringReader(resource), aClass);
670    }
671
672    /**
673     * Retrieves the list of {@link Revision} resources for a file.
674     *
675     * @return a list of {@link Revision} resources
676     */
677    // subclassed for mock
678    protected RevisionList getRevisionList(FileInfo fileInfo) throws IOException {
679        return getService(fileInfo.user).revisions().list(fileInfo.fileId).execute();
680    }
681
682    /**
683     * Executes a GET request with the user's credentials.
684     */
685    protected InputStream doGet(String user, URI url) throws IOException {
686        HttpResponse response = getService(user).getRequestFactory().buildGetRequest(new GenericUrl(url)).execute();
687        return response.getContent();
688    }
689
690    /**
691     * Parse a {@link URI}.
692     *
693     * @return the {@link URI} or null if it fails
694     */
695    protected static URI asURI(String link) {
696        try {
697            return new URI(link);
698        } catch (URISyntaxException e) {
699            log.error("Invalid URI: " + link, e);
700            return null;
701        }
702    }
703
704    protected Cache getCache() {
705        if (cache == null) {
706            cache = Framework.getService(CacheService.class).getCache(FILE_CACHE_NAME);
707        }
708        return cache;
709    }
710
711    public String getClientId() {
712        OAuth2ServiceProvider provider = getOAuth2Provider();
713        return (provider != null && provider.isEnabled()) ? provider.getClientId() : clientId;
714    }
715
716    protected OAuth2ServiceProvider getOAuth2Provider() {
717        return Framework.getLocalService(OAuth2ServiceProviderRegistry.class).getProvider(blobProviderId);
718    }
719
720    @Override
721    public List<DocumentModel> checkChangesAndUpdateBlob(List<DocumentModel> docs) {
722        List<DocumentModel> changedDocuments = new ArrayList<>();
723        // TODO use google batch request here
724        for (DocumentModel doc : docs) {
725            final SimpleManagedBlob blob = (SimpleManagedBlob) doc.getProperty("content").getValue();
726            FileInfo fileInfo = getFileInfo(blob);
727            if (isVersion(blob)) {
728                // assume that revisions never change
729                continue;
730            }
731            try {
732                File remote = getPartialFile(fileInfo.user, fileInfo.fileId, "id", "title", "etag", "md5Checksum");
733                if (isChanged(blob, remote)) {
734                    doc.setPropertyValue("content", (SimpleManagedBlob) getBlob(getFileInfo(blob)));
735                    String cacheKey = "file_" + fileInfo.fileId;
736                    getCache().invalidate(cacheKey);
737                    changedDocuments.add(doc);
738                }
739            } catch (IOException e) {
740                log.error("Could not update google drive document " + doc.getTitle(), e);
741            }
742
743        }
744        return changedDocuments;
745    }
746
747    @Override
748    public String getPageProviderNameForUpdate() {
749        return GOOGLEDRIVE_DOCUMENT_TO_BE_UPDATED_PP;
750    }
751
752    @Override
753    public String getBlobProviderId() {
754        return blobProviderId;
755    }
756
757    @Override
758    public boolean isVersion(ManagedBlob blob) {
759        FileInfo fileInfo = getFileInfo(blob);
760        return fileInfo.revisionId != null;
761    }
762
763}