Skip to content

Commit 1e1b1fe

Browse files
milanmajchrakclaude
andcommitted
fix: use RFC 5987 Content-Disposition for allzip and download-by-handle
Ports the allzip fix from customer/zcu-data (#1267) to dtq-dev and aligns the fork's own endpoints with the encoding vanilla now uses. The allzip endpoint still built its header with a bare `attachment;filename="<name>"`, so item names with diacritics reached the browser mangled and names containing a double quote closed the quoted-string early (ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION). MetadataBitstreamController and BitstreamByHandleRestController have no counterpart upstream, so each carries its own private copy of vanilla's createFallbackAsciiName / createEncodedUtf8Name rather than a shared fork utility. Copying keeps every endpoint tracking upstream behaviour and adds no fork-invented API to maintain. HttpHeadersInitializer stays byte-identical to vanilla and keeps its own copy for the same reason. One deliberate deviation from vanilla, marked in both copies: the ASCII fallback escapes \ and ". Vanilla omits this, so a name containing a quote closes the quoted-string early — exactly the bug #1267 was raised for. Because the fallback now transliterates instead of blanking out, BitstreamByHandleRestControllerIT expects "Media (3).jfif" where it used to expect "M_di_ (3).jfif". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f317911 commit 1e1b1fe

4 files changed

Lines changed: 151 additions & 18 deletions

File tree

dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.net.URLEncoder;
1616
import java.nio.charset.StandardCharsets;
1717
import java.sql.SQLException;
18+
import java.text.Normalizer;
1819
import java.util.List;
1920
import java.util.Objects;
2021
import javax.servlet.http.HttpServletRequest;
@@ -279,25 +280,50 @@ private void redirectToS3DownloadUrl(String bitName, String bitInternalId,
279280
}
280281

281282
/**
282-
* Build a Content-Disposition header value using RFC 5987 encoding.
283-
* Includes both {@code filename} (ASCII fallback) and {@code filename*}
284-
* (UTF-8 percent-encoded) so that curl -J and browsers can save files
285-
* with non-ASCII characters in the name correctly.
286-
*
287-
* @param name the original filename
288-
* @return the Content-Disposition header value
283+
* Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII
284+
* fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in
285+
* {@code filename*} for everyone else. This endpoint has no upstream counterpart, so the logic
286+
* is copied from vanilla rather than shared, to keep it tracking upstream's behaviour.
287+
* curl -J on Windows cannot create files with non-ASCII characters from a raw UTF-8 header,
288+
* which is why this endpoint needs it too.
289289
*/
290290
private String buildContentDisposition(String name) {
291-
// RFC 5987 percent-encoding for filename*
292-
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8)
293-
.replace("+", "%20");
294-
// ASCII fallback: replace non-ASCII chars with underscore, escape quotes.
295-
// Modern clients use filename* (RFC 5987 / RFC 6266) with real UTF-8 name.
296-
String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_")
291+
return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
292+
createFallbackAsciiName(name), createEncodedUtf8Name(name));
293+
}
294+
295+
/**
296+
* Creates a safe ASCII-only fallback filename by removing diacritics (accents)
297+
* and replacing any remaining non-ASCII characters.
298+
* E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf".
299+
* @param originalFilename The original filename.
300+
* @return A string containing only ASCII characters.
301+
*/
302+
private String createFallbackAsciiName(String originalFilename) {
303+
if (originalFilename == null) {
304+
return "";
305+
}
306+
String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD);
307+
String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
308+
// Deviates from vanilla by escaping \ and ": the value is a quoted-string, and a name
309+
// containing a quote closes it early. That is the bug #1267 fixed; vanilla still has it.
310+
return withoutAccents.replaceAll("[^\\x00-\\x7F]", "")
297311
.replace("\\", "\\\\")
298312
.replace("\"", "\\\"");
299-
return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
300-
asciiFallback, encoded);
313+
}
314+
315+
/**
316+
* Creates a percent-encoded UTF-8 filename according to RFC 5987.
317+
* This is for the `filename*` parameter.
318+
* E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf".
319+
* @param originalFilename The original filename.
320+
* @return A percent-encoded string.
321+
*/
322+
private String createEncodedUtf8Name(String originalFilename) {
323+
if (originalFilename == null) {
324+
return "";
325+
}
326+
return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20");
301327
}
302328

