JasperReportsResult.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.jasperreports;

import org.apache.struts2.ActionInvocation;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.security.NotExcludedAcceptedPatternsChecker;
import org.apache.struts2.util.ValueStack;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JRExporter;
import net.sf.jasperreports.engine.JRExporterParameter;
import net.sf.jasperreports.engine.JRParameter;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.engine.export.HtmlExporter;
import net.sf.jasperreports.engine.export.JRCsvExporter;
import net.sf.jasperreports.engine.export.JRCsvExporterParameter;
import net.sf.jasperreports.engine.export.JRHtmlExporterParameter;
import net.sf.jasperreports.engine.export.JRPdfExporter;
import net.sf.jasperreports.engine.export.JRRtfExporter;
import net.sf.jasperreports.engine.export.JRXlsExporter;
import net.sf.jasperreports.engine.export.JRXmlExporter;
import net.sf.jasperreports.engine.util.JRLoader;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.result.StrutsResultSupport;

import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.sql.Connection;
import java.util.HashMap;
import java.util.Map;
import java.util.TimeZone;

/**
 * <!-- START SNIPPET: description -->
 * <p>
 * Generates a JasperReports report using the specified format or PDF if no
 * format is specified.
 * </p>
 * <!-- END SNIPPET: description -->
 * <p>
 * <b>This result type takes the following parameters:</b>
 * </p>
 * <!-- START SNIPPET: params -->
 *
 * <ul>
 *
 * <li><b>location (default)</b> - the location where the compiled jasper report
 * definition is (foo.jasper), relative from current URL.</li>
 * <li><b>dataSource (required)</b> - the EL expression used to retrieve the
 * datasource from the value stack (usually a List).</li>
 * <li><b>parse</b> - true by default. If set to false, the location param will
 * not be parsed for EL expressions.</li>
 * <li><b>format</b> - the format in which the report should be generated. Valid
 * values can be found in {@link JasperReportConstants}. If no format is
 * specified, PDF will be used.</li>
 * <li><b>contentDisposition</b> - disposition (defaults to "inline", values are
 * typically <i>filename="document.pdf"</i>).</li>
 * <li><b>documentName</b> - name of the document (will generate the http header
 * <code>Content-disposition = X; filename=X.[format]</code>).</li>
 * <li><b>delimiter</b> - the delimiter used when generating CSV reports. By
 * default, the character used is ",".</li>
 * <li><b>imageServletUrl</b> - name of the url that, when prefixed with the
 * context page, can return report images.</li>
 * <li>
 * <b>reportParameters</b> - (2.1.2+) OGNL expression used to retrieve a map of
 * report parameters from the value stack. The parameters may be accessed
 * in the report via the usual JR mechanism and might include data not
 * part of the dataSource, such as the user name of the report creator, etc.
 * </li>
 * <li>
 * <b>exportParameters</b> - (2.1.2+) OGNL expression used to retrieve a map of
 * JR exporter parameters from the value stack. The export parameters are
 * used to customize the JR export. For example, a PDF export might enable
 * encryption and set the user password to a string known to the report creator.
 * </li>
 * <li>
 * <b>connection</b> - (2.1.7+) JDBC Connection which can be passed to the
 * report instead of dataSource
 * </li>
 * <li><b>wrapField</b> - (2.3.18+) defines if fields should warp with ValueStackDataSource
 * see https://issues.apache.org/jira/browse/WW-3698 for more details
 * </li>
 * </ul>
 * <p>
 * This result follows the same rules from {@link StrutsResultSupport}.
 * Specifically, all parameters will be parsed if the "parse" parameter
 * is not set to false.
 * </p>
 * <!-- END SNIPPET: params -->
 * <p><b>Example:</b></p>
 * <pre>
 * <!-- START SNIPPET: example1 -->
 * &lt;result name="success" type="jasper"&gt;
 *   &lt;param name="location"&gt;foo.jasper&lt;/param&gt;
 *   &lt;param name="dataSource"&gt;mySource&lt;/param&gt;
 *   &lt;param name="format"&gt;CSV&lt;/param&gt;
 * &lt;/result&gt;
 * <!-- END SNIPPET: example1 -->
 * </pre>
 * <p>
 * or for pdf
 *
 * <pre>
 * <!-- START SNIPPET: example2 -->
 * &lt;result name="success" type="jasper"&gt;
 *   &lt;param name="location"&gt;foo.jasper&lt;/param&gt;
 *   &lt;param name="dataSource"&gt;mySource&lt;/param&gt;
 * &lt;/result&gt;
 * <!-- END SNIPPET: example2 -->
 * </pre>
 */
