Adobe-Consulting-Services/acs-aem-commons

View on GitHub
bundle/src/main/java/com/adobe/acs/commons/wcm/impl/AemEnvironmentIndicatorFilter.java

Summary

Maintainability
A
0 mins
Test Coverage
/*
 * ACS AEM Commons
 *
 * Copyright (C) 2013 - 2023 Adobe
 *
 * Licensed 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
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * 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 com.adobe.acs.commons.wcm.impl;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Dictionary;
import java.util.Hashtable;
import java.util.Map;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.adobe.acs.commons.util.BufferedHttpServletResponse;
import com.adobe.acs.commons.util.BufferedServletOutput.ResponseWriteMethod;
import com.day.cq.commons.PathInfo;
import com.day.cq.wcm.api.WCMMode;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang.text.StrLookup;
import org.apache.commons.lang.text.StrSubstitutor;
import org.apache.commons.lang3.StringUtils;
import org.apache.felix.scr.annotations.*;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.commons.osgi.PropertiesUtil;
import org.apache.sling.engine.EngineConstants;
import org.apache.sling.xss.XSSAPI;
import org.osgi.framework.Constants;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.http.whiteboard.HttpWhiteboardConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * The Environment filter consists of 2 filters:
 * * the environment filter, which is registered directly to the HTTP whiteboard, and which can cover
 * also non-Sling applications (like CRXDE and the OSGI webconsole)
 * * a Sling filter, which is required for the filtering based on the WCM modes.
 * <p>
 * The environment indicator output is written by the "outer" filter, but its decision might be overwritten
 * by the Sling Filter. The status is stored as a request attribute.
 */


@Component(
        label = "ACS AEM Commons - AEM Environment Indicator",
        description = "Adds a visual cue to the AEM WebUI indicating which environment is being access "
                + "(localdev, dev, qa, staging)",
        metatype = true,
        policy = ConfigurationPolicy.REQUIRE
)
public class AemEnvironmentIndicatorFilter implements Filter {
    private static final Logger log = LoggerFactory.getLogger(AemEnvironmentIndicatorFilter.class);

    private static final String DIV_ID = "acs-commons-env-indicator";

    static final String INJECT_INDICATOR_PARAMETER = "AemEnvironmentIndicatorFilter.includeIndicator";
    static final String NO_EXTENSION_PLACEHOLDER = "<NONE>";

    private static final String BASE_DEFAULT_STYLE =
            ";background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3NpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDIxIDc5LjE1NDkxMSwgMjAxMy8xMC8yOS0xMTo0NzoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo5ZmViMDk1Ni00MTMwLTQ0NGMtYWM3Ny02MjU0NjY0OTczZWIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MDk4RTBGQkYzMjA5MTFFNDg5MDFGQzVCQkEyMjY0NDQiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MDk4RTBGQkUzMjA5MTFFNDg5MDFGQzVCQkEyMjY0NDQiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChNYWNpbnRvc2gpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6Mjc5NmRkZmItZDVlYi00N2RlLWI1NDMtNDgxNzU2ZjIwZDc1IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjlmZWIwOTU2LTQxMzAtNDQ0Yy1hYzc3LTYyNTQ2NjQ5NzNlYiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Ps64/vsAAAAkSURBVHjaYvz//z8DGjBmAAkiYWOwInQBZEFjZB0YAiAMEGAAVBk/wkPTSYQAAAAASUVORK5CYII=');"
                    + "border-bottom: 1px solid rgba(0, 0, 0, .25);"
                    + "box-sizing: border-box;"
                    + "-moz-box-sizing: border-box;"
                    + "-webkit-box-sizing: border-box;"
                    + "position: fixed;"
                    + "left: 0;"
                    + "top: 0;"
                    + "right: 0;"
                    + "height: 5px;"
                    + "z-index: 100000000000000;";

    private static final String TITLE_UPDATE_SCRIPT = "<script>(function() { var c = 0; t = '%s' + ' | ' + document.title, "
            + "i = setInterval(function() { if (document.title === t && c++ > 10) { clearInterval(i); } else { document.title = t; } }, 1500); "
            + "document.title = t; })();</script>\n";

    @Reference
    private XSSAPI xss;

    /* Property: Default Color */

    @Property(label = "Color",
            description = "The color of the indicator bar; takes any valid value"
                    + " for CSS's 'background-color' attribute."
                    + " This is only effective if no 'CSS Override' is provided or 'Always Include Color CSS' is set to true.",
            value = "")
    public static final String PROP_COLOR = "css-color";

    /* Property: CSS Override */

