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 b20d721e6420..e310b706dd2e 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 @@ -14,6 +14,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; @@ -202,25 +203,50 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, /** - * 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. Vanilla still has that bug. + 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 581a6aae0ed2..4b5985df7716 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; @@ -114,7 +117,7 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S Item item = (Item) dso; 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"); @@ -142,4 +145,49 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S zip.close(); 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. Vanilla still has that bug. + 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/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java index f74c41471780..8e288a2ae0eb 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 @@ -284,7 +284,9 @@ private String createFallbackAsciiName(String originalFilename) { } String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); - return withoutAccents.replaceAll("[^\\x00-\\x7F]", ""); + return withoutAccents.replaceAll("[^\\x00-\\x7F]", "") + .replace("\\", "\\\\") + .replace("\"", "\\\""); } /** 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 52909c42b6ec..297723a58e42 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)); } @@ -512,8 +512,8 @@ public void downloadBitstreamByHandleCjkFilename() throws Exception { + "/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"))) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, - // CJK chars replaced with _ in ASCII fallback; filename* has UTF-8 encoding - equalTo("attachment; filename=\"___.txt\"; " + // CJK has no ASCII decomposition, so it drops out of the fallback entirely + equalTo("attachment; filename=\".txt\"; " + "filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"))) .andExpect(content().string(bitstreamContent)); } @@ -592,7 +592,7 @@ public void downloadBitstreamByHandleComplexFilename() throws Exception { + "/M%C3%A9di%C3%A1%20(%2B)%239)%20ano"))) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, - equalTo("attachment; filename=\"M_di_ (+)#9) ano\"; " + equalTo("attachment; filename=\"Media (+)#9) ano\"; " + "filename*=UTF-8''M%C3%A9di%C3%A1%20%28%2B%29%239%29%20ano"))) .andExpect(content().string(bitstreamContent)); } 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 b4542dacb22e..b410b1cf35e5 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 @@ -374,9 +374,8 @@ public void testBitstreamName() throws Exception { String bitstreamContent = "0123456789"; 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"; + 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)) { @@ -406,6 +405,48 @@ public void testBitstreamName() throws Exception { )); } + @Test + public void testBitstreamNameWithQuote() throws Exception { + + context.turnOffAuthorisationSystem(); + + parentCommunity = CommunityBuilder + .createCommunity(context) + .build(); + + Collection collection = CollectionBuilder + .createCollection(context, parentCommunity) + .build(); + + String bitstreamContent = "0123456789"; + String bitstreamName = "file \"quoted\".txt"; + String expectedAscii = "file \\\"quoted\\\".txt"; + String expectedUtf8Encoded = "file%20%22quoted%22.txt"; + + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + + Item item = ItemBuilder + .createItem(context, collection) + .build(); + + bitstream = BitstreamBuilder + .createBitstream(context, item, is) + .withName(bitstreamName) + .build(); + } + + context.restoreAuthSystemState(); + + getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content")) + .andExpect(status().isOk()) + .andExpect(header().string( + "Content-Disposition", + String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + expectedAscii, + expectedUtf8Encoded) + )); + } + @Test public void testBitstreamNotFound() throws Exception { getClient().perform(get("/api/core/bitstreams/" + UUID.randomUUID() + "/content")) 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 7fb2f4cecdf8..3dde5b78931d 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")); + } }