public class JasperReportsResult extends StrutsResultSupport implements JasperReportConstants {

    private static final long serialVersionUID = -2523174799621182907L;

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

    protected String dataSource;
    private String parsedDataSource;
    protected String format;
    protected String documentName;
    protected String contentDisposition;
    protected String delimiter;
    protected String imageServletUrl = "/images/";
    protected String timeZone;
    protected boolean wrapField = true;

    /**
     * Connection which can be passed to the report
     * instead od dataSource.
     */
    protected String connection;

    /**
     * Names a report parameters map stack value, allowing
     * additional report parameters from the action.
     */
    protected String reportParameters;
    private String parsedReportParameters;

    /**
     * Names an exporter parameters map stack value,
     * allowing the use of custom export parameters.
     */
    protected String exportParameters;
    private String parsedExportParameters;

    private NotExcludedAcceptedPatternsChecker notExcludedAcceptedPatterns;

    /**
     * Default ctor.
     */
    public JasperReportsResult() {
        super();
    }

    /**
     * Default ctor with location.
     *
     * @param location Result location.
     */
    public JasperReportsResult(String location) {
        super(location);
    }

    @Inject
    public void setNotExcludedAcceptedPatterns(NotExcludedAcceptedPatternsChecker notExcludedAcceptedPatterns) {
        this.notExcludedAcceptedPatterns = notExcludedAcceptedPatterns;
    }

    public String getImageServletUrl() {
        return imageServletUrl;
    }

    public void setImageServletUrl(final String imageServletUrl) {
        this.imageServletUrl = imageServletUrl;
    }

    public void setDataSource(String dataSource) {
        this.dataSource = dataSource;
    }

    public void setFormat(String format) {
        this.format = format;
    }

    public void setDocumentName(String documentName) {
        this.documentName = documentName;
    }

    public void setContentDisposition(String contentDisposition) {
        this.contentDisposition = contentDisposition;
    }

    public void setDelimiter(String delimiter) {
        this.delimiter = delimiter;
    }

    /**
     * @param timeZone set time zone id
     */
    public void setTimeZone(final String timeZone) {
        this.timeZone = timeZone;
    }

    public void setWrapField(boolean wrapField) {
        this.wrapField = wrapField;
    }

    public String getReportParameters() {
        return reportParameters;
    }

    public void setReportParameters(String reportParameters) {
        this.reportParameters = reportParameters;
    }

    public String getExportParameters() {
        return exportParameters;
    }

    public void setExportParameters(String exportParameters) {
        this.exportParameters = exportParameters;
    }

    public String getConnection() {
        return connection;
    }

    public void setConnection(String connection) {
        this.connection = connection;
    }