    @Property(label = "CSS Override",
            description = "Accepts any valid CSS to style the AEM indicator div. All CSS rules must only be "
                    + "scoped to #" + DIV_ID + " { .. }",
            value = "")
    public static final String PROP_CSS_OVERRIDE = "css-override";

    /* Property: Inner HTML */

    private String innerHTML = "";

    @Property(label = "Inner HTML",
            description = "Any additional HTML required; Will be injected into a div with"
                    + " id='" + DIV_ID + "'",
            value = "")
    public static final String PROP_INNER_HTML = "inner-html";


    /* Property: Browser Title Prefix */

    private static final String DEFAULT_TITLE_PREFIX = "";

    private String titlePrefix = DEFAULT_TITLE_PREFIX;

    /* Property: Always Include Base CSS */

    @Property(label = "Always Include Base CSS",
            description = "Always include the base CSS scoped to #" + DIV_ID + " { .. }",
            boolValue = false)
    public static final String PROP_ALWAYS_INCLUDE_BASE_CSS = "always-include-base-css";

    /* Property: Always Include Color CSS */

    @Property(label = "Always Include Color CSS",
            description = "Always include the color CSS scoped to #" + DIV_ID + " { .. }",
            boolValue = false)
    public static final String PROP_ALWAYS_INCLUDE_COLOR_CSS = "always-include-color-css";

    @Property(label = "Browser Title",
            description = "A prefix to add to the browser tab/window title; <THIS VALUE> | <ORIGINAL DOC TITLE>",
            value = DEFAULT_TITLE_PREFIX)
    public static final String PROP_TITLE_PREFIX = "browser-title-prefix";

    private static final String[] DEFAULT_EXCLUDED_WCMMODES = {"DISABLED"};
    @Property(label = "Excluded WCM modes",
            description = "Do not display the indicator when these WCM modes are active",
            cardinality = Integer.MAX_VALUE)
    public static final String PROP_EXCLUDED_WCMMODES = "excluded-wcm-modes";


    private static final String[] DEFAULT_ALLOWED_EXTENSIONS = {"html", "htm", "jsp", NO_EXTENSION_PLACEHOLDER};
    @Property(label = "Allowed URI extensions",
            description = "Only inject the environment indicator on URI that use these extensions. Use '" + NO_EXTENSION_PLACEHOLDER + "' to match on no extension.",
            cardinality = Integer.MAX_VALUE)
    public static final String PROP_ALLOWED_EXTENSIONS = "allowed-extensions";
    private String[] allowedExtensions;

    private String css = "";

    private ServiceRegistration filterRegistration;

    private ServiceRegistration innerFilterRegistration;

    @Override
    public void init(final FilterConfig filterConfig) throws ServletException {
        // no-op
    }

