В ходе разработки приложения на базе 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
Проверим, что отдает нам сервер с помощью инструментов разработчика:

Есть явные отличия от требуемого формата:
- Вместо '*=' в ответе заголовке содержится '='
- Отсутствует обязательная часть 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);
}
}
}
Тестируем:

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