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