How to return a file from an Action?

This is to properly answer an older question, in case anyone else happens across it. The old question is closed, so I am unable to answer on it.

THE ORIGINAL QUESTION

Yup, that’s from 2014 and it’s still an issue…5 and a half years later.

THE EXCEPTION

java.lang.NullPointerException
	at java.util.regex.Matcher.getTextLength(Matcher.java:1140)
	at java.util.regex.Matcher.reset(Matcher.java:291)
	at java.util.regex.Matcher.<init>(Matcher.java:211)
	at java.util.regex.Pattern.matcher(Pattern.java:888)
	at com.opensymphony.xwork.util.TextParseUtil.translateVariables(TextParseUtil.java:32)
	at com.opensymphony.webwork.dispatcher.WebWorkResultSupport.execute(WebWorkResultSupport.java:113)
	at com.opensymphony.xwork.DefaultActionInvocation.executeResult(DefaultActionInvocation.java:263)

I was not happy with the explanation of nor the solution to it; so, I dug into it and found out what is actually causing this exception.

THE INVESTIGATION
The <result> XML you are creating eventually turns into a com.opensymphony.xwork.Result object. In Confluence v6.15.8, this comes from the xwork-1.0.3.6.jar dependency.

The exact implementation is determined by the result element’s type attribute. In our case, this is “stream”, which ends up as a com.opensymphony.webwork.dispatcher.StreamResult object. In Confluence v6.15.8, this comes from the webwork-2.1.5-atlassin-3.jar dependency.

Atlassian’s StreamResult extends WebWorkResultSupport (in the same package). This abstract class adds on a few parameters, such as location and parse.

Hmm, that’s funny, because here is the chunk of code that triggers the NPE:

        if (this.parse) {
            OgnlValueStack stack = ActionContext.getContext().getValueStack();
            finalLocation = TextParseUtil.translateVariables(this.location, stack);
        }

To be more specific, it’s on the 3rd line, inside the translateVariables call. We never set location, because according to xwork/struts, this parameter is not relevant to stream type results. The parse parameter determines whether or not the location parameter will be parsed for variables (which are in the stack and can come from your action, etc - it’s very handy if you actually need the location parameter).

Since the parse parameter defaults to true and we aren’t using a location parameter, the location is parsed. The location is null, eventually java.util.regex.Matcher attempts to grab it’s length, and we get the NPE.

THE CAUSE
To compare, I checked the current version in struts2 (webwork 2.3.x and beyond were merged into struts2). Their version does not extend WebWorkResultSupport. Nor should it, as location and parse are not relevant to stream types. It simply implements Result.

Why Atlassian extended this - we may never know… If they were to default parse to false in StreamResult or stopped extending WebWorkResultSupport, this would no longer be a problem.

THE FIX
Now that we know the problem, the fix is easy: disable parsing of the location. This can be done by adding the following to your <result> element:

                    <param name="parse">false</param>

COMPLETE, WORKING EXAMPLE

<atlassian-plugin>
    <xwork key="my-work" name="download">
        <package name="download" extends="default" namespace="/admin/my-plugin">
            <default-interceptor-ref name="defaultStack"/>

            <action name="download" class="com.example.confluence.plugin.myplugin.actions.MyAction">
                <result name="download" type="stream">
                    <param name="contentType">text/csv; charset=UTF-8</param>
                    <param name="inputName">csv</param>
                    <param name="bufferSize">4096</param>
                    <!-- Atlassian's StreamResult extends the wrong class, so we must disable an irrelevant option. -->
                    <param name="parse">false</param>
                </result>
            </action>
        </package>
    </xwork>
</atlassian-plugin>

Unfortunately, if you look at StreamResult, they don’t actually support all the parameters you would expect from looking at documentation for webwork or struts2. From what I can tell, only contentType, inputName, and bufferSize are used. The rest are set directly on the response with help from ServletResponseAware.

package com.example.confluence.plugin.myplugin.actions;

import com.example.confluence.plugin.myplugin.services.MyService;
import com.opensymphony.webwork.interceptor.ServletResponseAware;
import com.opensymphony.xwork.Action;

import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.HttpHeaders;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

@SuppressWarnings("java:S1948")
public class MyAction implements Action, ServletResponseAware {

    private final MyService myService;

    private InputStream csv;

    private HttpServletResponse response;

    public MyAction(final MyService myService) {
        this.myService = myService;
    }

    @Override
    public String execute() {
        final byte[] contents = myService.getCSV().getBytes(StandardCharsets.UTF_8);

        csv = new ByteArrayInputStream(contents);

        response.addHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contents.length));
        response.addHeader(HttpHeaders.CACHE_CONTROL, "no-cache");
        response.addHeader("Pragma", "no-cache");
        response.addHeader(HttpHeaders.EXPIRES, "-1");
        response.addHeader("Content-Disposition", "attachment; filename=\"my.csv\"");

        return "download";
    }

    @SuppressWarnings("unused") // used by xwork
    public InputStream getCsv() {
        return csv;
    }

    @Override
    public void setServletResponse(final HttpServletResponse response) {
        this.response = response;
    }

}

The path is: /admin/my-plugin/download.action

When hit (e.g. from a web-item), a file download dialog opens in Firefox. The filename is set properly and Firefox recognizes the mime type as “CSV document”.

This was performed using Atlassian SDK 8.0.16, Confluence 6.15.8, and the app is using Atlassian Spring Scanner 2.1.7. Names, packages, and other things have changed from my working code; so, please check for typos (mine and yours) before saying it doesn’t work!

1 Like

This is a how-to. See question for the answer.

1 Like