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