001/* 002 * (C) Copyright 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 * Thomas Roger 018 */ 019 020package org.nuxeo.wopi; 021 022import static org.nuxeo.wopi.Constants.WOPI_DISCOVERY_KEY; 023import static org.nuxeo.wopi.Constants.WOPI_DISCOVERY_REFRESH_EVENT; 024import static org.nuxeo.wopi.Constants.WOPI_DISCOVERY_URL_PROPERTY; 025import static org.nuxeo.wopi.Constants.WOPI_KEY_VALUE_STORE_NAME; 026 027import java.io.IOException; 028import java.io.InputStream; 029import java.io.OutputStream; 030import java.io.Serializable; 031import java.security.PublicKey; 032import java.util.Arrays; 033import java.util.Collections; 034import java.util.HashMap; 035import java.util.List; 036import java.util.Map; 037import java.util.Random; 038 039import org.apache.commons.io.FilenameUtils; 040import org.apache.commons.io.IOUtils; 041import org.apache.commons.lang3.ArrayUtils; 042import org.apache.commons.lang3.StringUtils; 043import org.apache.http.client.methods.CloseableHttpResponse; 044import org.apache.http.client.methods.HttpGet; 045import org.apache.http.impl.client.CloseableHttpClient; 046import org.apache.http.impl.client.HttpClientBuilder; 047import org.apache.logging.log4j.LogManager; 048import org.apache.logging.log4j.Logger; 049import org.nuxeo.ecm.core.api.Blob; 050import org.nuxeo.ecm.core.api.NuxeoException; 051import org.nuxeo.ecm.core.event.Event; 052import org.nuxeo.ecm.core.event.EventContext; 053import org.nuxeo.ecm.core.event.EventProducer; 054import org.nuxeo.ecm.core.event.impl.EventContextImpl; 055import org.nuxeo.runtime.api.Framework; 056import org.nuxeo.runtime.kv.KeyValueService; 057import org.nuxeo.runtime.kv.KeyValueStore; 058import org.nuxeo.runtime.model.ComponentContext; 059import org.nuxeo.runtime.model.DefaultComponent; 060import org.nuxeo.runtime.pubsub.AbstractPubSubBroker; 061import org.nuxeo.runtime.pubsub.SerializableMessage; 062import org.nuxeo.runtime.services.config.ConfigurationService; 063import org.nuxeo.wopi.WOPIDiscovery.NetZone; 064 065/** 066 * @since 10.3 067 */ 068public class WOPIServiceImpl extends DefaultComponent implements WOPIService { 069 070 private static final Logger log = LogManager.getLogger(WOPIServiceImpl.class); 071 072 public static final String PLACEHOLDER_IS_LICENSED_USER = "IsLicensedUser"; 073 074 public static final String PLACEHOLDER_IS_LICENSED_USER_VALUE = "1"; 075 076 public static final String WOPI_PROPERTY_NAMESPACE = "org.nuxeo.wopi"; 077 078 public static final String SUPPORTED_APP_NAMES_PROPERTY_KEY = "supportedAppNames"; 079 080 protected static final String CLUSTERING_ENABLED_PROP = "repository.clustering.enabled"; 081 082 protected static final String NODE_ID_PROP = "repository.clustering.id"; 083 084 protected static final Random RANDOM = new Random(); // NOSONAR (doesn't need cryptographic strength) 085 086 protected static final String WOPI_DISCOVERY_INVAL_PUBSUB_TOPIC = "wopiDiscoveryInval"; 087 088 // extension => app name 089 protected Map<String, String> extensionAppNames = new HashMap<>(); 090 091 // extension => wopi action => wopi action url 092 protected Map<String, Map<String, String>> extensionActionURLs = new HashMap<>(); 093 094 protected PublicKey proofKey; 095 096 protected PublicKey oldProofKey; 097 098 protected String discoveryURL; 099 100 protected WOPIDiscoveryInvalidator invalidator; 101 102 @Override 103 public void start(ComponentContext context) { 104 registerInvalidator(); 105 106 discoveryURL = Framework.getProperty(WOPI_DISCOVERY_URL_PROPERTY); 107 108 loadDiscovery(); 109 } 110 111 protected void registerInvalidator() { 112 if (Framework.isBooleanPropertyTrue(CLUSTERING_ENABLED_PROP)) { 113 // register WOPI discovery invalidator 114 String nodeId = Framework.getProperty(NODE_ID_PROP); 115 if (StringUtils.isBlank(nodeId)) { 116 nodeId = String.valueOf(RANDOM.nextLong()); 117 log.warn( 118 "Missing cluster node id configuration, please define it explicitly " 119 + "(usually through repository.clustering.id). Using random cluster node id instead: {}", 120 nodeId); 121 } else { 122 nodeId = nodeId.trim(); 123 } 124 invalidator = new WOPIDiscoveryInvalidator(); 125 invalidator.initialize(WOPI_DISCOVERY_INVAL_PUBSUB_TOPIC, nodeId); 126 log.info("Registered WOPI discovery invalidator for node: {}", nodeId); 127 } else { 128 log.info("Not registering a WOPI discovery invalidator because clustering is not enabled"); 129 } 130 } 131 132 protected void loadDiscovery() { 133 byte[] discoveryBytes = getDiscovery(); 134 if (ArrayUtils.isEmpty(discoveryBytes)) { 135 boolean refreshed = refreshDiscovery(); 136 if (!refreshed) { 137 log.error("Cannot load WOPI discovery: WOPI disabled"); 138 } 139 } else { 140 loadDiscovery(discoveryBytes); 141 fireRefreshDiscovery(); 142 } 143 } 144 145 protected boolean loadDiscovery(byte[] discoveryBytes) { 146 WOPIDiscovery discovery; 147 try { 148 discovery = WOPIDiscovery.read(discoveryBytes); 149 } catch (NuxeoException e) { 150 log.error("Error while reading WOPI discovery {}", e::getMessage); 151 log.debug(e, e); 152 return false; 153 } 154 155 NetZone netZone = discovery.getNetZone(); 156 if (netZone == null) { 157 log.error("Invalid WOPI discovery, no net-zone element"); 158 return false; 159 } 160 161 List<String> supportedAppNames = getSupportedAppNames(); 162 netZone.getApps() 163 .stream() 164 .filter(app -> supportedAppNames.contains(app.getName())) 165 .forEach(this::registerApp); 166 log.debug("Successfully loaded WOPI discovery: WOPI enabled"); 167 168 WOPIDiscovery.ProofKey pk = discovery.getProofKey(); 169 proofKey = ProofKeyHelper.getPublicKey(pk.getModulus(), pk.getExponent()); 170 oldProofKey = ProofKeyHelper.getPublicKey(pk.getOldModulus(), pk.getOldExponent()); 171 log.debug("Registered proof key: {}", proofKey); 172 log.debug("Registered old proof key: {}", oldProofKey); 173 return true; 174 } 175 176 protected void fireRefreshDiscovery() { 177 EventContext ctx = new EventContextImpl(); 178 Event event = ctx.newEvent(WOPI_DISCOVERY_REFRESH_EVENT); 179 Framework.getService(EventProducer.class).fireEvent(event); 180 } 181 182 protected List<String> getSupportedAppNames() { 183 Serializable supportedAppNames = Framework.getService(ConfigurationService.class) 184 .getProperties(WOPI_PROPERTY_NAMESPACE) 185 .get(SUPPORTED_APP_NAMES_PROPERTY_KEY); 186 if (!(supportedAppNames instanceof String[])) { 187 return Collections.emptyList(); 188 } 189 return Arrays.asList((String[]) supportedAppNames); 190 } 191 192 protected void registerApp(WOPIDiscovery.App app) { 193 app.getActions().forEach(action -> { 194 extensionAppNames.put(action.getExt(), app.getName()); 195 extensionActionURLs.computeIfAbsent(action.getExt(), k -> new HashMap<>()) 196 .put(action.getName(), 197 String.format("%s%s=%s&", action.getUrl().replaceFirst("<.*$", ""), 198 PLACEHOLDER_IS_LICENSED_USER, PLACEHOLDER_IS_LICENSED_USER_VALUE)); 199 }); 200 } 201 202 @Override 203 public boolean isEnabled() { 204 return !(extensionAppNames.isEmpty() || extensionActionURLs.isEmpty()); 205 } 206 207 @Override 208 public WOPIBlobInfo getWOPIBlobInfo(Blob blob) { 209 if (!isEnabled() || Helpers.isExternalBlobProvider(blob)) { 210 return null; 211 } 212 213 String extension = getExtension(blob); 214 String appName = extensionAppNames.get(extension); 215 Map<String, String> actionURLs = extensionActionURLs.get(extension); 216 return appName == null || actionURLs.isEmpty() ? null : new WOPIBlobInfo(appName, actionURLs.keySet()); 217 } 218 219 @Override 220 public String getActionURL(Blob blob, String action) { 221 String extension = getExtension(blob); 222 return extensionActionURLs.getOrDefault(extension, Collections.emptyMap()).get(action); 223 } 224 225 protected String getExtension(Blob blob) { 226 String filename = blob.getFilename(); 227 if (filename == null) { 228 return null; 229 } 230 231 String extension = FilenameUtils.getExtension(filename); 232 return StringUtils.isNotBlank(extension) ? extension : null; 233 } 234 235 @Override 236 public boolean verifyProofKey(String proofKeyHeader, String oldProofKeyHeader, String url, String accessToken, 237 String timestampHeader) { 238 if (StringUtils.isBlank(proofKeyHeader)) { 239 return true; // assume valid 240 } 241 242 long timestamp = Long.parseLong(timestampHeader); 243 if (!ProofKeyHelper.verifyTimestamp(timestamp)) { 244 return false; 245 } 246 247 byte[] expectedProofBytes = ProofKeyHelper.getExpectedProofBytes(url, accessToken, timestamp); 248 // follow flow from https://wopi.readthedocs.io/en/latest/scenarios/proofkeys.html#verifying-the-proof-keys 249 boolean res = ProofKeyHelper.verifyProofKey(proofKey, proofKeyHeader, expectedProofBytes); 250 if (!res && StringUtils.isNotBlank(oldProofKeyHeader)) { 251 res = ProofKeyHelper.verifyProofKey(proofKey, oldProofKeyHeader, expectedProofBytes); 252 if (!res) { 253 res = ProofKeyHelper.verifyProofKey(oldProofKey, proofKeyHeader, expectedProofBytes); 254 } 255 } 256 return res; 257 } 258 259 @Override 260 public boolean refreshDiscovery() { 261 byte[] discoveryBytes = fetchDiscovery(); 262 if (ArrayUtils.isEmpty(discoveryBytes)) { 263 return false; 264 } 265 log.debug("Successfully fetched WOPI dicovery"); 266 267 if (loadDiscovery(discoveryBytes)) { 268 storeDiscovery(discoveryBytes); 269 if (invalidator != null) { 270 invalidator.sendMessage(new WOPIDiscoveryInvalidation()); 271 } 272 return true; 273 } 274 return false; 275 } 276 277 protected byte[] fetchDiscovery() { 278 if (discoveryURL == null) { 279 log.warn("No WOPI discovery URL configured, cannot fetch discovery. Please configure the '{}' property.", 280 WOPI_DISCOVERY_URL_PROPERTY); 281 return ArrayUtils.EMPTY_BYTE_ARRAY; 282 } 283 284 log.debug("Fetching WOPI dicovery from discovery URL {}", discoveryURL); 285 HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); 286 HttpGet request = new HttpGet(discoveryURL); 287 try (CloseableHttpClient httpClient = httpClientBuilder.build(); 288 CloseableHttpResponse response = httpClient.execute(request); 289 InputStream is = response.getEntity().getContent()) { 290 return IOUtils.toByteArray(is); 291 } catch (IOException e) { 292 log.error("Error while fetching WOPI discovery: {}", e::getMessage); 293 log.debug(e, e); 294 return ArrayUtils.EMPTY_BYTE_ARRAY; 295 } 296 } 297 298 protected byte[] getDiscovery() { 299 return getKeyValueStore().get(WOPI_DISCOVERY_KEY); 300 } 301 302 protected void storeDiscovery(byte[] discoveryBytes) { 303 getKeyValueStore().put(WOPI_DISCOVERY_KEY, discoveryBytes); 304 } 305 306 protected KeyValueStore getKeyValueStore() { 307 return Framework.getService(KeyValueService.class).getKeyValueStore(WOPI_KEY_VALUE_STORE_NAME); 308 } 309 310 public static class WOPIDiscoveryInvalidation implements SerializableMessage { 311 312 private static final long serialVersionUID = 1L; 313 314 @Override 315 public void serialize(OutputStream out) throws IOException { 316 // nothing to write, sending the message itself is enough 317 } 318 } 319 320 public class WOPIDiscoveryInvalidator extends AbstractPubSubBroker<WOPIDiscoveryInvalidation> { 321 322 @Override 323 public WOPIDiscoveryInvalidation deserialize(InputStream in) throws IOException { 324 return new WOPIDiscoveryInvalidation(); 325 } 326 327 @Override 328 public void receivedMessage(WOPIDiscoveryInvalidation message) { 329 // nothing to read from the message, receiving the message itself is enough 330 byte[] discoveryBytes = getDiscovery(); 331 loadDiscovery(discoveryBytes); 332 } 333 } 334 335}