/** * 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.renderer; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; import org.apereo.portal.soffit.Headers; import org.apereo.portal.soffit.model.v1_0.Bearer; import org.apereo.portal.soffit.model.v1_0.Definition; import org.apereo.portal.soffit.model.v1_0.PortalRequest; import org.apereo.portal.soffit.model.v1_0.PortalRequest.Attributes; import org.apereo.portal.soffit.model.v1_0.Preferences; import org.apereo.portal.soffit.service.BearerService; import org.apereo.portal.soffit.service.DefinitionService; import org.apereo.portal.soffit.service.PortalRequestService; import org.apereo.portal.soffit.service.PreferencesService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.env.Environment; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.servlet.ModelAndView; /** * <code>Controller</code> bean for remote soffits. This class is provided as a convenience. * * @since 5.0 */ @Controller @RequestMapping("/soffit") public class SoffitRendererController { /** * The default value for the <code>Cache-Control</code> header is "no-store," which indicates * the response should never be cached. Currently, this header value will be sent if the Soffit * does not specify a value for <strong>both</strong> scope or max-age. */ public static final String CACHE_CONTROL_NOSTORE = "no-store"; /** * Indicates the response may be cached with validation caching based on Last-Modified or ETag. * These features are not currently implemented. */ public static final String CACHE_CONTROL_NOCACHE = "no-cache"; /** Prefix for all custom properties. */ public static final String PROPERTY_PREFIX = "soffit."; /** Used to create a property key specific to the soffit for cache scope. */ public static final String CACHE_SCOPE_PROPERTY_FORMAT = PROPERTY_PREFIX + "%s.cache.scope"; /** Used to create a property key specific to the soffit for cache max-age. */ public static final String CACHE_MAXAGE_PROPERTY_FORMAT = PROPERTY_PREFIX + "%s.cache.max-age"; private static final String PORTAL_REQUEST_MODEL_NAME = "portalRequest"; private static final String BEARER_MODEL_NAME = "bearer"; private static final String PREFERENCES_MODEL_NAME = "preferences"; private static final String DEFINITION_MODEL_NAME = "definition"; private static final String DEFAULT_MODE = "view"; private static final String DEFAULT_WINDOW_STATE = "normal"; @Autowired private Environment environment; @Autowired private PortalRequestService portalRequestService; @Autowired private BearerService bearerService; @Autowired private PreferencesService preferencesService; @Autowired private DefinitionService definitionService; @Autowired private ModelAttributeService modelAttributeService; @Value("${soffit.renderer.viewsLocation:/WEB-INF/soffit/}") private String viewsLocation; private final Map<ViewTuple, String> availableViews = new HashMap<>(); protected final Logger logger = LoggerFactory.getLogger(getClass()); @RequestMapping(value = "/{module}", method = RequestMethod.GET) public ModelAndView render( final HttpServletRequest req, final HttpServletResponse res, final @PathVariable String module) { logger.debug("Rendering for request URI '{}'", req.getRequestURI()); // Soffit Object Model final PortalRequest portalRequest = getPortalRequest(req); final Bearer bearer = getBearer(req); final Preferences preferences = getPreferences(req); final Definition definition = getDefinition(req); // Select a view final String viewName = selectView(req, module, portalRequest); final Map<String, Object> model = modelAttributeService.gatherModelAttributes( viewName, req, res, portalRequest, bearer, preferences, definition); model.put(PORTAL_REQUEST_MODEL_NAME, portalRequest); model.put(BEARER_MODEL_NAME, bearer); model.put(PREFERENCES_MODEL_NAME, preferences); model.put(DEFINITION_MODEL_NAME, definition); // Set up cache headers appropriately configureCacheHeaders(res, module); return new ModelAndView(viewName, model); } /* * Implementation */ private PortalRequest getPortalRequest(final HttpServletRequest req) { final String portalRequestToken = req.getHeader(Headers.PORTAL_REQUEST.getName()); return portalRequestService.parsePortalRequest(portalRequestToken); } private Bearer getBearer(final HttpServletRequest req) { final String authorizationHeader = req.getHeader(Headers.AUTHORIZATION.getName()); final String bearerToken = authorizationHeader.substring(Headers.BEARER_TOKEN_PREFIX.length()); return bearerService.parseBearerToken(bearerToken); } private Preferences getPreferences(final HttpServletRequest req) { final String preferencesToken = req.getHeader(Headers.PREFERECES.getName()); return preferencesService.parsePreferences(preferencesToken); } private Definition getDefinition(final HttpServletRequest req) { final String definitionToken = req.getHeader(Headers.DEFINITION.getName()); return definitionService.parseDefinition(definitionToken); } private String selectView( final HttpServletRequest req, final String module, final PortalRequest portalRequest) { final StringBuilder modulePathBuilder = new StringBuilder().append(viewsLocation); if (!viewsLocation.endsWith("/")) { // Final slash in the configs is optional modulePathBuilder.append("/"); } modulePathBuilder.append(module).append("/"); final String modulePath = modulePathBuilder.toString(); logger.debug("Calculated modulePath of '{}'", modulePath); @SuppressWarnings("unchecked") final Set<String> moduleResources = req.getSession().getServletContext().getResourcePaths(modulePath); // Need to make a selection based on 3 things: module (above), mode, & windowState final Map<String, List<String>> requestAttributes = portalRequest.getAttributes(); final String modeLowercase = !requestAttributes.get(Attributes.MODE.getName()).isEmpty() ? requestAttributes.get(Attributes.MODE.getName()).get(0).toLowerCase() : DEFAULT_MODE; final String windowStateLowercase = !requestAttributes.get(Attributes.WINDOW_STATE.getName()).isEmpty() ? requestAttributes .get(Attributes.WINDOW_STATE.getName()) .get(0) .toLowerCase() : DEFAULT_WINDOW_STATE; final ViewTuple viewTuple = new ViewTuple(modulePath, modeLowercase, windowStateLowercase); String rslt = availableViews.get(viewTuple); if (rslt == null) { /* * This circumstance means that we haven't looked (yet); * check for a file named to match all 3. */ final String pathBasedOnModeAndState = getCompletePathforParts(modulePath, modeLowercase, windowStateLowercase); if (moduleResources.contains(pathBasedOnModeAndState)) { // We have a winner! availableViews.put(viewTuple, pathBasedOnModeAndState); rslt = pathBasedOnModeAndState; } else { // Widen the search (within this module) based on Mode only final String pathBasedOnModeOnly = getCompletePathforParts(modulePath, modeLowercase); if (moduleResources.contains(pathBasedOnModeOnly)) { // We still need to store the choice so we're not constantly looking availableViews.put(viewTuple, pathBasedOnModeOnly); rslt = pathBasedOnModeOnly; } else { throw new IllegalStateException( "Unable to select a view for Mode=" + modeLowercase + " and WindowState=" + windowStateLowercase); } } } logger.info( "Selected viewName='{}' for Mode='{}' and WindowState='{}'", rslt, modeLowercase, windowStateLowercase); return rslt; } private String getCompletePathforParts(final String... parts) { StringBuilder rslt = new StringBuilder(); for (String part : parts) { rslt.append(part); if (!part.endsWith("/")) { // First part will be a directory rslt.append("."); } } rslt.append("jsp"); // TODO: support more options logger.debug("Calculated path '{}' for parts={}", rslt, parts); return rslt.toString(); } private void configureCacheHeaders(final HttpServletResponse res, final String module) { final String cacheScopeProperty = String.format(CACHE_SCOPE_PROPERTY_FORMAT, module); final String cacheScopeValue = environment.getProperty(cacheScopeProperty); logger.debug( "Selecting cacheScopeValue='{}' for property '{}'", cacheScopeValue, cacheScopeProperty); final String cacheMaxAgeProperty = String.format(CACHE_MAXAGE_PROPERTY_FORMAT, module); final Integer cacheMaxAgeValueSeconds = environment.getProperty(cacheMaxAgeProperty, Integer.class); logger.debug( "Selecting cacheMaxAgeValueSeconds='{}' for property '{}'", cacheMaxAgeValueSeconds, cacheMaxAgeProperty); // Both must be specified, else we just use the default... final String cacheControl = (StringUtils.isNotEmpty(cacheScopeValue) && cacheMaxAgeValueSeconds != null) ? cacheScopeValue + ", max-age=" + cacheMaxAgeValueSeconds : CACHE_CONTROL_NOSTORE; logger.debug("Setting cache-control='{}' for module '{}'", cacheControl, module); // TODO: support validation caching res.setHeader(Headers.CACHE_CONTROL.getName(), cacheControl); } /* * Nested Types */ private static final class ViewTuple { private final String moduleName; private final String mode; private final String windowState; public ViewTuple(String moduleName, String mode, String windowState) { this.moduleName = moduleName; this.mode = mode; this.windowState = windowState; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((mode == null) ? 0 : mode.hashCode()); result = prime * result + ((moduleName == null) ? 0 : moduleName.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; ViewTuple other = (ViewTuple) obj; if (mode == null) { if (other.mode != null) return false; } else if (!mode.equals(other.mode)) return false; if (moduleName == null) { if (other.moduleName != null) return false; } else if (!moduleName.equals(other.moduleName)) 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 "ViewTuple [moduleName=" + moduleName + ", mode=" + mode + ", windowState=" + windowState + "]"; } } }