001/*
002 * (C) Copyright 2015-2018 Nuxeo (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 *     Kevin Leturc
018 */
019package org.nuxeo.ecm.liveconnect.box;
020
021import java.io.IOException;
022import java.io.Serializable;
023import java.security.Principal;
024import java.util.HashMap;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Optional;
028
029import javax.faces.application.Application;
030import javax.faces.component.UIComponent;
031import javax.faces.component.UIInput;
032import javax.faces.component.UINamingContainer;
033import javax.faces.component.html.HtmlInputText;
034import javax.faces.context.FacesContext;
035import javax.faces.context.ResponseWriter;
036import javax.servlet.http.HttpServletRequest;
037
038import org.apache.commons.lang3.StringUtils;
039import org.nuxeo.common.utils.i18n.I18NUtils;
040import org.nuxeo.ecm.core.api.Blob;
041import org.nuxeo.ecm.core.api.NuxeoException;
042import org.nuxeo.ecm.core.blob.BlobManager;
043import org.nuxeo.ecm.liveconnect.core.LiveConnectFileInfo;
044import org.nuxeo.ecm.platform.oauth2.tokens.NuxeoOAuth2Token;
045import org.nuxeo.ecm.platform.ui.web.component.file.InputFileChoice;
046import org.nuxeo.ecm.platform.ui.web.component.file.InputFileInfo;
047import org.nuxeo.ecm.platform.ui.web.component.file.JSFBlobUploader;
048import org.nuxeo.ecm.platform.ui.web.util.ComponentUtils;
049import org.nuxeo.runtime.api.Framework;
050
051import com.box.sdk.BoxAPIConnection;
052import com.box.sdk.BoxAPIException;
053import com.box.sdk.BoxFile;
054import com.google.api.client.http.HttpStatusCodes;
055
056/**
057 * JSF Blob Upload based on box blobs.
058 *
059 * @since 8.1
060 */
061public class BoxBlobUploader implements JSFBlobUploader {
062
063    public static final String UPLOAD_BOX_FACET_NAME = InputFileChoice.UPLOAD + "Box";
064
065    protected final String id;
066
067    public BoxBlobUploader(String id) {
068        this.id = id;
069        try {
070            getBoxBlobProvider();
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 UPLOAD_BOX_FACET_NAME;
081    }
082
083    @Override
084    public void hookSubComponent(UIInput parent) {
085        Application app = FacesContext.getCurrentInstance().getApplication();
086        ComponentUtils.initiateSubComponent(parent, UPLOAD_BOX_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_BOX_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        BoxOAuth2ServiceProvider provider = getBoxBlobProvider().getOAuth2Provider();
102
103        String inputId = facet.getClientId(context);
104        String prefix = parent.getClientId(context) + UINamingContainer.getSeparatorChar(context);
105        String pickId = prefix + "BoxPickMsg";
106        String infoId = prefix + "BoxInfo";
107        String authorizationUrl = hasServiceAccount(provider) ? "" : getOAuthAuthorizationUrl(provider);
108        Locale locale = context.getViewRoot().getLocale();
109        String message;
110        boolean isProviderAvailable = provider != null && provider.isProviderAvailable();
111
112        writer.startElement("button", parent);
113        writer.writeAttribute("type", "button", null);
114        writer.writeAttribute("class", "button", null);
115
116        // only add onclick event to button if oauth service provider is available
117        // this prevents users from using the picker if some configuration is missing
118        if (isProviderAvailable) {
119            String onButtonClick = onClick + ";" + String.format("new nuxeo.utils.BoxPicker('%s', '%s','%s', '%s')",
120                    getClientId(provider), inputId, infoId, authorizationUrl);
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.boxUploadPicker", 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.box.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_BOX_FACET_NAME);
159        if (!(facet instanceof HtmlInputText)) {
160            return;
161        }
162        HtmlInputText inputText = (HtmlInputText) facet;
163        Object value = inputText.getSubmittedValue();
164        if (value != null && !(value instanceof String)) {
165            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.invalidSpecialBlob");
166            parent.setValid(false);
167            return;
168        }
169        String string = (String) value;
170        if (StringUtils.isBlank(string)) {
171            String message = context.getPartialViewContext().isAjaxRequest() ? 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        BoxOAuth2ServiceProvider provider = getBoxBlobProvider().getOAuth2Provider();
179        if (provider == null) {
180            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.boxInvalidConfiguration");
181            parent.setValid(false);
182            return;
183        }
184
185        String fileId = string;
186        Optional<String> serviceUserId = getServiceUserId(provider, fileId,
187                FacesContext.getCurrentInstance().getExternalContext().getUserPrincipal());
188        if (!serviceUserId.isPresent()) {
189            String link = String.format(
190                    "<a href='#' onclick=\"openPopup('%s'); return false;\">Register a new token</a> and try again.",
191                    getOAuthAuthorizationUrl(provider));
192            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.boxInvalidPermissions",
193                    new Object[] { link });
194            parent.setValid(false);
195            return;
196        }
197
198        try {
199            LiveConnectFileInfo fileInfo = new LiveConnectFileInfo(serviceUserId.get(), fileId);
200            Blob blob = getBoxBlobProvider().toBlob(fileInfo);
201            submitted.setBlob(blob);
202            submitted.setFilename(blob.getFilename());
203            submitted.setMimeType(blob.getMimeType());
204        } catch (IOException e) {
205            if (isCausedByUnauthorized(e)) {
206                String link = String.format(
207                        "<a href='#' onclick=\"openPopup('%s'); return false;\">Register a new token</a> and try again.",
208                        getOAuthAuthorizationUrl(provider));
209                ComponentUtils.addErrorMessage(context, parent, "error.inputFile.boxInvalidPermissions",
210                        new Object[] { link });
211                parent.setValid(false);
212                return;
213            }
214            throw new RuntimeException(e);
215        }
216    }
217
218    private boolean isCausedByUnauthorized(IOException ioe) {
219        return ioe.getCause() instanceof BoxAPIException
220                && ((BoxAPIException) ioe.getCause()).getResponseCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED;
221    }
222
223    /**
224     * Box upload button is added to the file widget if and only if Box OAuth service provider is enabled
225     *
226     * @return {@code true} if Box OAuth service provider is enabled or {@code false} otherwise
227     */
228    @Override
229    public boolean isEnabled() {
230        BoxOAuth2ServiceProvider provider = getBoxBlobProvider().getOAuth2Provider();
231        return provider != null && provider.isEnabled();
232    }
233
234    protected String getClientId(BoxOAuth2ServiceProvider provider) {
235        return Optional.ofNullable(provider).map(BoxOAuth2ServiceProvider::getClientId).orElse("");
236    }
237
238    protected BoxBlobProvider getBoxBlobProvider() {
239        return (BoxBlobProvider) Framework.getService(BlobManager.class).getBlobProvider(id);
240    }
241
242    /**
243     * Iterates all registered Box tokens of a {@link Principal} to get the serviceLogin of a token with access to a Box
244     * file. We need this because Box file picker doesn't provide any information about the account that was used to
245     * select the file, and therefore we need to "guess".
246     */
247    private Optional<String> getServiceUserId(BoxOAuth2ServiceProvider provider, String fileId, Principal principal) {
248        Map<String, Serializable> filter = new HashMap<>();
249        filter.put("nuxeoLogin", principal.getName());
250
251        return provider.getCredentialDataStore()
252                       .query(filter)
253                       .stream()
254                       .map(NuxeoOAuth2Token::new)
255                       .filter(token -> hasAccessToFile(token, fileId))
256                       .map(NuxeoOAuth2Token::getServiceLogin)
257                       .findFirst();
258    }
259
260    /**
261     * Attempts to retrieve a Box file's metadata to check if an accessToken has permissions to access the file.
262     *
263     * @return {@code true} if metadata was successfully retrieved, or {@code false} otherwise
264     */
265    private boolean hasAccessToFile(NuxeoOAuth2Token token, String fileId) {
266        try {
267            BoxAPIConnection client = getBoxBlobProvider().getBoxClient(token);
268            return new BoxFile(client, fileId).getInfo("size") != null;
269        } catch (IOException e) {
270            throw new RuntimeException(e); // TODO better feedback
271        } catch (BoxAPIException e) {
272            // Unauthorized
273            return e.getResponseCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED;
274        }
275    }
276
277    private boolean hasServiceAccount(BoxOAuth2ServiceProvider provider) {
278        HttpServletRequest request = getHttpServletRequest();
279        String username = request.getUserPrincipal().getName();
280        return provider != null && provider.getServiceUser(username) != null;
281    }
282
283    private String getOAuthAuthorizationUrl(BoxOAuth2ServiceProvider provider) {
284        HttpServletRequest request = getHttpServletRequest();
285        return (provider != null && provider.getClientId() != null) ? provider.getAuthorizationUrl(request) : "";
286    }
287
288    private HttpServletRequest getHttpServletRequest() {
289        return (HttpServletRequest) FacesContext.getCurrentInstance().getExternalContext().getRequest();
290    }
291}