    protected void doExecute(String finalLocation, ActionInvocation invocation) throws Exception {
        // Will throw a runtime exception if no "datasource" property. TODO Best place for that is...?
        initializeProperties(invocation);

        LOG.debug("Creating JasperReport for dataSource = {}, format = {}", dataSource, format);

        HttpServletRequest request = invocation.getInvocationContext().getServletRequest();
        HttpServletResponse response = invocation.getInvocationContext().getServletResponse();

        // Handle IE special case: it sends a "contype" request first.
        // TODO Set content type to config settings?
        if ("contype".equals(request.getHeader("User-Agent"))) {
            try (OutputStream outputStream = response.getOutputStream()) {
                response.setContentType("application/pdf");
                response.setContentLength(0);
            } catch (IOException e) {
                LOG.error("Error writing report output", e);
                throw new ServletException(e.getMessage(), e);
            }
            return;
        }

        // Construct the data source for the report.
        ValueStack stack = invocation.getStack();
        ValueStackDataSource stackDataSource = null;

        Connection conn = (Connection) stack.findValue(connection);
        if (conn == null) {
            boolean evaluated = parsedDataSource != null && !parsedDataSource.equals(dataSource);
            boolean reevaluate = !evaluated || isAcceptableExpression(parsedDataSource);
            if (reevaluate) {
                stackDataSource = new ValueStackDataSource(stack, parsedDataSource, wrapField);
            } else {
                throw new ServletException(String.format("Error building dataSource for excluded or not accepted [%s]",
                        parsedDataSource));
            }
        }

        if ("https".equalsIgnoreCase(request.getScheme())) {
            // set the the HTTP Header to work around IE SSL weirdness
            response.setHeader("CACHE-CONTROL", "PRIVATE");
            response.setHeader("Cache-Control", "maxage=3600");
            response.setHeader("Pragma", "public");
            response.setHeader("Accept-Ranges", "none");
        }

        // Determine the directory that the report file is in and set the reportDirectory parameter
        // For WW 2.1.7:
        //  ServletContext servletContext = ((ServletConfig) invocation.getInvocationContext().get(ServletActionContext.SERVLET_CONFIG)).getServletContext();
        ServletContext servletContext = invocation.getInvocationContext().getServletContext();
        String systemId = servletContext.getRealPath(finalLocation);
        Map<String, Object> parameters = new ValueStackShadowMap(stack);
        File directory = new File(systemId.substring(0, systemId.lastIndexOf(File.separator)));
        parameters.put("reportDirectory", directory);
        parameters.put(JRParameter.REPORT_LOCALE, invocation.getInvocationContext().getLocale());

        // put timezone in jasper report parameter
        if (timeZone != null) {
            timeZone = conditionalParse(timeZone, invocation);
            final TimeZone tz = TimeZone.getTimeZone(timeZone);
            if (tz != null) {
                // put the report time zone
                parameters.put(JRParameter.REPORT_TIME_ZONE, tz);
            }
        }

        // Add any report parameters from action to param map.
        boolean evaluated = parsedReportParameters != null && !parsedReportParameters.equals(reportParameters);
        boolean reevaluate = !evaluated || isAcceptableExpression(parsedReportParameters);
        Map reportParams = reevaluate ? (Map) stack.findValue(parsedReportParameters) : null;
        if (reportParams != null) {
            LOG.debug("Found report parameters; adding to parameters...");
            parameters.putAll(reportParams);
        }

        ByteArrayOutputStream output;
        JasperPrint jasperPrint;

        // Fill the report and produce a print object
        try {
            JasperReport jasperReport = (JasperReport) JRLoader.loadObject(new File(systemId));
            if (conn == null) {
                jasperPrint = JasperFillManager.fillReport(jasperReport, parameters, stackDataSource);
            } else {
                jasperPrint = JasperFillManager.fillReport(jasperReport, parameters, conn);
            }
        } catch (JRException e) {
            LOG.error("Error building report for uri {}", systemId, e);
            throw new ServletException(e.getMessage(), e);
        }

        // Export the print object to the desired output format
        try {
            if (contentDisposition != null || documentName != null) {
                final StringBuilder tmp = new StringBuilder();
                tmp.append((contentDisposition == null) ? "inline" : contentDisposition);

                if (documentName != null) {
                    tmp.append("; filename=");
                    tmp.append(documentName);
                    tmp.append(".");
                    tmp.append(format.toLowerCase());
                }

                response.setHeader("Content-disposition", tmp.toString());
            }

            //TODO: replace deprecated logic
            JRExporter exporter;

            switch (format) {
                case FORMAT_PDF:
                    response.setContentType("application/pdf");
                    exporter = new JRPdfExporter();
                    break;
                case FORMAT_CSV:
                    response.setContentType("text/csv");
                    exporter = new JRCsvExporter();
                    break;
                case FORMAT_HTML:
                    response.setContentType("text/html");

                    // IMAGES_MAPS seems to be only supported as "backward compatible" from JasperReports 1.1.0

                    Map imagesMap = new HashMap();
                    request.getSession(true).setAttribute("IMAGES_MAP", imagesMap);

                    exporter = new HtmlExporter();
                    exporter.setParameter(JRHtmlExporterParameter.IMAGES_MAP, imagesMap);
                    exporter.setParameter(JRHtmlExporterParameter.IMAGES_URI, request.getContextPath() + imageServletUrl);

                    // Needed to support chart images:
                    exporter.setParameter(JRExporterParameter.JASPER_PRINT, jasperPrint);
                    request.getSession().setAttribute("net.sf.jasperreports.j2ee.jasper_print", jasperPrint);
                    break;
                case FORMAT_XLS:
                    response.setContentType("application/vnd.ms-excel");
                    exporter = new JRXlsExporter();
                    break;
                case FORMAT_XML:
                    response.setContentType("text/xml");
                    exporter = new JRXmlExporter();
                    break;
                case FORMAT_RTF:
                    response.setContentType("application/rtf");
                    exporter = new JRRtfExporter();
                    break;
                default:
                    throw new ServletException("Unknown report format: " + format);
            }

            evaluated = parsedExportParameters != null && !parsedExportParameters.equals(exportParameters);
            reevaluate = !evaluated || isAcceptableExpression(parsedExportParameters);
            Map exportParams = reevaluate ? (Map) stack.findValue(parsedExportParameters) : null;
            if (exportParams != null) {
                LOG.debug("Found export parameters; adding to exporter parameters...");
                exporter.getParameters().putAll(exportParams);
            }

            output = exportReportToBytes(jasperPrint, exporter);
        } catch (JRException e) {
            LOG.error("Error producing {} report for uri {}", format, systemId, e);
            throw new ServletException(e.getMessage(), e);
        } finally {
            try {
                if (conn != null) {
                    // avoid NPE if connection was not used for the report
                    conn.close();
                }
            } catch (Exception e) {
                LOG.warn("Could not close db connection properly", e);
            }
        }

        response.setContentLength(output.size());
        // Will throw ServletException on IOException.
        writeReport(response, output);
    }

