From f3179117d46a466c30289c2679b408389bd78e1f Mon Sep 17 00:00:00 2001 From: JohnnyMendesC <177888064+JohnnyMendesC@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:10:46 -0300 Subject: [PATCH 1/3] fix(#11191): Align Content-Disposition with RFC 5987/6266 (cherry picked from commit fe4077acee30fac8fb5607821a7d6e1ee3bd9d88) --- .../rest/utils/HttpHeadersInitializer.java | 54 +++++++++++++++++-- .../app/rest/BitstreamRestControllerIT.java | 11 ++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java index d68c710a3c7..67ad7202b5a 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java @@ -9,9 +9,11 @@ import static java.util.Objects.isNull; import static java.util.Objects.nonNull; -import static javax.mail.internet.MimeUtility.encodeText; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.util.Arrays; import java.util.Collections; import java.util.Objects; @@ -171,9 +173,16 @@ public HttpHeaders initialiseHeaders() throws IOException { // distposition may be null here if contentType is null if (!isNullOrEmpty(disposition)) { - httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(String.format(CONTENT_DISPOSITION_FORMAT, - disposition, - encodeText(fileName)))); + String fallbackAsciiName = createFallbackAsciiName(this.fileName); + String encodedUtf8Name = createEncodedUtf8Name(this.fileName); + + String headerValue = String.format( + "%s; filename=\"%s\"; filename*=UTF-8''%s", + disposition, + fallbackAsciiName, + encodedUtf8Name + ); + httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(headerValue)); } log.debug("Content-Disposition : {}", disposition); @@ -261,4 +270,41 @@ private static boolean matches(String matchHeader, String toMatch) { return Arrays.binarySearch(matchValues, toMatch) > -1 || Arrays.binarySearch(matchValues, "*") > -1; } + /** + * Creates a safe ASCII-only fallback filename by removing diacritics (accents) + * and replacing any remaining non-ASCII characters. + * E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf". + * @param originalFilename The original filename. + * @return A string containing only ASCII characters. + */ + private String createFallbackAsciiName(String originalFilename) { + if (originalFilename == null) { + return ""; + } + String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); + String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + return withoutAccents.replaceAll("[^\\x00-\\x7F]", ""); + } + + /** + * Creates a percent-encoded UTF-8 filename according to RFC 5987. + * This is for the `filename*` parameter. + * E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf". + * @param originalFilename The original filename. + * @return A percent-encoded string. + */ + private String createEncodedUtf8Name(String originalFilename) { + if (originalFilename == null) { + return ""; + } + try { + String encoded = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.toString()); + return encoded.replace("+", "%20"); + } catch (java.io.UnsupportedEncodingException e) { + // Fallback to a simple ASCII name if encoding fails. + log.error("UTF-8 encoding not supported, which should not happen.", e); + return createFallbackAsciiName(originalFilename); + } + } + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java index e0c64a71d07..03e28cdeedd 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java @@ -8,7 +8,6 @@ package org.dspace.app.rest; import static java.util.UUID.randomUUID; -import static javax.mail.internet.MimeUtility.encodeText; import static org.apache.commons.codec.CharEncoding.UTF_8; import static org.apache.commons.collections.CollectionUtils.isEmpty; import static org.apache.commons.io.IOUtils.toInputStream; @@ -364,7 +363,11 @@ public void testBitstreamName() throws Exception { //2. A public item with a bitstream String bitstreamContent = "0123456789"; - String bitstreamName = "ภาษาไทย"; + String bitstreamName = "ภาษาไทย-com-acentuação.pdf"; + String expectedAscii = "-com-acentuacao.pdf"; + String expectedUtf8Encoded = + "%E0%B8%A0%E0%B8%B2%E0%B8%A9%E0%B8%B2%E0%B9%84%E0%B8%97%E0%B8%A2-" + + "com-acentua%C3%A7%C3%A3o.pdf"; try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { @@ -388,7 +391,9 @@ public void testBitstreamName() throws Exception { //We expect the content disposition to have the encoded bitstream name .andExpect(header().string( "Content-Disposition", - "attachment;filename=\"" + encodeText(bitstreamName) + "\"" + String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + expectedAscii, + expectedUtf8Encoded) )); } From 1e1b1fea5f0437b8fa43a82af6d839a406eee71f Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 16 Jul 2026 12:59:49 +0200 Subject: [PATCH 2/3] fix: use RFC 5987 Content-Disposition for allzip and download-by-handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=""`, 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) --- .../rest/BitstreamByHandleRestController.java | 56 +++++++++++++----- .../app/rest/MetadataBitstreamController.java | 50 +++++++++++++++- .../BitstreamByHandleRestControllerIT.java | 4 +- .../rest/MetadataBitstreamControllerIT.java | 59 +++++++++++++++++++ 4 files changed, 151 insertions(+), 18 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java index 36cdffc9b20..52209ec5e9e 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java @@ -15,6 +15,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.text.Normalizer; import java.util.List; import java.util.Objects; import javax.servlet.http.HttpServletRequest; @@ -279,25 +280,50 @@ private void redirectToS3DownloadUrl(String bitName, String bitInternalId, } /** - * Build a Content-Disposition header value using RFC 5987 encoding. - * Includes both {@code filename} (ASCII fallback) and {@code filename*} - * (UTF-8 percent-encoded) so that curl -J and browsers can save files - * with non-ASCII characters in the name correctly. - * - * @param name the original filename - * @return the Content-Disposition header value + * Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII + * fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in + * {@code filename*} for everyone else. This endpoint has no upstream counterpart, so the logic + * is copied from vanilla rather than shared, to keep it tracking upstream's behaviour. + * curl -J on Windows cannot create files with non-ASCII characters from a raw UTF-8 header, + * which is why this endpoint needs it too. */ private String buildContentDisposition(String name) { - // RFC 5987 percent-encoding for filename* - String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) - .replace("+", "%20"); - // ASCII fallback: replace non-ASCII chars with underscore, escape quotes. - // Modern clients use filename* (RFC 5987 / RFC 6266) with real UTF-8 name. - String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_") + return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + createFallbackAsciiName(name), createEncodedUtf8Name(name)); + } + + /** + * Creates a safe ASCII-only fallback filename by removing diacritics (accents) + * and replacing any remaining non-ASCII characters. + * E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf". + * @param originalFilename The original filename. + * @return A string containing only ASCII characters. + */ + private String createFallbackAsciiName(String originalFilename) { + if (originalFilename == null) { + return ""; + } + String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); + String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + // Deviates from vanilla by escaping \ and ": the value is a quoted-string, and a name + // containing a quote closes it early. That is the bug #1267 fixed; vanilla still has it. + return withoutAccents.replaceAll("[^\\x00-\\x7F]", "") .replace("\\", "\\\\") .replace("\"", "\\\""); - return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", - asciiFallback, encoded); + } + + /** + * Creates a percent-encoded UTF-8 filename according to RFC 5987. + * This is for the `filename*` parameter. + * E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf". + * @param originalFilename The original filename. + * @return A percent-encoded string. + */ + private String createEncodedUtf8Name(String originalFilename) { + if (originalFilename == null) { + return ""; + } + return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20"); } /** diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java index 917a590caf6..b52608da623 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java @@ -11,7 +11,10 @@ import java.io.IOException; import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.text.Normalizer; import java.util.List; import java.util.Objects; import java.util.UUID; @@ -115,7 +118,7 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S // This bitstream is used to get it's item in the statistics tracker Bitstream bitstreamForStatistics = null; name = item.getName() + ".zip"; - response.setHeader(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment;filename=\"%s\"", name)); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(name)); response.setContentType("application/zip"); List bundles = item.getBundles("ORIGINAL"); @@ -143,4 +146,49 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S matomoBitstreamTracker.trackBitstreamDownload(context, request, bitstreamForStatistics, true); response.getOutputStream().flush(); } + + /** + * Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII + * fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in + * {@code filename*} for everyone else. This endpoint has no upstream counterpart, so the logic + * is copied from vanilla rather than shared, to keep it tracking upstream's behaviour. + */ + private String buildContentDisposition(String name) { + return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + createFallbackAsciiName(name), createEncodedUtf8Name(name)); + } + + /** + * Creates a safe ASCII-only fallback filename by removing diacritics (accents) + * and replacing any remaining non-ASCII characters. + * E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf". + * @param originalFilename The original filename. + * @return A string containing only ASCII characters. + */ + private String createFallbackAsciiName(String originalFilename) { + if (originalFilename == null) { + return ""; + } + String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); + String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + // Deviates from vanilla by escaping \ and ": the value is a quoted-string, and an item name + // containing a quote closes it early. That is the bug #1267 fixed; vanilla still has it. + return withoutAccents.replaceAll("[^\\x00-\\x7F]", "") + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } + + /** + * Creates a percent-encoded UTF-8 filename according to RFC 5987. + * This is for the `filename*` parameter. + * E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf". + * @param originalFilename The original filename. + * @return A percent-encoded string. + */ + private String createEncodedUtf8Name(String originalFilename) { + if (originalFilename == null) { + return ""; + } + return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20"); + } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java index 52909c42b6e..bbf3c6df7b4 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java @@ -260,8 +260,8 @@ public void downloadBitstreamByHandleUtf8Filename() throws Exception { + "/M%C3%A9di%C3%A1%20(3).jfif"))) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, - // ASCII fallback replaces non-ASCII with underscore; filename* has UTF-8 encoding - equalTo("attachment; filename=\"M_di_ (3).jfif\"; " + // ASCII fallback transliterates the diacritics away; filename* keeps the real name + equalTo("attachment; filename=\"Media (3).jfif\"; " + "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif"))) .andExpect(content().string(bitstreamContent)); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java index 7fb2f4cecdf..3dde5b78931 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java @@ -9,6 +9,7 @@ import static org.junit.Assert.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.io.ByteArrayInputStream; @@ -104,4 +105,62 @@ public void downloadAllZip() throws Exception { assertEquals(Set.of(bts.getName()), entries.keySet()); assertEquals(BITSTREAM_CONTENT, entries.get(bts.getName())); } + + @Test + public void downloadAllZipWithDoubleQuotesInItemName() throws Exception { + context.turnOffAuthorisationSystem(); + + // Double quotes in the name used to close the header's quoted-string early, which browsers + // reported as ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION. + Item itemWithQuotes = ItemBuilder.createItem(context, col) + .withTitle("Supported data for manuscript \"Thermally-induced evolution\"") + .withAuthor(AUTHOR) + .build(); + + try (InputStream is = IOUtils.toInputStream("QuotedItemContent", CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, itemWithQuotes, is) + .withName("data.csv") + .withMimeType("text/csv") + .build(); + } + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithQuotes.getID() + + "/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithQuotes.getHandle())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"Supported data for manuscript" + + " \\\"Thermally-induced evolution\\\".zip\";" + + " filename*=UTF-8''Supported%20data%20for%20manuscript" + + "%20%22Thermally-induced%20evolution%22.zip")); + } + + @Test + public void downloadAllZipWithNonAsciiItemName() throws Exception { + context.turnOffAuthorisationSystem(); + + Item itemWithDiacritics = ItemBuilder.createItem(context, col) + .withTitle("Příliš žluťoučký kůň") + .withAuthor(AUTHOR) + .build(); + + try (InputStream is = IOUtils.toInputStream("DiacriticsContent", CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, itemWithDiacritics, is) + .withName("file.txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithDiacritics.getID() + + "/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithDiacritics.getHandle())) + .andExpect(status().isOk()) + // fallback transliterates the diacritics away; filename* carries the real name + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"Prilis zlutoucky kun.zip\";" + + " filename*=UTF-8''P%C5%99%C3%ADli%C5%A1%20%C5%BElu%C5%A5ou%C4%8Dk%C3%BD" + + "%20k%C5%AF%C5%88.zip")); + } } From e99c57de5cba88f1ed1272cf3928e448e09277d5 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 16 Jul 2026 11:45:04 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20S3=20presigned-URL=20Content-Disposi?= =?UTF-8?q?tion=20=E2=80=94=20spaces=20became=20'+',=20raw=20non-ASCII=20i?= =?UTF-8?q?n=20filename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S3 direct-download path built its own header and had two defects: - URLEncoder.encode without the "+" -> "%20" fixup, so a space in the name arrived as a literal plus sign in filename* - the ASCII fallback only replaced CR, LF and quotes, leaving raw non-ASCII bytes in the filename param, which is ISO-8859-1 only Both are fixed by copying vanilla's createFallbackAsciiName / createEncodedUtf8Name into this class, the same way the other fork-only endpoints do. This class has no upstream counterpart, so copying costs nothing and keeps its behaviour tracking vanilla. Two deviations from vanilla are kept, both of them behaviour this class already had and both marked in the code: CR/LF are dropped to prevent header injection, and \ and " are escaped so a quoted name cannot close the string early. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bitstore/S3DirectDownloadServiceImpl.java | 59 ++++++++++++++++--- .../bitstore/S3DirectDownloadServiceTest.java | 13 ++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java index 455f34d8f01..e2d3d98fbdd 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.time.Instant; import java.util.Date; @@ -79,15 +80,8 @@ public String generatePresignedUrl(String bucket, String key, int expirationSeco .withMethod(HttpMethod.GET) .withExpiration(expiration); // Add custom response header for filename - to download the file with the desired name - // Remove CRLF and quotes to prevent header injection - String safeName = desiredFilename.replaceAll("[\\r\\n\"]", "_"); - // RFC-5987: percent-encode UTF-8, e.g. filename*=UTF-8''%E2%82%ACrates.txt - String encoded = URLEncoder.encode(desiredFilename, StandardCharsets.UTF_8); - String contentDisposition = String.format( - "attachment; filename=\"%s\"; filename*=UTF-8''%s", - safeName, encoded); - - request.addRequestParameter("response-content-disposition", contentDisposition); + request.addRequestParameter("response-content-disposition", + buildContentDisposition(desiredFilename)); try { return s3Client.generatePresignedUrl(request).toString(); } catch (Exception e) { @@ -95,4 +89,51 @@ public String generatePresignedUrl(String bucket, String key, int expirationSeco throw new RuntimeException("Failed to generate presigned URL", e); } } + + /** + * Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII + * fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in + * {@code filename*} for everyone else. S3 direct download has no upstream counterpart, so the + * logic is copied from vanilla rather than shared. + */ + private String buildContentDisposition(String name) { + return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + createFallbackAsciiName(name), createEncodedUtf8Name(name)); + } + + /** + * Creates a safe ASCII-only fallback filename by removing diacritics (accents) + * and replacing any remaining non-ASCII characters. + * E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf". + * @param originalFilename The original filename. + * @return A string containing only ASCII characters. + */ + private String createFallbackAsciiName(String originalFilename) { + if (originalFilename == null) { + return ""; + } + String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); + String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + // Two deviations from vanilla, both of them behaviour this class already had: CR/LF are + // dropped so a crafted name cannot inject a header, and \ and " are escaped so a name + // containing a quote cannot close the quoted-string early. + return withoutAccents.replaceAll("[^\\x00-\\x7F]", "") + .replaceAll("[\\r\\n]", "") + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } + + /** + * Creates a percent-encoded UTF-8 filename according to RFC 5987. + * This is for the `filename*` parameter. + * E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf". + * @param originalFilename The original filename. + * @return A percent-encoded string. + */ + private String createEncodedUtf8Name(String originalFilename) { + if (originalFilename == null) { + return ""; + } + return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20"); + } } diff --git a/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java b/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java index 906cdb04a1b..1b6774fd437 100644 --- a/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java +++ b/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java @@ -139,6 +139,19 @@ public void weirdFilename() throws Exception { assertTrue(cd.contains("UTF-8")); } + // Spaces must be %20 in filename*, not '+' — URLEncoder is form-encoding and differs from RFC 5987 here + @Test + public void spacesAndDiacriticsInFilename() throws Exception { + URL fake = new URL("https://spaces"); + when(amazonS3.generatePresignedUrl(any(GeneratePresignedUrlRequest.class))).thenReturn(fake); + + s3DirectDownloadService.generatePresignedUrl("b", "k", 60, "Příliš žluťoučký kůň.txt"); + String cd = captureRequest().getRequestParameters().get("response-content-disposition"); + + assertEquals("attachment; filename=\"Prilis zlutoucky kun.txt\"; " + + "filename*=UTF-8''P%C5%99%C3%ADli%C5%A1%20%C5%BElu%C5%A5ou%C4%8Dk%C3%BD%20k%C5%AF%C5%88.txt", cd); + } + // Underlying AmazonS3 throws → IllegalArgumentException @Test(expected = IllegalArgumentException.class) public void nullFilename() throws Exception {