FreemarkerResult.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.views.freemarker;

import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.locale.LocaleProvider;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.util.ValueStack;
import freemarker.template.Configuration;
import freemarker.template.ObjectWrapper;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.StrutsStatics;
import org.apache.struts2.result.StrutsResultSupport;

import java.io.CharArrayWriter;
import java.io.IOException;
import java.io.Serial;
import java.io.Writer;
import java.util.Locale;

/**
 * Renders a view using the Freemarker template engine.
 */
public class FreemarkerResult extends StrutsResultSupport {

    @Serial
    private static final long serialVersionUID = -3778230771704661631L;

    private static final Logger LOG = LogManager.getLogger(FreemarkerResult.class);

    protected ActionInvocation invocation;
    protected Configuration configuration;
    protected ObjectWrapper wrapper;
    protected FreemarkerManager freemarkerManager;
    private Writer writer;
    private Boolean writeIfCompleted = null;

    /*
     * Struts results are constructed for each result execution
     *
     * the current context is available to subclasses via these protected fields
     */
    protected String location;
    private String pContentType = "text/html";
    private static final String PARENT_TEMPLATE_WRITER = FreemarkerResult.class.getName() + ".parentWriter";

    public FreemarkerResult() {
        super();
    }

    public FreemarkerResult(String location) {
        super(location);
    }

    @Inject
    public void setFreemarkerManager(FreemarkerManager mgr) {
        this.freemarkerManager = mgr;
    }

    public void setContentType(String aContentType) {
        pContentType = aContentType;
    }

    /**
     * allow parameterization of the contentType
     * the default being text/html
     *
     * @return the content type
     */
    public String getContentType() {
        return pContentType;
    }

    /**
     * Execute this result, using the specified template locationArg.
     * <p>
     * The template locationArg has already been interpolated for any variable substitutions
     * </p>
     * <p>
     * this method obtains the freemarker configuration and the object wrapper from the provided hooks.
     * It them implements the template processing workflow by calling the hooks for
     * preTemplateProcess and postTemplateProcess
     * </p>
     *
     * @param locationArg location argument
     * @param invocation  the action invocation
     * @throws IOException       in case of IO errors
     * @throws TemplateException in case of freemarker template errors
     */
    public void doExecute(String locationArg, ActionInvocation invocation) throws IOException, TemplateException {
        this.location = locationArg;
        this.invocation = invocation;
        this.configuration = getConfiguration();
        this.wrapper = getObjectWrapper();

        ActionContext ctx = invocation.getInvocationContext();
        HttpServletRequest req = ctx.getServletRequest();

        String absoluteLocation;
        if (location.startsWith("/")) {
            absoluteLocation = location;
        } else {
            String namespace = invocation.getProxy().getNamespace();
            if (namespace == null || namespace.isEmpty() || namespace.equals("/")) {
                absoluteLocation = "/" + location;
            } else if (namespace.startsWith("/")) {
                absoluteLocation = namespace + "/" + location;
            } else {
                absoluteLocation = "/" + namespace + "/" + location;
            }
        }

        Template template = configuration.getTemplate(absoluteLocation, deduceLocale());
        TemplateModel model = createModel();

        // Give subclasses a chance to hook into preprocessing
        if (preTemplateProcess(template, model)) {
            try {
                final boolean willWriteIfCompleted;
                if (writeIfCompleted != null) {
                    willWriteIfCompleted = isWriteIfCompleted();
                } else {
                    willWriteIfCompleted = template.getTemplateExceptionHandler() == TemplateExceptionHandler.RETHROW_HANDLER;
                }

                // Process the template
                Writer writer = getWriter();
                if (willWriteIfCompleted) {
                    CharArrayWriter parentCharArrayWriter = (CharArrayWriter) req.getAttribute(PARENT_TEMPLATE_WRITER);
                    boolean isTopTemplate;
                    if (isTopTemplate = (parentCharArrayWriter == null)) {
                        //this is the top template
                        parentCharArrayWriter = new CharArrayWriter();
                        //set it in the request because when the "action" tag is used a new VS and ActionContext is created
                        req.setAttribute(PARENT_TEMPLATE_WRITER, parentCharArrayWriter);
                    }

                    try {
                        template.process(model, parentCharArrayWriter);

                        if (isTopTemplate) {
                            parentCharArrayWriter.flush();
                            parentCharArrayWriter.writeTo(writer);
                        }
                    } catch (TemplateException | IOException e) {
                        if (LOG.isErrorEnabled()) {
                            LOG.error("Error processing Freemarker result!", e);
                        }
                        throw e;
                    } finally {
                        if (isTopTemplate) {
                            req.removeAttribute(PARENT_TEMPLATE_WRITER);
                            parentCharArrayWriter.close();
                        }
                    }
                } else {
                    template.process(model, writer);
                }
            } finally {
                // Give subclasses a chance to hook into postprocessing
                postTemplateProcess(template, model);
            }
        }
    }

    /**
     * <p>
     * This method is called from {@link #doExecute(String, ActionInvocation)} to obtain the
     * FreeMarker configuration object that this result will use for template loading. This is a
     * hook that allows you to custom-configure the configuration object in a subclass, or to fetch
     * it from an IoC container.
     * </p>
     *
     * <p>
     * <b>
     * The default implementation obtains the configuration from the ConfigurationManager instance.
     * </b>
     * </p>
     *
     * @return the freemarker configuration object
     * @throws TemplateException in case of freemarker configuration errors
     */
    protected Configuration getConfiguration() throws TemplateException {
        return freemarkerManager.getConfiguration(ActionContext.getContext().getServletContext());
    }

