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