/** * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file * distributed with this work for additional information regarding copyright ownership. Apereo * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use * this file except in compliance with the License. You may obtain a copy of the License at the * following location: * * <p>http://www.apache.org/licenses/LICENSE-2.0 * * <p>Unless required by applicable law or agreed to in writing, software distributed under the * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either * express or implied. See the License for the specific language governing permissions and * limitations under the License. */ package org.apereo.portal.soffit.connector; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import javax.annotation.PostConstruct; import javax.portlet.PortletPreferences; import javax.portlet.RenderRequest; import javax.portlet.RenderResponse; import net.sf.ehcache.Cache; import net.sf.ehcache.Element; import org.apache.commons.io.IOUtils; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.util.EntityUtils; import org.apereo.portal.soffit.Headers; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.portlet.bind.annotation.RenderMapping; /** @since 5.0 */ @Controller @RequestMapping(value = {"VIEW", "EDIT", "HELP"}) public class SoffitConnectorController implements ApplicationContextAware { /** Preferences that begin with this String will not be shared with the remote soffit. */ public static final String CONNECTOR_PREFERENCE_PREFIX = SoffitConnectorController.class.getName(); private static final String SERVICE_URL_PREFERENCE = CONNECTOR_PREFERENCE_PREFIX + ".serviceUrl"; private static final int TIMEOUT_SECONDS = 10; @Value( "${org.apereo.portlet.soffit.connector.SoffitConnectorController.maxConnectionsPerRoute:20}") private Integer maxConnectionsPerRoute; @Value( "${org.apereo.portlet.soffit.connector.SoffitConnectorController.maxConnectionsTotal:50}") private Integer maxConnectionsTotal; private final RequestConfig requestConfig = RequestConfig.custom() .setSocketTimeout(TIMEOUT_SECONDS * 1000) .setConnectTimeout(TIMEOUT_SECONDS * 1000) .build(); private final HttpClientBuilder httpClientBuilder = HttpClientBuilder.create() .setDefaultRequestConfig(requestConfig) .setConnectionManagerShared( true); // Prevents the client from shutting down the pool private ApplicationContext applicationContext; private List<IHeaderProvider> headerProviders; @Autowired @Qualifier( value = "org.apereo.portlet.soffit.connector.SoffitConnectorController.RESPONSE_CACHE" ) private Cache responseCache; private final Logger logger = LoggerFactory.getLogger(getClass()); @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; } @PostConstruct public void init() { PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(); poolingHttpClientConnectionManager.setDefaultMaxPerRoute(maxConnectionsPerRoute); poolingHttpClientConnectionManager.setMaxTotal(maxConnectionsTotal); httpClientBuilder.setConnectionManager(poolingHttpClientConnectionManager); final Map<String, IHeaderProvider> beans = BeanFactoryUtils.beansOfTypeIncludingAncestors( applicationContext, IHeaderProvider.class); final List<IHeaderProvider> values = new ArrayList<>(beans.values()); headerProviders = Collections.unmodifiableList(values); } @RenderMapping public void invokeService(final RenderRequest req, final RenderResponse res) { final PortletPreferences prefs = req.getPreferences(); final String serviceUrl = prefs.getValue(SERVICE_URL_PREFERENCE, null); if (serviceUrl == null) { throw new IllegalStateException( "Missing portlet prefernce value for " + SERVICE_URL_PREFERENCE); } // First look in cache for an existing response that applies to this request ResponseWrapper responseValue = fetchContentFromCacheIfAvailable(req, serviceUrl); if (responseValue != null) { logger.debug("Response value obtained from cache for serviceUrl '{}'", serviceUrl); } else { logger.debug("No applicable response in cache; invoking serviceUrl '{}'", serviceUrl); final HttpGet getMethod = new HttpGet(serviceUrl); try (final CloseableHttpClient httpClient = httpClientBuilder.build()) { // Send the data model as encrypted JWT HTTP headers for (IHeaderProvider headerProvider : headerProviders) { final Header header = headerProvider.createHeader(req, res); getMethod.addHeader(header); } // Send the request final HttpResponse httpResponse = httpClient.execute(getMethod); try { final int statusCode = httpResponse.getStatusLine().getStatusCode(); logger.debug("HTTP response code for url '{}' was '{}'", serviceUrl, statusCode); if (statusCode == HttpStatus.SC_OK) { responseValue = extractResponseAndCacheIfAppropriate(httpResponse, req, serviceUrl); } else { logger.error( "Failed to get content from remote service '{}'; HttpStatus={}", serviceUrl, statusCode); res.getWriter() .write("FAILED! statusCode=" + statusCode); // TODO: Better message } } finally { if (null != httpResponse) { // Ensures that the entity content is fully consumed and the content stream, if exists, is closed. EntityUtils.consumeQuietly(httpResponse.getEntity()); } } } catch (IOException e) { logger.error("Failed to invoke serviceUrl '{}'", serviceUrl, e); } } if (responseValue != null) { // Whether by cache or by fresh HTTP request, we have a response we can show... try { res.getPortletOutputStream().write(responseValue.getBytes()); } catch (IOException e) { logger.error("Failed to write the response for serviceUrl '{}'", serviceUrl, e); } } } /* * Implementation */ private ResponseWrapper fetchContentFromCacheIfAvailable( final RenderRequest req, final String serviceUrl) { ResponseWrapper rslt = null; // default final List<CacheTuple> cacheKeysToTry = new ArrayList<>(); // Don't use private-scope caching for anonymous users if (req.getRemoteUser() != null) { cacheKeysToTry.add( // Private-scope cache key new CacheTuple( serviceUrl, req.getPortletMode().toString(), req.getWindowState().toString(), req.getRemoteUser())); } cacheKeysToTry.add( // Public-scope cache key new CacheTuple( serviceUrl, req.getPortletMode().toString(), req.getWindowState().toString())); for (CacheTuple key : cacheKeysToTry) { final Element cacheElement = this.responseCache.get(key); if (cacheElement != null) { rslt = (ResponseWrapper) cacheElement.getObjectValue(); break; } } return rslt; } private ResponseWrapper extractResponseAndCacheIfAppropriate( final HttpResponse httpResponse, final RenderRequest req, final String serviceUrl) { // Extract final HttpEntity entity = httpResponse.getEntity(); ResponseWrapper rslt; try { rslt = new ResponseWrapper(IOUtils.toByteArray(entity.getContent())); } catch (UnsupportedOperationException | IOException e) { throw new RuntimeException("Failed to read the response", e); } // Cache the response if indicated by the remote service final Header cacheControlHeader = httpResponse.getFirstHeader(Headers.CACHE_CONTROL.getName()); if (cacheControlHeader != null) { final String cacheControlValue = cacheControlHeader.getValue(); logger.debug( "Soffit with serviceUrl='{}' specified cache-control header value='{}'", serviceUrl, cacheControlValue); if (cacheControlHeader != null) { switch (cacheControlValue) { case Headers.CACHE_CONTROL_NOCACHE: /* * This value means we can use validation caching based on * Last-Modified or ETag. Those things aren't implemented * yet, so fall through to the handling for 'no-store'. */ case Headers.CACHE_CONTROL_NOSTORE: /* * The value 'no-store' is the default. */ logger.debug( "Not caching response due to CacheControl directive of '{}'", cacheControlValue); break; default: /* * Looks like we're using the expiration cache feature. */ CacheTuple cacheTuple = null; // TODO: Need to find a polished utility that parses a cache-control header, or write one final String[] tokens = cacheControlValue.split(","); // At present, we expect all valid values to be in the form '[public|private], max-age=300' if (tokens.length == 2) { final String maxAge = tokens[1].trim().substring("max-age=".length()); int timeToLive = Integer.parseInt(maxAge); if ("private".equals(tokens[0].trim())) { cacheTuple = new CacheTuple( serviceUrl, req.getPortletMode().toString(), req.getWindowState().toString(), req.getRemoteUser()); } else if ("public".equals(tokens[0].trim())) { cacheTuple = new CacheTuple( serviceUrl, req.getPortletMode().toString(), req.getWindowState().toString()); } logger.debug( "Produced cacheTuple='{}' for cacheControlValue='{}'", cacheTuple, cacheControlValue); if (cacheTuple != null) { final Element element = new Element(cacheTuple, rslt); element.setTimeToLive(timeToLive); responseCache.put(element); } else { logger.warn( "The remote soffit specified cacheControlValue='{}', " + "but SoffitConnectorController failed to generate a cacheTuple"); } } break; } } } return rslt; } /* * Nested Types */ private static final class CacheTuple { private final String serviceUrl; private final String mode; private final String windowState; private final String username; private final boolean publicScope; /** Creates a CacheTuple for a public-scope soffit response. */ public CacheTuple(String serviceUrl, String mode, String windowState) { this.serviceUrl = serviceUrl; this.mode = mode; this.windowState = windowState; this.username = null; this.publicScope = true; } /** Creates a CacheTuple for a private-scope soffit response. */ public CacheTuple(String serviceUrl, String mode, String windowState, String username) { this.serviceUrl = serviceUrl; this.mode = mode; this.windowState = windowState; this.username = username; this.publicScope = false; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((mode == null) ? 0 : mode.hashCode()); result = prime * result + (publicScope ? 1231 : 1237); result = prime * result + ((serviceUrl == null) ? 0 : serviceUrl.hashCode()); result = prime * result + ((username == null) ? 0 : username.hashCode()); result = prime * result + ((windowState == null) ? 0 : windowState.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; CacheTuple other = (CacheTuple) obj; if (mode == null) { if (other.mode != null) return false; } else if (!mode.equals(other.mode)) return false; if (publicScope != other.publicScope) return false; if (serviceUrl == null) { if (other.serviceUrl != null) return false; } else if (!serviceUrl.equals(other.serviceUrl)) return false; if (username == null) { if (other.username != null) return false; } else if (!username.equals(other.username)) return false; if (windowState == null) { if (other.windowState != null) return false; } else if (!windowState.equals(other.windowState)) return false; return true; } @Override public String toString() { return "CacheTuple [serviceUrl=" + serviceUrl + ", mode=" + mode + ", windowState=" + windowState + ", username=" + username + ", publicScope=" + publicScope + "]"; } } public static final class ResponseWrapper { private final byte[] bytes; public ResponseWrapper(byte[] bytes) { this.bytes = bytes; } public byte[] getBytes() { return bytes; } } }