null

Primefaces, FileDownload и Unicode

В ходе разработки приложения на базе JSF, Primefaces и Spring я столкнулся с задачей — дать возможность клиенту скачать динамически сгенерированный файл. В используемом фреймворке имелся специальный высокоуровневый компонент для этой задачи, которым я радостно воспользовался.

import org.springframework.stereotype.Component;

@Component
class Reporter {
    public StreamedContent downloadCertGroupInfo() {
        String filename = "Отчет на дату " + new Date() + ".xlsx";
        byte[] report = generateReport();
        return new DefaultStreamedContent(new ByteArrayInputStream(report), ContentTypes.MS_XLSX, filename);
    }
}
<p:commandButton value="#{i18n['report.cert-group.download']}" ajax="false">
    <p:fileDownload value="#{reporter.downloadCertGroupInfo()}"/>
</p:commandButton>

Казалось бы, что может пойти не так? Вот что было предложено браузером:

 

Это не очень похоже на ожидаемое 'Отчет ...xlsx'. В чем же проблема?

Беглый поиск в гугле говорит, что это баг версии Primefaces 6.1, который был исправлен в версии 6.2 (на момент написания статьи еще не выпущен). Умные люди посоветовали использовать UrlEncoder#encode. До поры до времени такое решение (почти) работало:

Пока я не попытался скачать этот же файл в браузере Firefox:

В чем же проблема?

Если копнуть глубже, то мы узнаем (если уже не знали), что за имя файла, предлагаемое браузером при загрузке, будет взято из заголовка Content-Disposition.

Content-Disposition: attachment; filename=test.txt

Поиск информации по работе filename с Unicode подсказывает, что url-encoding должно было помочь. Только содержимое заголовка должно быть таким:

Content-Disposition: attachment; filname*= UTF=8''url-encoded-filename.txt

Проверим, что отдает нам сервер с помощью инструментов разработчика:

Есть явные отличия от требуемого формата:

  1. Вместо '*=' в ответе заголовке содержится '='
  2. Отсутствует обязательная часть UTF-8''

За такое поведение отвечает следующая строка класса 
org.primefaces.component.filedownload.FileDownloadActionListener

externalContext.setResponseHeader("Content-Disposition", contentDispositionValue + ";filename=\"" + content.getName() + "\"");

Как же исправить это поведение, не изменяя исходный код Primefaces'a? Быть может, попробуем изменить заголовок в фильтре?

Попытка № 1 (нерабочая)

Поверхностные воспоминания об устройстве фильтров в Java EE говорят, что работу фильтра можно условно разбить на две фазы: предобработку (до вызова filterChain.doFilter) и постобработку. Наивное решение - изменить Content-Disposition в постобработке.

Раз кодирование происходит в фильтре, то из основного кода его можно убрать.

package ru.edu.portfolio.filters;

import lombok.extern.slf4j.Slf4j;

import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;

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.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

@Slf4j
@Component
class ContentDispositionFilter implements Filter {

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

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        filterChain.doFilter(servletRequest, servletResponse);
        HttpServletResponse http = (HttpServletResponse) servletResponse;
        String contentDisposition = http.getHeader(HttpHeaders.CONTENT_DISPOSITION);
        if (contentDisposition != null) {
            String filenameParam = "filename";
            int filenameIdx = contentDisposition.indexOf(filenameParam);
            String attachment = "attachment;";
            if (contentDisposition.startsWith(attachment) && filenameIdx >= 0) {
                int eq = contentDisposition.indexOf("=");
                if (eq >= 0) {
                    String filename = contentDisposition.substring(eq + 1);
                    if (filename.startsWith("\"")) {
                        filename = filename.substring(1);
                    }
                    if (filename.endsWith("\"")) {
                        filename = filename.substring(0, filename.length() - 1);
                    }
                    try {
                        filename = URLEncoder.encode(filename, "UTF-8");
                        contentDisposition = attachment + filenameParam + "*= UTF-8''" + filename;
                        http.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition);
                    } catch (UnsupportedEncodingException e) {
                        log.error("Unexpected error - UTF-8 is not supported by URLEncoder", e);
                    }
                }
            }
        }
    }

    @Override
    public void destroy() {

    }
    
}

Логика проста - если после filterChain в ответе есть заголовок Content-Disposition, то попытаемся извлечь из него имя файла и закодировать его с помощью UrlEncoder. К сожалению, в этот момент уже поздно — ответ уже сформирован и отправлен пользователю, что прекрасно видно при попытке скачать файл.

Видимо, нам нужно как-то проверять заголовок сразу при его добавлении. Как же это можно сделать? Как добавить к HttpServletResponse поведение по проверке заголовка?

Попытка № 2 (паттерн "Декоратор")

Если подумать (или погуглить), то задача динамического добавления поведения к объекту является типовой, потому для ее решения уже придумали шаблон проектирования — декоратор. Причем для HttpResponse уже есть готовый класс javax.servlet.http.HttpServletResponseWrapper, в котором можно переопределить только нужные нам методы — setHeader/addHeader. Создадим свой класс, наследуемся от HttpServletResponseWrapper, переопределим методы и переместим логику из фильтра. В фильтре же обернем объект ServletResponse в наш класс.

package ru.edu.portfolio.filters;

import lombok.extern.slf4j.Slf4j;

import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;

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.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

@Slf4j
@Component
public class ContentDispositionFilter implements Filter {

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

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        filterChain.doFilter(servletRequest, new ContentDispositionWrapper((HttpServletResponse) servletResponse));
    }

    @Override
    public void destroy() {

    }

    private static class ContentDispositionWrapper extends HttpServletResponseWrapper {
        private final HttpServletResponse response;

        private ContentDispositionWrapper(HttpServletResponse response) {
            super(response);
            this.response = response;
        }

        @Override
        public void setHeader(String name, String value) {
            if (name.equalsIgnoreCase(HttpHeaders.CONTENT_DISPOSITION)) {
                String filenameParam = "filename";
                int filenameIdx = value.indexOf(filenameParam);
                String attachment = "attachment;";
                if (value.startsWith(attachment) && filenameIdx >= 0) {
                    int eq = value.indexOf("=");
                    if (eq >= 0) {
                        String filename = value.substring(eq + 1);
                        if (filename.startsWith("\"")) {
                            filename = filename.substring(1);
                        }
                        if (filename.endsWith("\"")) {
                            filename = filename.substring(0, filename.length() - 1);
                        }
                        try {
                            filename = URLEncoder.encode(filename, "UTF-8");
                            value = attachment + filenameParam + "*= UTF-8''" + filename;
                        } catch (UnsupportedEncodingException e) {
                            log.error("Unexpected error - UTF-8 is not supported by URLEncoder", e);
                        }
                    }
                }
            }
            super.setHeader(name, value);
        }
    }
}

Тестируем:

Готово, мы восхитительны!

Засим откланиваюсь, прощайте.