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}