    @Override
    @SuppressWarnings("squid:S3776")
    public final void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
                               final FilterChain filterChain) throws IOException, ServletException {

        if (!(servletRequest instanceof HttpServletRequest)
                || !(servletResponse instanceof HttpServletResponse)) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }

        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;

        if (!this.accepts(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        try (BufferedHttpServletResponse capturedResponse =
                     new BufferedHttpServletResponse(response, new StringWriter(), null)) {

            request.setAttribute(INJECT_INDICATOR_PARAMETER, Boolean.TRUE);

            log.debug("Executing the rest of the filter chain");
            filterChain.doFilter(request, capturedResponse);
            log.debug("Executing the rest of the filter chain");

            if (StringUtils.contains(response.getContentType(), "html") && innerFilterAcceptsInjection(request)) {
                // Get contents
                final String contents = capturedResponse.getBufferedServletOutput()
                        .getWriteMethod() == ResponseWriteMethod.WRITER
                        ? capturedResponse.getBufferedServletOutput().getBufferedString()
                        : null;

                if (contents != null) {
                    final int bodyIndex = StringUtils.lastIndexOf(contents, "</body>");
                    if (bodyIndex != -1) {
                        // prevent the captured response from being given out a 2nd time via the implicit close()
                        capturedResponse.setFlushBufferOnClose(false);
                        final PrintWriter printWriter = response.getWriter();
                        printWriter.write(contents.substring(0, bodyIndex));

                        writeEnvironmentIndicator(css, innerHTML, titlePrefix, printWriter);

                        printWriter.write(contents.substring(bodyIndex));
                    }
                }
            }
        }
    }

    boolean innerFilterAcceptsInjection(HttpServletRequest request) {
        return request.getAttribute(INJECT_INDICATOR_PARAMETER).equals(Boolean.TRUE);
    }

    void writeEnvironmentIndicator(String css, String innerHTML, String titlePrefix,
                                   PrintWriter printWriter) {
        if (StringUtils.isNotBlank(css)) {
            printWriter.write("<style>" + css + " </style>");
            printWriter.write("<div id=\"" + DIV_ID + "\">" + innerHTML + "</div>");
        }

        if (StringUtils.isNotBlank(titlePrefix)) {
            printWriter.printf(TITLE_UPDATE_SCRIPT, titlePrefix);
        }
    }

    @Override
    public void destroy() {
        // no-op
    }

    @SuppressWarnings("squid:S3923")
    boolean accepts(final HttpServletRequest request) {
        if (isImproperlyConfigured(css, titlePrefix)) {
            // Only accept is properly configured
            log.warn(
                    "AEM Environment Indicator is not properly configured; If this feature is unwanted, "
                            + "remove the OSGi configuration and disable completely.");
            return false;
        } else if (isUnsupportedExtension(request.getRequestURI())) {
            log.debug("Request's extension does not match allowed extensions [ {} ]", request.getRequestURI());
            return false;
        } else if (isUnsupportedRequestMethod(request.getMethod())) {
            log.debug("Request was not a GET request [ {} ]", request.getRequestURI());
            return false;
        } else if (isXhr(request.getHeader("X-Requested-With"))) {
            log.debug("Request was an XHR [ {} ]", request.getRequestURI());
            return false;
        } else if (hasAemEditorReferrer(request.getHeader("Referer"), request.getRequestURI())) {
            log.debug("Request was for a page in an editor [ {} ]", request.getRequestURI());
            return false;
        }
        // Checking for WcmMode does not make sense, it is not available here
        log.debug("All checks pass, filter can execute [ {} ]", request.getRequestURI());
        return true;
    }

    protected boolean isUnsupportedExtension(String requestURI) {
        if (ArrayUtils.isEmpty(allowedExtensions)) {
            return false;
        }

        final PathInfo pathInfo = new PathInfo(requestURI);
        final String extension = pathInfo.getExtension();

        if (StringUtils.isBlank(extension)) {
            // Special case handle of blank extension
            return !ArrayUtils.contains(allowedExtensions, NO_EXTENSION_PLACEHOLDER);
        } else {
            // If extension is not blank, check to make sure it is allowed
            return !ArrayUtils.contains(allowedExtensions, extension);
        }
    }

    boolean isImproperlyConfigured(final String css, final String titlePrefix) {
        return StringUtils.isBlank(css) && StringUtils.isBlank(titlePrefix);
    }

    boolean isUnsupportedRequestMethod(final String requestMethod) {
        return !StringUtils.equalsIgnoreCase("get", requestMethod);
    }

    boolean isXhr(final String headerValue) {
        return StringUtils.equals(headerValue, "XMLHttpRequest");
    }

    boolean hasAemEditorReferrer(final String refererHeaderValue, final String requestUri) {
        String refererPath = refererHeaderValue;

        try {
            if (StringUtils.isNotBlank(refererHeaderValue)) {
                URI uri = new URI(refererHeaderValue);
                refererPath = uri.getPath();
            }
        } catch (URISyntaxException e) {
            log.info("Could not parse the HTTP Requests's referer header value [ {} ] as a URI. Using raw header value", refererHeaderValue);
        }

        return StringUtils.endsWith(refererHeaderValue, "/editor.html" + requestUri)
                || StringUtils.endsWith(refererHeaderValue, "/cf")
                || isEditExperienceFragmentVariation(refererHeaderValue, requestUri)
                // AEM as a Cloud Service
                || StringUtils.endsWith(refererPath, "/editor.html" + requestUri)
                || StringUtils.endsWith(refererPath, "/cf");
    }

    boolean isEditExperienceFragmentVariation(String headerValue, String requestUri) {
        return StringUtils.contains(headerValue, "/editor.html/content/experience-fragments/") && StringUtils.startsWith(requestUri, "/content/experience-fragments/");
    }

    @Activate
    @SuppressWarnings("squid:S1149")
    protected final void activate(ComponentContext ctx) {
        Dictionary<?, ?> config = ctx.getProperties();

        innerHTML = PropertiesUtil.toString(config.get(PROP_INNER_HTML), "");
        innerHTML = new StrSubstitutor(StrLookup.systemPropertiesLookup()).replace(innerHTML);
        String color = PropertiesUtil.toString(config.get(PROP_COLOR), "");
        String cssOverride = PropertiesUtil.toString(config.get(PROP_CSS_OVERRIDE), "");
        boolean alwaysIncludeBaseCss = PropertiesUtil.toBoolean(PROP_ALWAYS_INCLUDE_BASE_CSS, false);
        boolean alwaysIncludeColorCss = PropertiesUtil.toBoolean(PROP_ALWAYS_INCLUDE_COLOR_CSS, false);

        StringBuilder cssSb = new StringBuilder();

        if (shouldUseBaseCss(alwaysIncludeBaseCss, cssOverride, color)) {
            cssSb.append(createBaseCss());
        }

        if (shouldUseColorCss(alwaysIncludeColorCss, cssOverride, color)) {
            cssSb.append(createColorCss(color));
        }

        if (StringUtils.isNotBlank(cssOverride)) {
            cssSb.append(cssOverride);
        }

        css = cssSb.toString();

        titlePrefix = xss.encodeForJSString(
                PropertiesUtil.toString(config.get(PROP_TITLE_PREFIX), "").toString());

        String[] excludedWCMModes = PropertiesUtil.toStringArray(config.get(PROP_EXCLUDED_WCMMODES),
                DEFAULT_EXCLUDED_WCMMODES);

        allowedExtensions = PropertiesUtil.toStringArray(config.get(PROP_ALLOWED_EXTENSIONS),
                DEFAULT_ALLOWED_EXTENSIONS);

        if (StringUtils.isNotBlank(css) || StringUtils.isNotBlank(titlePrefix)) {
            Dictionary<String, String> filterProps = new Hashtable<String, String>();
            filterProps.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_FILTER_PATTERN, "/");
            filterProps.put(HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_SELECT,
                    "(" + HttpWhiteboardConstants.HTTP_WHITEBOARD_CONTEXT_NAME + "=*)");
            filterRegistration = ctx.getBundleContext().registerService(Filter.class.getName(),
                    this, filterProps);

            // Register the innerFilter so it is invoked after the WcmRequestFilter (Ranking = 2000)
            Dictionary<String, Object> innerFilterProps = new Hashtable<>();
            innerFilterProps.put(EngineConstants.SLING_FILTER_SCOPE, EngineConstants.FILTER_SCOPE_REQUEST);
            innerFilterProps.put(Constants.SERVICE_RANKING, 1000);
            Filter innerFilter = new InnerEnvironmentIndicatorFilter(excludedWCMModes);
            innerFilterRegistration = ctx.getBundleContext().registerService(Filter.class.getName(), innerFilter, innerFilterProps);
        }
    }

    String createBaseCss() {
        return "#" + DIV_ID + " { "
                + BASE_DEFAULT_STYLE
                + " }";
    }

    String createColorCss(final String providedColor) {
        return "#" + DIV_ID + " { "
                + "background-color:" + providedColor
                + "; }";
    }

    boolean shouldUseBaseCss(boolean alwaysInclude, String cssOverride, String color) {
        return alwaysInclude
                || StringUtils.isBlank(cssOverride) && StringUtils.isNotBlank(color);
    }

    boolean shouldUseColorCss(boolean alwaysInclude, String cssOverride, String color) {
        return alwaysInclude
                || StringUtils.isBlank(cssOverride) && StringUtils.isNotBlank(color);
    }

    @Deactivate
    protected final void deactivate(final Map<String, String> config) {
        if (filterRegistration != null) {
            filterRegistration.unregister();
            filterRegistration = null;
        }
        if (innerFilterRegistration != null) {
            innerFilterRegistration.unregister();
            innerFilterRegistration = null;
        }

        // Reset CSS variable
        css = "";
    }

    WCMMode getWcmMode(HttpServletRequest request) {
        return WCMMode.fromRequest(request);
    }

    /*
     * Used for testing
     */
    String getCss() {
        return css;
    }

    String getTitlePrefix() {
        return titlePrefix;
    }

    protected static class InnerEnvironmentIndicatorFilter implements Filter {

        String[] excludedWcmModes;

        public InnerEnvironmentIndicatorFilter(String[] excludedWcmModes) {
            this.excludedWcmModes = excludedWcmModes;
        }

        @Override
        public void init(FilterConfig filterConfig) throws ServletException {
            // ignore
        }

        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {

            SlingHttpServletRequest req = (SlingHttpServletRequest) request;

            WCMMode mode = WCMMode.fromRequest(request);
            if (isDisallowedWcmMode(mode, excludedWcmModes)) {
                request.setAttribute(INJECT_INDICATOR_PARAMETER, Boolean.FALSE);
                String msg = String.format(
                        "reject inclusion of environment indicator, found wcmmode '%s' in exclusion list %s",
                        mode.name(), ArrayUtils.toString(excludedWcmModes));
                req.getRequestProgressTracker().log(msg);
            }
            chain.doFilter(request, response);
        }

        boolean isDisallowedWcmMode(WCMMode currentMode, String[] excludedWcmModes) {
            return currentMode == null || StringUtils.equalsAnyIgnoreCase(currentMode.name(), excludedWcmModes);
        }

        @Override
        public void destroy() {
            // ignore
        }

    }

}