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}