ServletUrlRenderer.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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
 *
 *  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 org.apache.struts2.components;

import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.config.entities.ActionConfig;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.util.ValueStack;
import jakarta.servlet.RequestDispatcher;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.StrutsException;
import org.apache.struts2.dispatcher.mapper.ActionMapper;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
import org.apache.struts2.url.QueryStringParser;
import org.apache.struts2.views.util.UrlHelper;

import java.io.IOException;
import java.io.Writer;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Implementation of the {@link UrlRenderer} interface that creates URLs suitable in a servlet environment.
 */
public class ServletUrlRenderer implements UrlRenderer {
    /**
     * Provide a logging instance.
     */
    private static final Logger LOG = LogManager.getLogger(ServletUrlRenderer.class);

    private ActionMapper actionMapper;
    private UrlHelper urlHelper;
    private QueryStringParser queryStringParser;

    @Override
    @Inject
    public void setActionMapper(ActionMapper mapper) {
        this.actionMapper = mapper;
    }

    @Inject
    public void setUrlHelper(UrlHelper urlHelper) {
        this.urlHelper = urlHelper;
    }

    @Inject
    public void setQueryStringParser(QueryStringParser queryStringParser) {
        this.queryStringParser = queryStringParser;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void renderUrl(Writer writer, UrlProvider urlComponent) {
        String scheme = urlComponent.getHttpServletRequest().getScheme();

        if (urlComponent.getScheme() != null) {
            ValueStack vs = ActionContext.getContext().getValueStack();
            scheme = vs.findString(urlComponent.getScheme());
            if (scheme == null) {
                scheme = urlComponent.getScheme();
            }
        }

        String result;
        ActionInvocation ai = ActionContext.getContext().getActionInvocation();
        if (urlComponent.getValue() == null && urlComponent.getAction() != null) {
            result = urlComponent.determineActionURL(urlComponent.getAction(), urlComponent.getNamespace(), urlComponent.getMethod(), urlComponent.getHttpServletRequest(), urlComponent.getHttpServletResponse(), urlComponent.getParameters(), scheme, urlComponent.isIncludeContext(), urlComponent.isEncode(), urlComponent.isForceAddSchemeHostAndPort(), urlComponent.isEscapeAmp());
        } else if (urlComponent.getValue() == null && urlComponent.getAction() == null && ai != null) {
            // both are null, we will default to the current action

            final String action = ai.getProxy().getActionName();
            final String namespace = ai.getProxy().getNamespace();
            final String method = urlComponent.getMethod() != null || !ai.getProxy().isMethodSpecified() ? urlComponent.getMethod() : ai.getProxy().getMethod();
            result = urlComponent.determineActionURL(action, namespace, method, urlComponent.getHttpServletRequest(), urlComponent.getHttpServletResponse(), urlComponent.getParameters(), scheme, urlComponent.isIncludeContext(), urlComponent.isEncode(), urlComponent.isForceAddSchemeHostAndPort(), urlComponent.isEscapeAmp());
        } else {
            String _value = urlComponent.getValue();

            // We don't include the request parameters cause they would have been
            // prioritised before this [in start(Writer) method]
            if (_value != null && _value.indexOf('?') > 0) {
                _value = _value.substring(0, _value.indexOf('?'));
            }
            result = urlHelper.buildUrl(_value, urlComponent.getHttpServletRequest(), urlComponent.getHttpServletResponse(), urlComponent.getParameters(), scheme, urlComponent.isIncludeContext(), urlComponent.isEncode(), urlComponent.isForceAddSchemeHostAndPort(), urlComponent.isEscapeAmp());
        }
        if (StringUtils.isNotEmpty(urlComponent.getAnchor())) {
            String anchor = urlComponent.findString(urlComponent.getAnchor());
            result += '#' + anchor;
        }

        if (urlComponent.isPutInContext()) {
            String var = urlComponent.getVar();
            if (StringUtils.isNotEmpty(var)) {
                urlComponent.putInContext(result);

                // Note: Old comments stated that var was placed in the page scope, but interactive checks with EL on JSPs prove otherwise.
                // Add the var attribute to the request scope as well.
                urlComponent.getHttpServletRequest().setAttribute(var, result);
            } else {
                try {
                    writer.write(result);
                } catch (IOException e) {
                    throw new StrutsException("IOError: " + e.getMessage(), e);
                }
            }
        } else {
            try {
                writer.write(result);
            } catch (IOException e) {
                throw new StrutsException("IOError: " + e.getMessage(), e);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void renderFormUrl(Form formComponent) {
        String namespace = formComponent.determineNamespace(formComponent.namespace, formComponent.getStack(), formComponent.request);
        String action;

        ValueStack vs = ActionContext.getContext().getValueStack();
        String scheme = vs.findString("scheme");

        if (formComponent.action != null) {
            action = formComponent.findString(formComponent.action);
        } else {
            // no action supplied? ok, then default to the current request
            // (action or general URL)
            ActionInvocation ai = formComponent.getStack().getActionContext().getActionInvocation();
            if (ai != null) {
                action = ai.getProxy().getActionName();
                namespace = ai.getProxy().getNamespace();
            } else {
                // hmm, ok, we need to just assume the current URL cut down
                String uri = formComponent.request.getRequestURI();
                action = uri.substring(uri.lastIndexOf('/'));
            }
        }

        QueryStringParser.Result queryStringResult = queryStringParser.empty();
        if (action != null && action.indexOf('?') > 0) {
            String queryString = action.substring(action.indexOf('?') + 1);
            queryStringResult = queryStringParser.parse(queryString);
            action = action.substring(0, action.indexOf('?'));
        }

        ActionMapping nameMapping = actionMapper.getMappingFromActionName(action);
        String actionName = nameMapping.getName();
        String actionMethod = nameMapping.getMethod();

        final ActionConfig actionConfig = formComponent.configuration.getRuntimeConfiguration().getActionConfig(
            namespace, actionName);
        if (actionConfig != null) {

            ActionMapping mapping = new ActionMapping(actionName, namespace, actionMethod, formComponent.attributes);
            String result = urlHelper.buildUrl(formComponent.actionMapper.getUriFromActionMapping(mapping),
                formComponent.request, formComponent.response, queryStringResult.getQueryParams(), scheme, formComponent.includeContext, true, false, false);
            formComponent.addParameter("action", result);

            // let's try to get the actual action class and name
            // this can be used for getting the list of validators
            formComponent.addParameter("actionName", actionName);
            try {
                Class<?> clazz = formComponent.objectFactory.getClassInstance(actionConfig.getClassName());
                formComponent.addParameter("actionClass", clazz);
            } catch (ClassNotFoundException e) {
                // this is OK, we'll just move on
            }

            formComponent.addParameter("namespace", namespace);

            // if the name isn't specified, use the action name
            if (formComponent.name == null) {
                formComponent.addParameter("name", actionName);
            }

            // if the id isn't specified, use the action name
            if (formComponent.getId() == null && actionName != null) {
                String escapedId = formComponent.escape(actionName);
                formComponent.addParameter("id", escapedId);
                formComponent.addParameter("escapedId", escapedId);
            }
        } else if (action != null) {
            // Since we can't find an action alias in the configuration, we just
            // assume the action attribute supplied is the path to be used as
            // the URI this form is submitting to.

            // Warn user that the specified namespace/action combo
            // was not found in the configuration.
            if (namespace != null && LOG.isWarnEnabled()) {
                LOG.warn("No configuration found for the specified action: '{}' in namespace: '{}'. Form action defaulting to 'action' attribute's literal value.", actionName, namespace);
            }

            String result = urlHelper.buildUrl(action, formComponent.request, formComponent.response, queryStringResult.getQueryParams(), scheme, formComponent.includeContext, true);
            formComponent.addParameter("action", result);

            // namespace: cut out anything between the start and the last /
            int slash = result.lastIndexOf('/');
            if (slash != -1) {
                formComponent.addParameter("namespace", result.substring(0, slash));
            } else {
                formComponent.addParameter("namespace", "");
            }

            // name/id: cut out anything between / and . should be the id and
            // name
            String id = formComponent.getId();
            if (id == null) {
                slash = result.lastIndexOf('/');
                int dot = result.indexOf('.', slash);
                if (dot != -1) {
                    id = result.substring(slash + 1, dot);
                } else {
                    id = result.substring(slash + 1);
                }
                String escapedId = formComponent.escape(id);
                formComponent.addParameter("id", escapedId);
                formComponent.addParameter("escapedId", escapedId);
            }
        }

        // WW-1284
        // evaluate if client-side js is to be enabled. (if validation
        // interceptor does allow validation eg. method is not filtered out)
        formComponent.evaluateClientSideJsEnablement(actionName, namespace, actionMethod);
    }


    @Override
    public void beforeRenderUrl(UrlProvider urlComponent) {
        if (urlComponent.getValue() != null) {
            urlComponent.setValue(urlComponent.findString(urlComponent.getValue()));
        }

        // no explicit url set so attach params from current url, do
        // this at start so body params can override any of these they wish.
        try {
            // ww-1266
            String includeParams = (urlComponent.getUrlIncludeParams() != null ? urlComponent.getUrlIncludeParams().toLowerCase() : UrlProvider.GET);

            if (urlComponent.getIncludeParams() != null) {
                includeParams = urlComponent.findString(urlComponent.getIncludeParams());
            }

            if (UrlProvider.NONE.equalsIgnoreCase(includeParams)) {
                mergeRequestParameters(urlComponent.getValue(), urlComponent.getParameters(), Collections.emptyMap());
            } else if (UrlProvider.ALL.equalsIgnoreCase(includeParams)) {
                mergeRequestParameters(urlComponent.getValue(), urlComponent.getParameters(), urlComponent.getHttpServletRequest().getParameterMap());

                // for ALL also include GET parameters
                includeGetParameters(urlComponent);
                includeExtraParameters(urlComponent);
            } else if (UrlProvider.GET.equalsIgnoreCase(includeParams) || (includeParams == null && urlComponent.getValue() == null && urlComponent.getAction() == null)) {
                includeGetParameters(urlComponent);
                includeExtraParameters(urlComponent);
            } else if (includeParams != null) {
                LOG.warn("Unknown value for includeParams parameter to URL tag: {}", includeParams);
            }
        } catch (Exception e) {
            LOG.warn("Unable to put request parameters ({}) into parameter map.", urlComponent.getHttpServletRequest().getQueryString(), e);
        }
    }

    private void includeExtraParameters(UrlProvider urlComponent) {
        if (urlComponent.getExtraParameterProvider() != null) {
            mergeRequestParameters(urlComponent.getValue(), urlComponent.getParameters(), urlComponent.getExtraParameterProvider().getExtraParameters());
        }
    }

    private void includeGetParameters(UrlProvider urlComponent) {
        String query = extractQueryString(urlComponent);
        QueryStringParser.Result result = queryStringParser.parse(query);
        result = mergeRequestParameters(urlComponent.getValue(), urlComponent.getParameters(), result.getQueryParams());
        if (!result.getQueryFragment().isEmpty()) {
            urlComponent.setAnchor(result.getQueryFragment());
        }
    }

    private String extractQueryString(UrlProvider urlComponent) {
        // Parse the query string to make sure that the parameters come from the query, and not some posted data
        String query = urlComponent.getHttpServletRequest().getQueryString();
        if (query == null) {
            query = (String) urlComponent.getHttpServletRequest().getAttribute(RequestDispatcher.FORWARD_QUERY_STRING);
        }

        if (query != null) {
            // Remove possible #foobar suffix
            int idx = query.lastIndexOf('#');

            if (idx != -1) {
                query = query.substring(0, idx);
            }
        }
        return query;
    }

    /**
     * Merge request parameters into current parameters. If a parameter is
     * already present, than the request parameter in the current request and value attribute
     * will not override its value.
     * <p>
     * The priority is as follows:-
     * <ul>
     *  <li>parameter from the current request (least priority)</li>
     *  <li>parameter form the value attribute (more priority)</li>
     *  <li>parameter from the param tag (most priority)</li>
     * </ul>
     *
     * @param value             the value attribute (URL to be generated by this component)
     * @param parameters        component parameters
     * @param contextParameters request parameters
     * @return {@link QueryStringParser.Result} of value's ?query-string or empty()
     */
    protected QueryStringParser.Result mergeRequestParameters(String value, Map<String, Object> parameters, Map<String, ?> contextParameters) {
        Map<String, Object> mergedParams = new LinkedHashMap<>(contextParameters);
        QueryStringParser.Result result = queryStringParser.empty();

        // Merge contextParameters (from current request) with parameters specified in value attribute
        // eg. value="someAction.action?id=someId&venue=someVenue"
        // where the parameters specified in value attribute takes priority.

        if (StringUtils.contains(value, "?")) {
            String queryString = value.substring(value.indexOf('?') + 1);

            result = queryStringParser.parse(queryString);
            mergedParams = new LinkedHashMap<>(result.getQueryParams());
            for (Map.Entry<String, ?> entry : contextParameters.entrySet()) {
                if (!mergedParams.containsKey(entry.getKey())) {
                    mergedParams.put(entry.getKey(), entry.getValue());
                }
            }
        }

        // Merge parameters specified in value attribute
        // eg. value="someAction.action?id=someId&venue=someVenue"
        // with parameters specified though param tag
        // eg. <param name="id" value="%{'someId'}" />
        // where parameters specified through param tag takes priority.

        for (Map.Entry<String, Object> entry : mergedParams.entrySet()) {
            if (!parameters.containsKey(entry.getKey())) {
                parameters.put(entry.getKey(), entry.getValue());
            }
        }

        return result;
    }

}