Skip to content

Commit 258ab1a

Browse files
Artur-claude
andauthored
feat: add inline Content-Disposition support with filenames (#21934) (#22567)
Add inline() methods to DownloadEvent for RFC 6266 compliant inline content disposition headers with filenames. Update all download handlers to include filenames when in inline mode, preventing browser UUID-based filenames. Add containsHeader() to VaadinResponse to prevent overriding custom headers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7fa337a commit 258ab1a

File tree

11 files changed

+196
-26
lines changed

11 files changed

+196
-26
lines changed

flow-server/src/main/java/com/vaadin/flow/server/VaadinResponse.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ public interface VaadinResponse {
6969
*/
7070
void setHeader(String name, String value);
7171

72+
/**
73+
* Checks if a response header with the given name has been set.
74+
*
75+
* @param name
76+
* the name of the header
77+
* @return {@code true} if the header has been set, {@code false} otherwise
78+
*
79+
* @see jakarta.servlet.http.HttpServletResponse#containsHeader(String)
80+
*/
81+
boolean containsHeader(String name);
82+
7283
/**
7384
* Properly formats a timestamp as a date header. If the header had already
7485
* been set, the new value overwrites the previous one.

flow-server/src/main/java/com/vaadin/flow/server/streams/ClassDownloadHandler.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,10 @@ public void handleDownloadRequest(DownloadEvent downloadEvent)
101101
String resourceName = getUrlPostfix();
102102
downloadEvent.setContentType(
103103
getContentType(resourceName, downloadEvent.getResponse()));
104-
if (!isInline()) {
105-
downloadEvent.setFileName(resourceName);
104+
if (isInline()) {
105+
downloadEvent.inline(resourceName);
106106
} else {
107-
downloadEvent.getResponse().setHeader("Content-Disposition",
108-
"inline");
107+
downloadEvent.setFileName(resourceName);
109108
}
110109
TransferUtil.transfer(inputStream, outputStream,
111110
getTransferContext(downloadEvent), getListeners());

flow-server/src/main/java/com/vaadin/flow/server/streams/DownloadEvent.java

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,19 @@ public VaadinSession getSession() {
138138
* <p>
139139
* If the <code>fileName</code> is <code>null</code>, the
140140
* Content-Disposition header won't be set.
141+
* <p>
142+
* If the Content-Disposition header has already been set, this method will
143+
* not override it.
141144
*
142145
* @param fileName
143146
* the name to be assigned to the file
144147
*/
145148
public void setFileName(String fileName) {
146-
if (fileName == null) {
149+
if (fileName == null
150+
|| response.containsHeader("Content-Disposition")) {
147151
return;
148152
}
149-
if (fileName.isEmpty()) {
153+
if (fileName.isBlank()) {
150154
response.setHeader("Content-Disposition", "attachment");
151155
} else {
152156
StringBuilder value = new StringBuilder();
@@ -167,6 +171,62 @@ public void setFileName(String fileName) {
167171
this.fileName = fileName;
168172
}
169173

174+
/**
175+
* Sets the Content-Disposition header to inline, allowing the content to be
176+
* displayed directly in the browser instead of being downloaded.
177+
* <p>
178+
* To be called before the response is committed.
179+
* <p>
180+
* If the Content-Disposition header has already been set, this method will
181+
* not override it.
182+
*/
183+
public void inline() {
184+
if (!response.containsHeader("Content-Disposition")) {
185+
response.setHeader("Content-Disposition", "inline");
186+
}
187+
}
188+
189+
/**
190+
* Sets the Content-Disposition header to inline with a filename, allowing
191+
* the content to be displayed directly in the browser with a suggested
192+
* filename if the user chooses to save it.
193+
* <p>
194+
* To be called before the response is committed.
195+
* <p>
196+
* If the <code>fileName</code> is <code>null</code> or blank, this behaves
197+
* the same as calling {@link #inline()}.
198+
* <p>
199+
* If the Content-Disposition header has already been set, this method will
200+
* not override it.
201+
*
202+
* @param fileName
203+
* the suggested name for the file if saved by the user
204+
*/
205+
public void inline(String fileName) {
206+
if (response.containsHeader("Content-Disposition")) {
207+
return;
208+
}
209+
if (fileName == null || fileName.isBlank()) {
210+
response.setHeader("Content-Disposition", "inline");
211+
} else {
212+
StringBuilder value = new StringBuilder();
213+
value.append("inline; ");
214+
if (EncodeUtil.isPureUSASCII(fileName)) {
215+
value.append("filename=\"").append(fileName).append("\"");
216+
} else {
217+
value
218+
// fallback legacy support
219+
.append("filename=\"")
220+
.append(EncodeUtil.rfc2047Encode(fileName))
221+
// used primarily
222+
.append("\"; filename*=UTF-8''")
223+
.append(EncodeUtil.rfc5987Encode(fileName));
224+
}
225+
response.setHeader("Content-Disposition", value.toString());
226+
}
227+
this.fileName = fileName;
228+
}
229+
170230
/**
171231
* Sets the content type for the current download. These methods utilize the
172232
* HTTP Content-Type header to specify the type of content being sent to the

flow-server/src/main/java/com/vaadin/flow/server/streams/FileDownloadHandler.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,10 @@ public void handleDownloadRequest(DownloadEvent downloadEvent)
7777
try (OutputStream outputStream = downloadEvent.getOutputStream();
7878
FileInputStream inputStream = new FileInputStream(file)) {
7979
String resourceName = getUrlPostfix();
80-
if (!isInline()) {
81-
downloadEvent.setFileName(resourceName);
80+
if (isInline()) {
81+
downloadEvent.inline(resourceName);
8282
} else {
83-
downloadEvent.getResponse().setHeader("Content-Disposition",
84-
"inline");
83+
downloadEvent.setFileName(resourceName);
8584
}
8685
downloadEvent
8786
.setContentType(getContentType(resourceName, response));

flow-server/src/main/java/com/vaadin/flow/server/streams/InputStreamDownloadHandler.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,10 @@ public void handleDownloadRequest(DownloadEvent downloadEvent)
8989
: download.getContentType();
9090
downloadEvent.setContentType(contentType);
9191

92-
if (!isInline()) {
93-
downloadEvent.setFileName(downloadName);
92+
if (isInline()) {
93+
downloadEvent.inline(downloadName);
9494
} else {
95-
downloadEvent.getResponse().setHeader("Content-Disposition",
96-
"inline");
95+
downloadEvent.setFileName(downloadName);
9796
}
9897

9998
try (OutputStream outputStream = downloadEvent.getOutputStream();

flow-server/src/main/java/com/vaadin/flow/server/streams/ServletResourceDownloadHandler.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,10 @@ public void handleDownloadRequest(DownloadEvent downloadEvent)
8585
String resourceName = getUrlPostfix();
8686
downloadEvent
8787
.setContentType(getContentType(resourceName, response));
88-
if (!isInline()) {
89-
downloadEvent.setFileName(resourceName);
88+
if (isInline()) {
89+
downloadEvent.inline(resourceName);
9090
} else {
91-
downloadEvent.getResponse().setHeader("Content-Disposition",
92-
"inline");
91+
downloadEvent.setFileName(resourceName);
9392
}
9493
TransferUtil.transfer(inputStream, outputStream,
9594
getTransferContext(downloadEvent), getListeners());

flow-server/src/test/java/com/vaadin/flow/server/streams/ClassDownloadHandlerTest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ public void attachment_doesNotSetFileNameWhenInlined() throws IOException {
226226
}
227227

228228
@Test
229-
public void handleSetToInline_contentTypeIsInline() throws IOException {
229+
public void handleSetToInline_contentDispositionIsInlineWithFilename()
230+
throws IOException {
230231
DownloadHandler handler = DownloadHandler.forClassResource(
231232
this.getClass(), PATH_TO_FILE, "my-download.pdf").inline();
232233

@@ -239,6 +240,7 @@ public void handleSetToInline_contentTypeIsInline() throws IOException {
239240

240241
handler.handleDownloadRequest(event);
241242

242-
Mockito.verify(response).setHeader("Content-Disposition", "inline");
243+
Mockito.verify(response).setHeader("Content-Disposition",
244+
"inline; filename=\"my-download.pdf\"");
243245
}
244246
}

flow-server/src/test/java/com/vaadin/flow/server/streams/DownloadEventTest.java

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ public void setFileName_emptyFileName_setsContentDispositionToResponse() {
6868
Mockito.verify(response).setHeader("Content-Disposition", "attachment");
6969
}
7070

71+
@Test
72+
public void setFileName_blankFileName_setsContentDispositionToResponse() {
73+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
74+
session, null);
75+
String fileName = " ";
76+
downloadEvent.setFileName(fileName);
77+
Mockito.verify(response).setHeader("Content-Disposition", "attachment");
78+
}
79+
7180
@Test
7281
public void setFileName_nullFileName_doesNotSetContentDispositionToResponse() {
7382
DownloadEvent downloadEvent = new DownloadEvent(request, response,
@@ -77,6 +86,93 @@ public void setFileName_nullFileName_doesNotSetContentDispositionToResponse() {
7786
.setHeader(Mockito.anyString(), Mockito.anyString());
7887
}
7988

89+
@Test
90+
public void setFileName_headerAlreadySet_doesNotOverrideHeader() {
91+
Mockito.when(response.containsHeader("Content-Disposition"))
92+
.thenReturn(true);
93+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
94+
session, null);
95+
downloadEvent.setFileName("test.txt");
96+
Mockito.verify(response, Mockito.times(0)).setHeader(
97+
Mockito.eq("Content-Disposition"), Mockito.anyString());
98+
}
99+
100+
@Test
101+
public void inline_noFileName_setsContentDispositionInlineToResponse() {
102+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
103+
session, null);
104+
downloadEvent.inline();
105+
Mockito.verify(response).setHeader("Content-Disposition", "inline");
106+
}
107+
108+
@Test
109+
public void inline_withASCIIFileName_setsContentDispositionInlineWithFilenameToResponse() {
110+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
111+
session, null);
112+
String fileName = "document.pdf";
113+
downloadEvent.inline(fileName);
114+
Mockito.verify(response).setHeader("Content-Disposition",
115+
"inline; filename=\"" + fileName + "\"");
116+
}
117+
118+
@Test
119+
public void inline_withNonASCIIFileName_setsContentDispositionInlineWithEncodedFilenameToResponse() {
120+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
121+
session, null);
122+
String fileName = "dökümänt üñîçødë.pdf";
123+
downloadEvent.inline(fileName);
124+
Mockito.verify(response).setHeader("Content-Disposition",
125+
"inline;" + " filename=\"" + EncodeUtil.rfc2047Encode(fileName)
126+
+ "\";" + " filename*=UTF-8''"
127+
+ EncodeUtil.rfc5987Encode(fileName));
128+
}
129+
130+
@Test
131+
public void inline_nullFileName_setsContentDispositionInlineToResponse() {
132+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
133+
session, null);
134+
downloadEvent.inline(null);
135+
Mockito.verify(response).setHeader("Content-Disposition", "inline");
136+
}
137+
138+
@Test
139+
public void inline_emptyFileName_setsContentDispositionInlineToResponse() {
140+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
141+
session, null);
142+
downloadEvent.inline("");
143+
Mockito.verify(response).setHeader("Content-Disposition", "inline");
144+
}
145+
146+
@Test
147+
public void inline_blankFileName_setsContentDispositionInlineToResponse() {
148+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
149+
session, null);
150+
downloadEvent.inline(" ");
151+
Mockito.verify(response).setHeader("Content-Disposition", "inline");
152+
}
153+
154+
@Test
155+
public void inline_headerAlreadySet_doesNotOverrideHeader() {
156+
Mockito.when(response.containsHeader("Content-Disposition"))
157+
.thenReturn(true);
158+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
159+
session, null);
160+
downloadEvent.inline();
161+
Mockito.verify(response, Mockito.times(0)).setHeader(
162+
Mockito.eq("Content-Disposition"), Mockito.anyString());
163+
}
164+
165+
@Test
166+
public void inline_withFileNameHeaderAlreadySet_doesNotOverrideHeader() {
167+
Mockito.when(response.containsHeader("Content-Disposition"))
168+
.thenReturn(true);
169+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
170+
session, null);
171+
downloadEvent.inline("test.pdf");
172+
Mockito.verify(response, Mockito.times(0)).setHeader(
173+
Mockito.eq("Content-Disposition"), Mockito.anyString());
174+
}
175+
80176
@Test
81177
public void setContentType_setsContentTypeToResponse() {
82178
DownloadEvent downloadEvent = new DownloadEvent(request, response,

flow-server/src/test/java/com/vaadin/flow/server/streams/FileDownloadHandlerTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ public void attachment_doesNotSetFileNameWhenInlined()
239239
}
240240

241241
@Test
242-
public void handleSetToInline_contentTypeIsInline()
242+
public void handleSetToInline_contentDispositionIsInlineWithFilename()
243243
throws IOException, URISyntaxException {
244244
URL resource = getClass().getClassLoader().getResource(PATH_TO_FILE);
245245
DownloadHandler handler = DownloadHandler
@@ -255,6 +255,7 @@ public void handleSetToInline_contentTypeIsInline()
255255

256256
handler.handleDownloadRequest(event);
257257

258-
Mockito.verify(response).setHeader("Content-Disposition", "inline");
258+
Mockito.verify(response).setHeader("Content-Disposition",
259+
"inline; filename=\"my-download.bin\"");
259260
}
260261
}

flow-server/src/test/java/com/vaadin/flow/server/streams/InputStreamDownloadHandlerTest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,8 @@ public void downloadResponseNullContentType_fileTypeIsUsed()
423423
}
424424

425425
@Test
426-
public void handleSetToInline_contentTypeIsInline() throws IOException {
426+
public void handleSetToInline_contentDispositionIsInlineWithFilename()
427+
throws IOException {
427428
InputStream stream = Mockito.mock(InputStream.class);
428429
Mockito.when(
429430
stream.read(Mockito.any(), Mockito.anyInt(), Mockito.anyInt()))
@@ -442,7 +443,8 @@ public void handleSetToInline_contentTypeIsInline() throws IOException {
442443

443444
handler.handleDownloadRequest(event);
444445

445-
Mockito.verify(response).setHeader("Content-Disposition", "inline");
446+
Mockito.verify(response).setHeader("Content-Disposition",
447+
"inline; filename=\"download\"");
446448
}
447449

448450
private static byte[] getBytes() {

0 commit comments

Comments
 (0)