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}