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}