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}