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