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_URL_PROPERTY; 024import static org.nuxeo.wopi.Constants.WOPI_KEY_VALUE_STORE_NAME; 025 026import java.io.IOException; 027import java.io.InputStream; 028import java.io.Serializable; 029import java.security.PublicKey; 030import java.util.Arrays; 031import java.util.Collections; 032import java.util.HashMap; 033import java.util.List; 034import java.util.Map; 035 036import org.apache.commons.io.FilenameUtils; 037import org.apache.commons.io.IOUtils; 038import org.apache.commons.lang3.StringUtils; 039import org.apache.http.client.methods.CloseableHttpResponse; 040import org.apache.http.client.methods.HttpGet; 041import org.apache.http.impl.client.CloseableHttpClient; 042import org.apache.http.impl.client.HttpClientBuilder; 043import org.apache.logging.log4j.LogManager; 044import org.apache.logging.log4j.Logger; 045import org.nuxeo.ecm.core.api.Blob; 046import org.nuxeo.runtime.api.Framework; 047import org.nuxeo.runtime.kv.KeyValueService; 048import org.nuxeo.runtime.kv.KeyValueStore; 049import org.nuxeo.runtime.model.ComponentContext; 050import org.nuxeo.runtime.model.DefaultComponent; 051import org.nuxeo.runtime.services.config.ConfigurationService; 052 053/** 054 * @since 10.3 055 */ 056public class WOPIServiceImpl extends DefaultComponent implements WOPIService { 057 058 private static final Logger log = LogManager.getLogger(WOPIServiceImpl.class); 059 060 public static final String PLACEHOLDER_IS_LICENSED_USER = "IsLicensedUser"; 061 062 public static final String PLACEHOLDER_IS_LICENSED_USER_VALUE = "1"; 063 064 public static final String WOPI_PROPERTY_NAMESPACE = "org.nuxeo.wopi"; 065 066 public static final String SUPPORTED_APP_NAMES_PROPERTY_KEY = "supportedAppNames"; 067 068 // extension => app name 069 protected Map<String, String> extensionAppNames = new HashMap<>(); 070 071 // extension => wopi action => wopi action url 072 protected Map<String, Map<String, String>> extensionActionURLs = new HashMap<>(); 073 074 protected PublicKey proofKey; 075 076 protected PublicKey oldProofKey; 077 078 protected String discoveryURL; 079 080 @Override 081 public void start(ComponentContext context) { 082 String wopiDiscoveryURL = Framework.getProperty(WOPI_DISCOVERY_URL_PROPERTY); 083 if (wopiDiscoveryURL == null) { 084 log.warn("No WOPI discovery URL configured: WOPI disabled"); 085 log.warn("WOPI can be enabled by configuring the '{}' property", WOPI_DISCOVERY_URL_PROPERTY); 086 return; 087 } 088 089 discoveryURL = wopiDiscoveryURL; 090 loadDiscovery(); 091 } 092 093 protected void loadDiscovery() { 094 KeyValueService service = Framework.getService(KeyValueService.class); 095 KeyValueStore keyValueStore = service.getKeyValueStore(WOPI_KEY_VALUE_STORE_NAME); 096 byte[] discoveryBytes = keyValueStore.get(WOPI_DISCOVERY_KEY); 097 if (discoveryBytes == null) { 098 discoveryBytes = fetchDiscovery(); 099 } 100 101 if (discoveryBytes == null) { 102 log.error("Cannot fetch WOPI discovery: WOPI disabled"); 103 return; 104 } 105 106 keyValueStore.put(WOPI_DISCOVERY_KEY, discoveryBytes); 107 loadDiscovery(discoveryBytes); 108 } 109 110 protected void loadDiscovery(byte[] discoveryBytes) { 111 WOPIDiscovery discovery = WOPIDiscovery.read(discoveryBytes); 112 List<String> supportedAppNames = getSupportedAppNames(); 113 discovery.getNetZone() 114 .getApps() 115 .stream() 116 .filter(app -> supportedAppNames.contains(app.getName())) 117 .forEach(this::registerApp); 118 119 WOPIDiscovery.ProofKey pk = discovery.getProofKey(); 120 proofKey = ProofKeyHelper.getPublicKey(pk.getModulus(), pk.getExponent()); 121 oldProofKey = ProofKeyHelper.getPublicKey(pk.getOldModulus(), pk.getOldExponent()); 122 log.debug("Registered proof key: {}", proofKey); 123 log.debug("Registered old proof key: {}", oldProofKey); 124 } 125 126 protected byte[] fetchDiscovery() { 127 HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); 128 HttpGet request = new HttpGet(discoveryURL); 129 try (CloseableHttpClient httpClient = httpClientBuilder.build(); 130 CloseableHttpResponse response = httpClient.execute(request); 131 InputStream is = response.getEntity().getContent()) { 132 return IOUtils.toByteArray(is); 133 } catch (IOException e) { 134 log.error("Error while fetching WOPI discovery: {}", e::getMessage); 135 log.debug(e, e); 136 } 137 return null; 138 } 139 140 protected List<String> getSupportedAppNames() { 141 Serializable supportedAppNames = Framework.getService(ConfigurationService.class) 142 .getProperties(WOPI_PROPERTY_NAMESPACE) 143 .get(SUPPORTED_APP_NAMES_PROPERTY_KEY); 144 if (!(supportedAppNames instanceof String[])) { 145 return Collections.emptyList(); 146 } 147 return Arrays.asList((String[]) supportedAppNames); 148 } 149 150 protected void registerApp(WOPIDiscovery.App app) { 151 app.getActions().forEach(action -> { 152 extensionAppNames.put(action.getExt(), app.getName()); 153 extensionActionURLs.computeIfAbsent(action.getExt(), k -> new HashMap<>()) 154 .put(action.getName(), 155 String.format("%s%s=%s&", action.getUrl().replaceFirst("<.*$", ""), 156 PLACEHOLDER_IS_LICENSED_USER, PLACEHOLDER_IS_LICENSED_USER_VALUE)); 157 }); 158 } 159 160 @Override 161 public boolean isEnabled() { 162 return !(extensionAppNames.isEmpty() || extensionActionURLs.isEmpty()); 163 } 164 165 @Override 166 public WOPIBlobInfo getWOPIBlobInfo(Blob blob) { 167 if (!isEnabled() || Helpers.isExternalBlobProvider(blob)) { 168 return null; 169 } 170 171 String extension = getExtension(blob); 172 String appName = extensionAppNames.get(extension); 173 Map<String, String> actionURLs = extensionActionURLs.get(extension); 174 return appName == null || actionURLs.isEmpty() ? null : new WOPIBlobInfo(appName, actionURLs.keySet()); 175 } 176 177 @Override 178 public String getActionURL(Blob blob, String action) { 179 String extension = getExtension(blob); 180 return extensionActionURLs.getOrDefault(extension, Collections.emptyMap()).get(action); 181 } 182 183 protected String getExtension(Blob blob) { 184 String filename = blob.getFilename(); 185 if (filename == null) { 186 return null; 187 } 188 189 String extension = FilenameUtils.getExtension(filename); 190 return StringUtils.isNotBlank(extension) ? extension : null; 191 } 192 193 @Override 194 public boolean verifyProofKey(String proofKeyHeader, String oldProofKeyHeader, String url, String accessToken, 195 String timestampHeader) { 196 if (StringUtils.isBlank(proofKeyHeader)) { 197 return true; // assume valid 198 } 199 200 long timestamp = Long.parseLong(timestampHeader); 201 if (!ProofKeyHelper.verifyTimestamp(timestamp)) { 202 return false; 203 } 204 205 byte[] expectedProofBytes = ProofKeyHelper.getExpectedProofBytes(url, accessToken, timestamp); 206 // follow flow from https://wopi.readthedocs.io/en/latest/scenarios/proofkeys.html#verifying-the-proof-keys 207 boolean res = ProofKeyHelper.verifyProofKey(proofKey, proofKeyHeader, expectedProofBytes); 208 if (!res && StringUtils.isNotBlank(oldProofKeyHeader)) { 209 res = ProofKeyHelper.verifyProofKey(proofKey, oldProofKeyHeader, expectedProofBytes); 210 if (!res) { 211 res = ProofKeyHelper.verifyProofKey(oldProofKey, proofKeyHeader, expectedProofBytes); 212 } 213 } 214 return res; 215 } 216}