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 *     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.lang.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
120                    + ";"
121                    + String.format("new nuxeo.utils.BoxPicker('%s', '%s','%s', '%s')", getClientId(provider), inputId,
122                            infoId, authorizationUrl);
123            writer.writeAttribute("onclick", onButtonClick, null);
124        }
125
126        writer.startElement("span", parent);
127        writer.writeAttribute("id", pickId, null);
128        message = I18NUtils.getMessageString("messages", "label.inputFile.boxUploadPicker", null, locale);
129        writer.write(message);
130        writer.endElement("span");
131
132        writer.endElement("button");
133
134        if (isProviderAvailable) {
135            writer.write(ComponentUtils.WHITE_SPACE_CHARACTER);
136            writer.startElement("span", parent);
137            writer.writeAttribute("id", infoId, null);
138            message = I18NUtils.getMessageString("messages", "error.inputFile.noFileSelected", null, locale);
139            writer.write(message);
140            writer.endElement("span");
141        } else {
142            // if oauth service provider not properly setup, add warning message
143            writer.startElement("span", parent);
144            writer.writeAttribute("class", "processMessage completeWarning", null);
145            writer.writeAttribute(
146                    "style",
147                    "margin: 0 0 .5em 0; font-size: 11px; padding: 0.4em 0.5em 0.5em 2.2em; background-position-y: 0.6em",
148                    null);
149            message = I18NUtils.getMessageString("messages", "error.box.providerUnavailable", null, locale);
150            writer.write(message);
151            writer.endElement("span");
152        }
153
154        inputText.setLocalValueSet(false);
155        inputText.setStyle("display: none");
156        ComponentUtils.encodeComponent(context, inputText);
157    }
158
159    @Override
160    public void validateUpload(UIInput parent, FacesContext context, InputFileInfo submitted) {
161        UIComponent facet = parent.getFacet(UPLOAD_BOX_FACET_NAME);
162        if (!(facet instanceof HtmlInputText)) {
163            return;
164        }
165        HtmlInputText inputText = (HtmlInputText) facet;
166        Object value = inputText.getSubmittedValue();
167        if (value != null && !(value instanceof String)) {
168            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.invalidSpecialBlob");
169            parent.setValid(false);
170            return;
171        }
172        String string = (String) value;
173        if (StringUtils.isBlank(string)) {
174            String message = context.getPartialViewContext().isAjaxRequest() ? 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        BoxOAuth2ServiceProvider provider = getBoxBlobProvider().getOAuth2Provider();
182        if (provider == null) {
183            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.boxInvalidConfiguration");
184            parent.setValid(false);
185            return;
186        }
187
188        String fileId = string;
189        Optional<String> serviceUserId = getServiceUserId(provider, fileId, FacesContext.getCurrentInstance()
190                                                                                        .getExternalContext()
191                                                                                        .getUserPrincipal());
192        if (!serviceUserId.isPresent()) {
193            String link = String.format(
194                    "<a href='#' onclick=\"openPopup('%s'); return false;\">Register a new token</a> and try again.",
195                    getOAuthAuthorizationUrl(provider));
196            ComponentUtils.addErrorMessage(context, parent, "error.inputFile.boxInvalidPermissions", new Object[] { link });
197            parent.setValid(false);
198            return;
199        }
200
201        try {
202            LiveConnectFileInfo fileInfo = new LiveConnectFileInfo(serviceUserId.get(), fileId);
203            Blob blob = getBoxBlobProvider().toBlob(fileInfo);
204            submitted.setBlob(blob);
205            submitted.setFilename(blob.getFilename());
206            submitted.setMimeType(blob.getMimeType());
207        } catch (IOException e) {
208            if (isCausedByUnauthorized(e)) {
209                String link = String.format(
210                        "<a href='#' onclick=\"openPopup('%s'); return false;\">Register a new token</a> and try again.",
211                        getOAuthAuthorizationUrl(provider));
212                ComponentUtils.addErrorMessage(context, parent, "error.inputFile.boxInvalidPermissions",
213                        new Object[] { link });
214                parent.setValid(false);
215                return;
216            }
217            throw new RuntimeException(e);
218        }
219    }
220
221    private boolean isCausedByUnauthorized(IOException ioe) {
222        return ioe.getCause() instanceof BoxAPIException
223                && ((BoxAPIException) ioe.getCause()).getResponseCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED;
224    }
225
226    /**
227     * Box upload button is added to the file widget if and only if Box OAuth service provider is enabled
228     *
229     * @return {@code true} if Box OAuth service provider is enabled or {@code false} otherwise
230     */
231    @Override
232    public boolean isEnabled() {
233        BoxOAuth2ServiceProvider provider = getBoxBlobProvider().getOAuth2Provider();
234        return provider != null && provider.isEnabled();
235    }
236
237    protected String getClientId(BoxOAuth2ServiceProvider provider) {
238        return Optional.ofNullable(provider).map(BoxOAuth2ServiceProvider::getClientId).orElse("");
239    }
240
241    protected BoxBlobProvider getBoxBlobProvider() {
242        return (BoxBlobProvider) Framework.getService(BlobManager.class).getBlobProvider(id);
243    }
244
245    /**
246     * Iterates all registered Box tokens of a {@link Principal} to get the serviceLogin of a token with access to a Box
247     * file. We need this because Box file picker doesn't provide any information about the account that was used to
248     * select the file, and therefore we need to "guess".
249     */
250    private Optional<String> getServiceUserId(BoxOAuth2ServiceProvider provider, String fileId, Principal principal) {
251        Map<String, Serializable> filter = new HashMap<>();
252        filter.put("nuxeoLogin", principal.getName());
253
254        return provider.getCredentialDataStore()
255                       .query(filter)
256                       .stream()
257                       .map(NuxeoOAuth2Token::new)
258                       .filter(token -> hasAccessToFile(token, fileId))
259                       .map(NuxeoOAuth2Token::getServiceLogin)
260                       .findFirst();
261    }
262
263    /**
264     * Attempts to retrieve a Box file's metadata to check if an accessToken has permissions to access the file.
265     *
266     * @return {@code true} if metadata was successfully retrieved, or {@code false} otherwise
267     */
268    private boolean hasAccessToFile(NuxeoOAuth2Token token, String fileId) {
269        try {
270            BoxAPIConnection client = getBoxBlobProvider().getBoxClient(token);
271            return new BoxFile(client, fileId).getInfo("size") != null;
272        } catch (IOException e) {
273            throw new RuntimeException(e); // TODO better feedback
274        } catch (BoxAPIException e) {
275            // Unauthorized
276            return e.getResponseCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED;
277        }
278    }
279
280    private boolean hasServiceAccount(BoxOAuth2ServiceProvider provider) {
281        HttpServletRequest request = getHttpServletRequest();
282        String username = request.getUserPrincipal().getName();
283        return provider != null && provider.getServiceUser(username) != null;
284    }
285
286    private String getOAuthAuthorizationUrl(BoxOAuth2ServiceProvider provider) {
287        HttpServletRequest request = getHttpServletRequest();
288        return (provider != null && provider.getClientId() != null) ? provider.getAuthorizationUrl(request) : "";
289    }
290
291    private HttpServletRequest getHttpServletRequest() {
292        return (HttpServletRequest) FacesContext.getCurrentInstance().getExternalContext().getRequest();
293    }
294}