    /**
     * <p>
     * This method is called from {@link #doExecute(String, ActionInvocation)}  to obtain the
     * FreeMarker object wrapper object that this result will use for adapting objects into template
     * models. This is a hook that allows you to custom-configure the wrapper object in a subclass.
     * </p>
     *
     * <p>
     * <b>
     * The default implementation returns {@link Configuration#getObjectWrapper()}
     * </b>
     * </p>
     *
     * @return the object wrapper from configuration
     */
    protected ObjectWrapper getObjectWrapper() {
        return configuration.getObjectWrapper();
    }


    public void setWriter(Writer writer) {
        this.writer = writer;
    }

    /**
     * The default writer writes directly to the response writer.
     *
     * @return Writer the response writer
     * @throws IOException in case of IO errors
     */
    protected Writer getWriter() throws IOException {
        if (writer != null) {
            return writer;
        }
        return ActionContext.getContext().getServletResponse().getWriter();
    }

    /**
     * Build the instance of the ScopesHashModel, including JspTagLib support
     * <p>
     * Objects added to the model are
     * </p>
     *
     * <ul>
     * <li>Application - servlet context attributes hash model
     * <li>JspTaglibs - jsp tag lib factory model
     * <li>Request - request attributes hash model
     * <li>Session - session attributes hash model
     * <li>request - the HttpServletRequst object for direct access
     * <li>response - the HttpServletResponse object for direct access
     * <li>stack - the OgnLValueStack instance for direct access
     * <li>action - the action itself
     * <li>exception - optional : the JSP or Servlet exception as per the servlet spec (for JSP Exception pages)
     * <li>struts - instance of the StrutsUtil class
     * </ul>
     *
     * @return TemplateModel returns the created template model
     * @throws TemplateModelException in case of errors during creating the model
     */
    protected TemplateModel createModel() throws TemplateModelException {
        ServletContext servletContext = ActionContext.getContext().getServletContext();
        HttpServletRequest request = ActionContext.getContext().getServletRequest();
        HttpServletResponse response = ActionContext.getContext().getServletResponse();
        ValueStack stack = ActionContext.getContext().getValueStack();

        Object action = null;
        if (invocation != null) action = invocation.getAction(); //Added for NullPointException
        return freemarkerManager.buildTemplateModel(stack, action, servletContext, request, response, wrapper);
    }

    /**
     * Returns the locale used for the {@link Configuration#getTemplate(String, Locale)} call. The base implementation
     * simply returns the locale setting of the action (assuming the action implements {@link LocaleProvider}) or, if
     * the action does not the configuration's locale is returned. Override this method to provide different behaviour,
     *
     * @return the locale from action if action implements the {@link LocaleProvider}) or local from configuration
     */
    protected Locale deduceLocale() {
        if (invocation.getAction() instanceof LocaleProvider) {
            return ((LocaleProvider) invocation.getAction()).getLocale();
        } else {
            return configuration.getLocale();
        }
    }

    /**
     * the default implementation of postTemplateProcess applies the contentType parameter
     *
     * @param template the freemarker template
     * @param model    the template model
     * @throws IOException in case of IO errors
     */
    protected void postTemplateProcess(Template template, TemplateModel model) throws IOException {
    }

    /**
     * Called before the execution is passed to template.process().
     * This is a generic hook you might use in subclasses to perform a specific
     * action before the template is processed. By default does nothing.
     * A typical action to perform here is to inject application-specific
     * objects into the model root
     *
     * @param template the freemarker template
     * @param model    the template model
     * @return true to process the template, false to suppress template processing.
     * @throws IOException in case of IO errors
     */
    protected boolean preTemplateProcess(Template template, TemplateModel model) throws IOException {
        Object attrContentType = template.getCustomAttribute("content_type");

        HttpServletResponse response = ActionContext.getContext().getServletResponse();
        if (response.getContentType() == null) {
            if (attrContentType != null) {
                response.setContentType(attrContentType.toString());
            } else {
                String contentType = getContentType();

                if (contentType == null) {
                    contentType = "text/html";
                }

                String encoding = template.getEncoding();

                if (encoding != null) {
                    contentType = contentType + "; charset=" + encoding;
                }

                response.setContentType(contentType);
            }
        } else if (isInsideActionTag()) {
            //trigger com.opensymphony.module.sitemesh.filter.PageResponseWrapper.deactivateSiteMesh()
            response.setContentType(response.getContentType());
        }

        return true;
    }

    private boolean isInsideActionTag() {
        Object attribute = ActionContext.getContext().getServletRequest().getAttribute(StrutsStatics.STRUTS_ACTION_TAG_INVOCATION);
        return (Boolean) ObjectUtils.defaultIfNull(attribute, Boolean.FALSE);
    }

    public boolean isWriteIfCompleted() {
        return writeIfCompleted != null && writeIfCompleted;
    }

    /**
     * @param writeIfCompleted template is processed and flushed according to freemarker library policies
     */
    public void setWriteIfCompleted(Boolean writeIfCompleted) {
        this.writeIfCompleted = writeIfCompleted;
    }
}