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 *     Andre Justo
018 */
019package org.nuxeo.ecm.liveconnect.dropbox;
020
021import java.io.IOException;
022import java.io.Serializable;
023import java.io.UnsupportedEncodingException;
024import java.net.URLDecoder;
025import java.security.Principal;
026import java.util.HashMap;
027import java.util.Locale;
028import java.util.Map;
029
030import javax.faces.application.Application;
031import javax.faces.component.NamingContainer;
032import javax.faces.component.UIComponent;
033import javax.faces.component.UIInput;
034import javax.faces.component.html.HtmlInputText;
035import javax.faces.context.FacesContext;
036import javax.faces.context.ResponseWriter;
037import javax.servlet.http.HttpServletRequest;
038
039import org.apache.commons.lang.StringUtils;
040import org.nuxeo.common.utils.i18n.I18NUtils;
041import org.nuxeo.ecm.core.api.Blob;
042import org.nuxeo.ecm.core.api.DocumentModel;
043import org.nuxeo.ecm.core.api.DocumentModelList;
044import org.nuxeo.ecm.core.api.NuxeoException;
045import org.nuxeo.ecm.core.blob.BlobManager;
046import org.nuxeo.ecm.liveconnect.core.LiveConnectFileInfo;
047import org.nuxeo.ecm.platform.oauth2.tokens.NuxeoOAuth2Token;
048import org.nuxeo.ecm.platform.ui.web.component.file.InputFileChoice;
049import org.nuxeo.ecm.platform.ui.web.component.file.InputFileInfo;
050import org.nuxeo.ecm.platform.ui.web.component.file.JSFBlobUploader;
051import org.nuxeo.ecm.platform.ui.web.util.ComponentUtils;
052import org.nuxeo.runtime.api.Framework;
053
054import com.dropbox.core.DbxException;
055
056/**
057 * JSF Blob Upload based on Dropbox blobs.
058 *
059 * @since 7.3
060 */
061public class DropboxBlobUploader implements JSFBlobUploader {
062
063    public static final String UPLOAD_DROPBOX_FACET_NAME = "uploadDropbox";
064
065    protected final String id;
066
067    public DropboxBlobUploader(String id) {
068        this.id = id;
069        try {
070            getDropboxBlobProvider();
071        } catch (NuxeoException e) {
072            // this exception is caught by JSFBlobUploaderDescriptor.getJSFBlobUploader
073            // to mean that the uploader is not available because badly configured
074            throw new IllegalStateException(e);
075        }
076    }
077
078    @Override
079    public String getChoice() {
080        return InputFileChoice.UPLOAD + "Dropbox";
081    }
082
083    @Override
084    public void hookSubComponent(UIInput parent) {
085        Application app = FacesContext.getCurrentInstance().getApplication();
086        ComponentUtils.initiateSubComponent(parent, UPLOAD_DROPBOX_FACET_NAME,
087                app.createComponent(HtmlInputText.COMPONENT_TYPE));
088    }
089
090    @Override
091    public void encodeBeginUpload(UIInput parent, FacesContext context, String onClick) throws IOException {
092        UIComponent facet = parent.getFacet(UPLOAD_DROPBOX_FACET_NAME);
093        if (!(facet instanceof HtmlInputText)) {
094            return;
095        }
096        HtmlInputText inputText = (HtmlInputText) facet;
097
098        // not ours to close
099        @SuppressWarnings("resource")
100        ResponseWriter writer = context.getResponseWriter();
101
102        String inputId = facet.getClientId(context);
103        String prefix = parent.getClientId(context) + NamingContainer.SEPARATOR_CHAR;
104        String pickId = prefix + "DropboxPickMsg";
105        String infoId = prefix + "DropboxInfo";
106        String authorizationUrl = hasServiceAccount() ? "" : getOAuthAuthorizationUrl();
107        Locale locale = context.getViewRoot().getLocale();
108        String message;
109        boolean isProviderAvailable = getDropboxBlobProvider().getOAuth2Provider().isProviderAvailable();
110
111        writer.startElement("button", parent);
112        writer.writeAttribute("type", "button", null);
113        writer.writeAttribute("class", "button", null);
114
115        // only add onclick event to button if oauth service provider is available
116        // this prevents users from using the picker if some configuration is missing
117        if (isProviderAvailable) {
118            String onButtonClick = onClick
119                    + ";"
120                    + String.format("new nuxeo.utils.DropboxPicker('%s', '%s','%s', '%s')", inputId, infoId,
121                            authorizationUrl, getClientId());
122            writer.writeAttribute("onclick", onButtonClick, null);
123        }
124
125        writer.startElement("span", parent);
126        writer.writeAttribute("id", pickId, null);
127        message = I18NUtils.getMessageString("messages", "label.inputFile.dropboxUploadPicker", null, locale);
128        writer.write(message);
129        writer.endElement("span");
130
131        writer.endElement("button");
132
133        if (isProviderAvailable) {
134            writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
135            writer.startElement("span", parent);
136            writer.writeAttribute("id", infoId, null);
137            message = I18NUtils.getMessageString("messages", "error.inputFile.noFileSelected", null, locale);
138            writer.write(message);
139            writer.endElement("span");
140        } else {
141            // if oauth service provider not properly setup, add warning message
142            writer.startElement("span", parent);
143            writer.writeAttribute("class", "processMessage completeWarning", null);
144            writer.writeAttribute(
145                    "style",
146                    "margin: 0 0 .5em 0; font-size: 11px; padding: 0.4em 0.5em 0.5em 2.2em; background-position-y: 0.6em",
147                    null);
148            message = I18NUtils.getMessageString("messages", "error.dropbox.providerUnavailable", null, locale);
149            writer.write(message);
150            writer.endElement("span");
151        }
152
153        inputText.setLocalValueSet(false);
154        inputText.setStyle("display: none");
155        ComponentUtils.encodeComponent(context, inputText);
156    }
157
158    @Override
159    public void validateUpload(UIInput parent, FacesContext context, InputFileInfo submitted) {
160        UIComponent facet = parent.getFacet(UPLOAD_DROPBOX_FACET_NAME);
161        if (!(facet instanceof HtmlInputText)) {
162            return;
163        }
164        HtmlInputText inputText = (HtmlInputText) facet;
165        Object value = inputText.getSubmittedValue();
166        String string;
167        if (value == null || value instanceof String) {
168            string = (String) value;
169        } else {
170            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.invalidSpecialBlob");
171            parent.setValid(false);
172            return;
173        }
174        if (StringUtils.isBlank(string)) {
175            String message = context.getPartialViewContext().isAjaxRequest() ? InputFileInfo.INVALID_WITH_AJAX_MESSAGE
176                    : InputFileInfo.INVALID_FILE_MESSAGE;
177            ComponentUtils.addErrorMessage(context, parent, message);
178            parent.setValid(false);
179            return;
180        }
181
182        if (getDropboxBlobProvider().getOAuth2Provider() == null) {
183            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.dropboxInvalidConfiguration");
184            parent.setValid(false);
185            return;
186        }
187
188        String filePath = getPathFromUrl(string);
189        if (StringUtils.isBlank(filePath)) {
190            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.invalidFilePath");
191            parent.setValid(false);
192            return;
193        }
194
195        String serviceUserId = getServiceUserId(filePath, FacesContext.getCurrentInstance()
196                                                                      .getExternalContext()
197                                                                      .getUserPrincipal());
198        if (StringUtils.isBlank(serviceUserId)) {
199            String link = String.format(
200                    "<a href='#' onclick=\"openPopup('%s'); return false;\">Register a new token</a> and try again.",
201                    getOAuthAuthorizationUrl());
202            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.dropboxInvalidPermissions", new Object[] { link });
203            parent.setValid(false);
204            return;
205        }
206
207        LiveConnectFileInfo fileInfo = new LiveConnectFileInfo(serviceUserId, filePath);
208        Blob blob = createBlob(fileInfo);
209        submitted.setBlob(blob);
210        submitted.setFilename(blob.getFilename());
211        submitted.setMimeType(blob.getMimeType());
212    }
213
214    /**
215     * Dropbox upload button is added to the file widget if and only if Dropbox OAuth service provider is enabled
216     *
217     * @return true if Dropbox OAuth service provider is enabled or false otherwise.
218     */
219    @Override
220    public boolean isEnabled() {
221        return getDropboxBlobProvider().getOAuth2Provider().isEnabled();
222    }
223
224    /**
225     * Creates a Dropbox managed blob.
226     *
227     * @param fileInfo the Dropbox file info
228     * @return the blob
229     */
230    protected Blob createBlob(LiveConnectFileInfo fileInfo) {
231        try {
232            return getDropboxBlobProvider().toBlob(fileInfo);
233        } catch (IOException e) {
234            throw new RuntimeException(e); // TODO better feedback
235        }
236    }
237
238    protected String getClientId() {
239        String clientId = getDropboxBlobProvider().getClientId();
240        return (clientId != null) ? clientId : "";
241    }
242
243    protected DropboxBlobProvider getDropboxBlobProvider() {
244        return (DropboxBlobProvider) Framework.getService(BlobManager.class).getBlobProvider(id);
245    }
246
247    /**
248     * Retrieves a file path from a Dropbox sharable URL.
249     *
250     * @param url
251     * @return
252     */
253    private String getPathFromUrl(String url) {
254        String pattern = "https://dl.dropboxusercontent.com/1/view/[\\w]*";
255        String path = url.replaceAll(pattern, "");
256        try {
257            path = URLDecoder.decode(path, "UTF-8");
258        } catch (UnsupportedEncodingException e) {
259            throw new RuntimeException(e); // TODO better feedback
260        }
261        return path;
262    }
263
264    /**
265     * Iterates all registered Dropbox tokens of a {@link Principal} to get the serviceLogin of a token with access to a
266     * Dropbox file. We need this because Dropbox file picker doesn't provide any information about the account that was
267     * used to select the file, and therefore we need to "guess".
268     *
269     * @param filePath
270     * @param principal
271     * @return
272     */
273    private String getServiceUserId(String filePath, Principal principal) {
274        Map<String, Serializable> filter = new HashMap<>();
275        filter.put("nuxeoLogin", principal.getName());
276
277        DocumentModelList userTokens = getDropboxBlobProvider().getOAuth2Provider()
278                                                               .getCredentialDataStore()
279                                                               .query(filter);
280        for (DocumentModel entry : userTokens) {
281            NuxeoOAuth2Token token = new NuxeoOAuth2Token(entry);
282            if (hasAccessToFile(filePath, token.getAccessToken())) {
283                return token.getServiceLogin();
284            }
285        }
286        return null;
287    }
288
289    /**
290     * Attempts to retrieve a Dropbox file's metadata to check if an accessToken has permissions to access the file.
291     *
292     * @param filePath
293     * @param accessToken
294     * @return true if metadata was successfully retrieved, or false otherwise.
295     */
296    private boolean hasAccessToFile(String filePath, String accessToken) {
297        try {
298            return getDropboxBlobProvider().getDropboxClient(accessToken).getMetadata(filePath) != null;
299        } catch (DbxException | IOException e) {
300            throw new RuntimeException(e); // TODO better feedback
301        }
302    }
303
304    private boolean hasServiceAccount() {
305        HttpServletRequest request = getHttpServletRequest();
306        String username = request.getUserPrincipal().getName();
307        DropboxOAuth2ServiceProvider provider = getDropboxBlobProvider().getOAuth2Provider();
308        return provider != null && provider.getServiceUser(username) != null;
309    }
310
311    private String getOAuthAuthorizationUrl() {
312        HttpServletRequest request = getHttpServletRequest();
313        DropboxOAuth2ServiceProvider provider = getDropboxBlobProvider().getOAuth2Provider();
314        return (provider != null && provider.getClientId() != null) ? provider.getAuthorizationUrl(request) : "";
315    }
316
317    private HttpServletRequest getHttpServletRequest() {
318        return (HttpServletRequest) FacesContext.getCurrentInstance().getExternalContext().getRequest();
319    }
320}