303329
/**

dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111

1212
import java.io.IOException;
1313
import java.io.InputStream;
14+
import java.net.URLEncoder;
15+
import java.nio.charset.StandardCharsets;
1416
import java.sql.SQLException;
17+
import java.text.Normalizer;
1518
import java.util.List;
1619
import java.util.Objects;
1720
import java.util.UUID;
@@ -115,7 +118,7 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S
115118
// This bitstream is used to get it's item in the statistics tracker
116119
Bitstream bitstreamForStatistics = null;
117120
name = item.getName() + ".zip";
118-
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment;filename=\"%s\"", name));
121+
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(name));
119122
response.setContentType("application/zip");
120123
List<Bundle> bundles = item.getBundles("ORIGINAL");
121124

@@ -143,4 +146,49 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S
143146
matomoBitstreamTracker.trackBitstreamDownload(context, request, bitstreamForStatistics, true);
144147
response.getOutputStream().flush();
145148
}
149+
150+
/**
151+
* Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII
152+
* fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in
153+
* {@code filename*} for everyone else. This endpoint has no upstream counterpart, so the logic
154+
* is copied from vanilla rather than shared, to keep it tracking upstream's behaviour.
155+
*/
156+
private String buildContentDisposition(String name) {
157+
return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
158+
createFallbackAsciiName(name), createEncodedUtf8Name(name));
159+
}
160+
161+
/**
162+
* Creates a safe ASCII-only fallback filename by removing diacritics (accents)
163+
* and replacing any remaining non-ASCII characters.
164+
* E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf".
165+
* @param originalFilename The original filename.
166+
* @return A string containing only ASCII characters.
167+
*/
168+
private String createFallbackAsciiName(String originalFilename) {
169+
if (originalFilename == null) {
170+
return "";
171+
}
172+
String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD);
173+
String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
174+
// Deviates from vanilla by escaping \ and ": the value is a quoted-string, and an item name
175+
// containing a quote closes it early. That is the bug #1267 fixed; vanilla still has it.
176+
return withoutAccents.replaceAll("[^\\x00-\\x7F]", "")
177+
.replace("\\", "\\\\")
178+
.replace("\"", "\\\"");
179+
}
180+
181+
/**
182+
* Creates a percent-encoded UTF-8 filename according to RFC 5987.
183+
* This is for the `filename*` parameter.
184+
* E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf".
185+
* @param originalFilename The original filename.
186+
* @return A percent-encoded string.
187+
*/
188+
private String createEncodedUtf8Name(String originalFilename) {
189+
if (originalFilename == null) {
190+
return "";
191+
}
192+
return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20");
193+
}
146194
}

dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,8 @@ public void downloadBitstreamByHandleUtf8Filename() throws Exception {
260260
+ "/M%C3%A9di%C3%A1%20(3).jfif")))
261261
.andExpect(status().isOk())
262262
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
263-
// ASCII fallback replaces non-ASCII with underscore; filename* has UTF-8 encoding
264-
equalTo("attachment; filename=\"M_di_ (3).jfif\"; "
263+
// ASCII fallback transliterates the diacritics away; filename* keeps the real name
264+
equalTo("attachment; filename=\"Media (3).jfif\"; "
265265
+ "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif")))
266266
.andExpect(content().string(bitstreamContent));
267267
}

dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import static org.junit.Assert.assertEquals;
1111
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
12+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
1213
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
1314

1415
import java.io.ByteArrayInputStream;
@@ -104,4 +105,62 @@ public void downloadAllZip() throws Exception {
104105
assertEquals(Set.of(bts.getName()), entries.keySet());
105106
assertEquals(BITSTREAM_CONTENT, entries.get(bts.getName()));
106107
}
108+
109+
@Test
110+
public void downloadAllZipWithDoubleQuotesInItemName() throws Exception {
111+
context.turnOffAuthorisationSystem();
112+
113+
// Double quotes in the name used to close the header's quoted-string early, which browsers
114+
// reported as ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION.
115+
Item itemWithQuotes = ItemBuilder.createItem(context, col)
116+
.withTitle("Supported data for manuscript \"Thermally-induced evolution\"")
117+
.withAuthor(AUTHOR)
118+
.build();
119+
120+
try (InputStream is = IOUtils.toInputStream("QuotedItemContent", CharEncoding.UTF_8)) {
121+
BitstreamBuilder.createBitstream(context, itemWithQuotes, is)
122+
.withName("data.csv")
123+
.withMimeType("text/csv")
124+
.build();
125+
}
126+
context.restoreAuthSystemState();
127+
128+
String token = getAuthToken(admin.getEmail(), password);
129+
getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithQuotes.getID() +
130+
"/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithQuotes.getHandle()))
131+
.andExpect(status().isOk())
132+
.andExpect(header().string("Content-Disposition",
133+
"attachment; filename=\"Supported data for manuscript"
134+
+ " \\\"Thermally-induced evolution\\\".zip\";"
135+
+ " filename*=UTF-8''Supported%20data%20for%20manuscript"
136+
+ "%20%22Thermally-induced%20evolution%22.zip"));
137+
}
138+
139+
@Test
140+
public void downloadAllZipWithNonAsciiItemName() throws Exception {
141+
context.turnOffAuthorisationSystem();
142+
143+
Item itemWithDiacritics = ItemBuilder.createItem(context, col)
144+
.withTitle("Příliš žluťoučký kůň")
145+
.withAuthor(AUTHOR)
146+
.build();
147+
148+
try (InputStream is = IOUtils.toInputStream("DiacriticsContent", CharEncoding.UTF_8)) {
149+
BitstreamBuilder.createBitstream(context, itemWithDiacritics, is)
150+
.withName("file.txt")
151+
.withMimeType("text/plain")
152+
.build();
153+
}
154+
context.restoreAuthSystemState();
155+
156+
String token = getAuthToken(admin.getEmail(), password);
157+
getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithDiacritics.getID() +
158+
"/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithDiacritics.getHandle()))
159+
.andExpect(status().isOk())
160+
// fallback transliterates the diacritics away; filename* carries the real name
161+
.andExpect(header().string("Content-Disposition",
162+
"attachment; filename=\"Prilis zlutoucky kun.zip\";"
163+
+ " filename*=UTF-8''P%C5%99%C3%ADli%C5%A1%20%C5%BElu%C5%A5ou%C4%8Dk%C3%BD"
164+
+ "%20k%C5%AF%C5%88.zip"));
165+
}
107166
}

0 commit comments

Comments
 (0)