    /**
     * Writes report bytes to response output stream.
     *
     * @param response Current response.
     * @param output   Report bytes to write.
     * @throws ServletException on stream IOException.
     */
    private void writeReport(HttpServletResponse response, ByteArrayOutputStream output) throws ServletException {
        try (OutputStream outputStream = response.getOutputStream()) {
            output.writeTo(outputStream);
            outputStream.flush();
        } catch (IOException e) {
            LOG.error("Error writing report output", e);
            throw new ServletException(e.getMessage(), e);
        }
    }

    /**
     * Sets up result properties, parsing etc.
     *
     * @param invocation Current invocation.
     * @throws Exception on initialization error.
     */
    private void initializeProperties(ActionInvocation invocation) {
        if (dataSource == null && connection == null) {
            String message = "No dataSource specified...";
            LOG.error(message);
            throw new RuntimeException(message);
        }
        if (dataSource != null) {
            parsedDataSource = conditionalParse(dataSource, invocation);
        }

        format = conditionalParse(format, invocation);
        if (StringUtils.isEmpty(format)) {
            format = FORMAT_PDF;
        }

        if (contentDisposition != null) {
            contentDisposition = conditionalParse(contentDisposition, invocation);
        }

        if (documentName != null) {
            documentName = conditionalParse(documentName, invocation);
        }

        parsedReportParameters = conditionalParse(reportParameters, invocation);
        parsedExportParameters = conditionalParse(exportParameters, invocation);
    }

    /**
     * Run a Jasper report to CSV format and put the results in a byte array
     *
     * @param jasperPrint The Print object to render as CSV
     * @param exporter    The exporter to use to export the report
     * @return A CSV formatted report
     * @throws net.sf.jasperreports.engine.JRException If there is a problem running the report
     */
    private ByteArrayOutputStream exportReportToBytes(JasperPrint jasperPrint, JRExporter exporter) throws JRException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        exporter.setParameter(JRExporterParameter.JASPER_PRINT, jasperPrint);
        exporter.setParameter(JRExporterParameter.OUTPUT_STREAM, baos);
        if (delimiter != null) {
            exporter.setParameter(JRCsvExporterParameter.FIELD_DELIMITER, delimiter);
        }

        exporter.exportReport();
        return baos;
    }

    /**
     * Checks if expression doesn't contain vulnerable code
     *
     * @param expression of result
     * @return true|false
     * @since 6.0.0
     */
    protected boolean isAcceptableExpression(String expression) {
        NotExcludedAcceptedPatternsChecker.IsAllowed isAllowed = notExcludedAcceptedPatterns.isAllowed(expression);
        if (isAllowed.isAllowed()) {
            return true;
        }

        LOG.warn("Expression [{}] isn't allowed by pattern [{}]! See Accepted / Excluded patterns at\n" +
                "https://struts.apache.org/security/", expression, isAllowed.getAllowedPattern());

        return false;
    }
}