From 72e25fb0824ad334316821698085fd68724bb28d Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Feb 2026 14:20:16 +0100 Subject: [PATCH 01/15] fix: add bitstream download-by-handle endpoint for curl instructions Adds GET /api/core/bitstreams/handle/{prefix}/{suffix}/{filename} endpoint that directly serves bitstream content by item handle and filename. This resolves the issue where curl download instructions generated by the UI produced URLs pointing to non-existent backend endpoints, resulting in 404 errors when users attempted to download files via command line. The new endpoint resolves the handle to an Item, finds the bitstream by exact filename in ORIGINAL bundles, and streams the raw content with correct Content-Type and Content-Disposition headers. Refs: dataquest-dev/dspace-angular#1210 --- .../rest/BitstreamByHandleRestController.java | 173 ++++++++++++ .../BitstreamByHandleRestControllerIT.java | 259 ++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java create mode 100644 dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java 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 new file mode 100644 index 000000000000..bb4760ae5513 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java @@ -0,0 +1,173 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest; + +import java.io.IOException; +import java.io.InputStream; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.dspace.app.rest.model.BitstreamRest; +import org.dspace.app.rest.utils.ContextUtil; +import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.service.AuthorizeService; +import org.dspace.content.Bitstream; +import org.dspace.content.BitstreamFormat; +import org.dspace.content.Bundle; +import org.dspace.content.DSpaceObject; +import org.dspace.content.Item; +import org.dspace.content.service.BitstreamService; +import org.dspace.core.Constants; +import org.dspace.core.Context; +import org.dspace.handle.service.HandleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +/** + * This controller provides a direct download endpoint for bitstreams + * identified by an Item handle and the bitstream filename. + * + * Endpoint: GET /api/core/bitstreams/handle/{prefix}/{suffix}/{filename} + * + * This is used by the command-line download instructions (curl commands) + * shown on the item page in the UI. + */ +@RestController +@RequestMapping("/api/" + BitstreamRest.CATEGORY + "/" + BitstreamRest.PLURAL_NAME + "/handle") +public class BitstreamByHandleRestController { + + private static final Logger log = + org.apache.logging.log4j.LogManager.getLogger(BitstreamByHandleRestController.class); + + private static final int BUFFER_SIZE = 4096 * 10; + + @Autowired + private BitstreamService bitstreamService; + + @Autowired + private HandleService handleService; + + @Autowired + private AuthorizeService authorizeService; + + /** + * Download a bitstream by item handle and filename. + * + * @param prefix the handle prefix (e.g. "11234") + * @param suffix the handle suffix (e.g. "1-5814") + * @param filename the bitstream filename (e.g. "pdtvallex-4.5.xml") + * @param request the HTTP request + * @param response the HTTP response + */ + @RequestMapping(method = {RequestMethod.GET, RequestMethod.HEAD}, + value = "/{prefix}/{suffix}/{filename:.+}") + public void downloadBitstreamByHandle(@PathVariable String prefix, + @PathVariable String suffix, + @PathVariable String filename, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + String handle = prefix + "/" + suffix; + + Context context = ContextUtil.obtainContext(request); + if (Objects.isNull(context)) { + log.error("Cannot obtain the context from the request."); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Cannot obtain the context from the request."); + return; + } + + try { + // Resolve handle to DSpaceObject + DSpaceObject dso = handleService.resolveToObject(context, handle); + if (Objects.isNull(dso) || !(dso instanceof Item)) { + log.warn("Handle '{}' does not resolve to a valid Item.", handle); + response.sendError(HttpServletResponse.SC_UNPROCESSABLE_ENTITY, + "Handle '" + handle + "' does not resolve to a valid item."); + return; + } + + Item item = (Item) dso; + Bitstream bitstream = findBitstreamByName(item, filename); + + if (bitstream == null) { + log.warn("No bitstream with name '{}' found for handle '{}'.", filename, handle); + response.sendError(HttpServletResponse.SC_NOT_FOUND, + "Bitstream '" + filename + "' not found for handle '" + handle + "'."); + return; + } + + // Check authorization + authorizeService.authorizeAction(context, bitstream, Constants.READ); + + // Retrieve content and stream it + BitstreamFormat format = bitstream.getFormat(context); + String mimeType = (format != null) ? format.getMIMEType() : "application/octet-stream"; + String name = StringUtils.isNotBlank(bitstream.getName()) + ? bitstream.getName() : bitstream.getID().toString(); + + response.setContentType(mimeType); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + String.format("attachment; filename=\"%s\"", name)); + long size = bitstream.getSizeBytes(); + if (size > 0) { + response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(size)); + } + + if (RequestMethod.HEAD.name().equals(request.getMethod())) { + // HEAD request — only headers, no body + return; + } + + try (InputStream is = bitstreamService.retrieve(context, bitstream)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, bytesRead); + } + response.getOutputStream().flush(); + } + } catch (AuthorizeException e) { + log.warn("Unauthorized access to bitstream '{}' for handle '{}'.", filename, handle); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, + "You are not authorized to download this file."); + } catch (SQLException e) { + log.error("Database error while downloading bitstream '{}' for handle '{}': {}", + filename, handle, e.getMessage()); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "An internal error occurred."); + } finally { + if (context != null && context.isValid()) { + context.complete(); + } + } + } + + /** + * Find a bitstream by name in the ORIGINAL bundles of an item. + */ + private Bitstream findBitstreamByName(Item item, String filename) { + List bundles = item.getBundles("ORIGINAL"); + for (Bundle bundle : bundles) { + for (Bitstream bitstream : bundle.getBitstreams()) { + if (StringUtils.equals(bitstream.getName(), filename)) { + return bitstream; + } + } + } + return null; + } +} 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 new file mode 100644 index 000000000000..a1e0720728dd --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java @@ -0,0 +1,259 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest; + +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.InputStream; + +import org.apache.commons.codec.CharEncoding; +import org.apache.commons.io.IOUtils; +import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.authorize.service.AuthorizeService; +import org.dspace.builder.BitstreamBuilder; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.builder.ResourcePolicyBuilder; +import org.dspace.content.Bitstream; +import org.dspace.content.Collection; +import org.dspace.content.Item; +import org.dspace.content.service.BitstreamService; +import org.dspace.core.Constants; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; + +/** + * Integration tests for {@link BitstreamByHandleRestController}. + */ +public class BitstreamByHandleRestControllerIT extends AbstractControllerIntegrationTest { + + private static final String ENDPOINT_BASE = "/api/core/bitstreams/handle"; + + @Autowired + AuthorizeService authorizeService; + + @Autowired + BitstreamService bitstreamService; + + @Test + public void downloadBitstreamByHandle() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String bitstreamContent = "TestBitstreamContent"; + Bitstream bitstream; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, item, is) + .withName("testfile.txt") + .withDescription("A test file") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/testfile.txt")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, + equalTo("attachment; filename=\"testfile.txt\""))) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, equalTo("text/plain"))) + .andExpect(content().string(bitstreamContent)); + } + + @Test + public void downloadBitstreamByHandleMultipleFiles() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + + String content1 = "FileOneContent"; + String content2 = "FileTwoContent"; + try (InputStream is1 = IOUtils.toInputStream(content1, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is1) + .withName("file1.txt") + .withMimeType("text/plain") + .build(); + } + try (InputStream is2 = IOUtils.toInputStream(content2, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is2) + .withName("file2.txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + // Download first file + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/file1.txt")) + .andExpect(status().isOk()) + .andExpect(content().string(content1)); + + // Download second file + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/file2.txt")) + .andExpect(status().isOk()) + .andExpect(content().string(content2)); + } + + @Test + public void downloadBitstreamByHandleInvalidHandle() throws Exception { + getClient().perform(get(ENDPOINT_BASE + "/99999/99999/nonexistent.txt")) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + public void downloadBitstreamByHandleMissingFile() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String bitstreamContent = "SomeContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName("existing.txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/nonexistent.txt")) + .andExpect(status().isNotFound()); + } + + @Test + public void downloadBitstreamByHandleSpecialCharInFilename() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String bitstreamContent = "SpecialCharContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName("my file (2).txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/my file (2).txt")) + .andExpect(status().isOk()) + .andExpect(content().string(bitstreamContent)); + } + + @Test + public void downloadBitstreamByHandleUnauthorized() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + + String bitstreamContent = "RestrictedContent"; + Bitstream bitstream; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, item, is) + .withName("restricted.txt") + .withMimeType("text/plain") + .build(); + } + + // Remove all read policies from the bitstream + authorizeService.removeAllPolicies(context, bitstream); + // Add a read policy only for admin + ResourcePolicyBuilder.createResourcePolicy(context, admin, null) + .withDspaceObject(bitstream) + .withAction(Constants.READ) + .build(); + + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + // Anonymous user should get 401 + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/restricted.txt")) + .andExpect(status().isUnauthorized()); + } + + @Test + public void headRequestBitstreamByHandle() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String bitstreamContent = "HeadRequestContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName("headtest.txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + getClient().perform(head(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/headtest.txt")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, + equalTo("attachment; filename=\"headtest.txt\""))); + } +} From cefec64c45503a2d44f55783fd20db4510e067cb Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Feb 2026 15:52:47 +0100 Subject: [PATCH 02/15] Fixed compliing errors --- .../dspace/app/rest/BitstreamByHandleRestController.java | 8 ++++++-- .../SubmissionCCLicenseUrlResourceHalLinkFactory.java | 2 +- 2 files changed, 7 insertions(+), 3 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 bb4760ae5513..665c05210ae6 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 @@ -95,7 +95,7 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, DSpaceObject dso = handleService.resolveToObject(context, handle); if (Objects.isNull(dso) || !(dso instanceof Item)) { log.warn("Handle '{}' does not resolve to a valid Item.", handle); - response.sendError(HttpServletResponse.SC_UNPROCESSABLE_ENTITY, + response.sendError(422, "Handle '" + handle + "' does not resolve to a valid item."); return; } @@ -151,7 +151,11 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, "An internal error occurred."); } finally { if (context != null && context.isValid()) { - context.complete(); + try { + context.complete(); + } catch (SQLException e) { + log.error("Error completing context: {}", e.getMessage()); + } } } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java index 07d5e46c61e0..8c6642bab98f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java @@ -54,7 +54,7 @@ protected void addLinks(SubmissionCCLicenseUrlResource halResource, final Pageab SubmissionCCLicenseUrlRest.CATEGORY, SubmissionCCLicenseUrlRest.PLURAL, "rightsByQuestions", null, null, null, null, new LinkedMultiValueMap<>())); for (String key : parameterMap.keySet()) { - uriComponentsBuilder.queryParam(key, parameterMap.get(key)); + uriComponentsBuilder.queryParam(key, (Object) parameterMap.get(key)); } list.add(buildLink("self", uriComponentsBuilder.build().toUriString())); From 3304e16d9812cd291699308da8bd311bdd84fa05 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Feb 2026 16:31:52 +0100 Subject: [PATCH 03/15] Small refactoring - use constants and removed unnecessary changes --- .../dspace/app/rest/BitstreamByHandleRestController.java | 6 ++++-- .../SubmissionCCLicenseUrlResourceHalLinkFactory.java | 2 +- 2 files changed, 5 insertions(+), 3 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 665c05210ae6..3c964f2082b1 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 @@ -30,8 +30,10 @@ import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.handle.service.HandleService; +import org.apache.http.HttpStatus; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -95,7 +97,7 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, DSpaceObject dso = handleService.resolveToObject(context, handle); if (Objects.isNull(dso) || !(dso instanceof Item)) { log.warn("Handle '{}' does not resolve to a valid Item.", handle); - response.sendError(422, + response.sendError(HttpStatus.SC_UNPROCESSABLE_ENTITY, "Handle '" + handle + "' does not resolve to a valid item."); return; } @@ -115,7 +117,7 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, // Retrieve content and stream it BitstreamFormat format = bitstream.getFormat(context); - String mimeType = (format != null) ? format.getMIMEType() : "application/octet-stream"; + String mimeType = (format != null) ? format.getMIMEType() : MediaType.APPLICATION_OCTET_STREAM_VALUE; String name = StringUtils.isNotBlank(bitstream.getName()) ? bitstream.getName() : bitstream.getID().toString(); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java index 8c6642bab98f..07d5e46c61e0 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java @@ -54,7 +54,7 @@ protected void addLinks(SubmissionCCLicenseUrlResource halResource, final Pageab SubmissionCCLicenseUrlRest.CATEGORY, SubmissionCCLicenseUrlRest.PLURAL, "rightsByQuestions", null, null, null, null, new LinkedMultiValueMap<>())); for (String key : parameterMap.keySet()) { - uriComponentsBuilder.queryParam(key, (Object) parameterMap.get(key)); + uriComponentsBuilder.queryParam(key, parameterMap.get(key)); } list.add(buildLink("self", uriComponentsBuilder.build().toUriString())); From 75915fe026c180ff5323b1ba0c94097c75acecfa Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 23 Feb 2026 14:15:59 +0100 Subject: [PATCH 04/15] Fixed checkstyle issue --- .../org/dspace/app/rest/BitstreamByHandleRestController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3c964f2082b1..a12b8b5298f4 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 @@ -16,6 +16,7 @@ import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpStatus; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.model.BitstreamRest; import org.dspace.app.rest.utils.ContextUtil; @@ -30,7 +31,6 @@ import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.handle.service.HandleService; -import org.apache.http.HttpStatus; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; From af551c7a5f9bc6faf2bec3570be0c0ae80e048fd Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Tue, 24 Feb 2026 11:53:45 +0100 Subject: [PATCH 05/15] added comments, return 404 status instead of 402 --- .../rest/BitstreamByHandleRestController.java | 2 +- ...ionCCLicenseUrlResourceHalLinkFactory.java | 2 + .../BitstreamByHandleRestControllerIT.java | 38 ++++++++++++++++++- 3 files changed, 40 insertions(+), 2 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 a12b8b5298f4..8f6c897c72be 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 @@ -97,7 +97,7 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, DSpaceObject dso = handleService.resolveToObject(context, handle); if (Objects.isNull(dso) || !(dso instanceof Item)) { log.warn("Handle '{}' does not resolve to a valid Item.", handle); - response.sendError(HttpStatus.SC_UNPROCESSABLE_ENTITY, + response.sendError(HttpStatus.SC_NOT_FOUND, "Handle '" + handle + "' does not resolve to a valid item."); return; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java index 07d5e46c61e0..61e25bec6b5b 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java @@ -54,6 +54,8 @@ protected void addLinks(SubmissionCCLicenseUrlResource halResource, final Pageab SubmissionCCLicenseUrlRest.CATEGORY, SubmissionCCLicenseUrlRest.PLURAL, "rightsByQuestions", null, null, null, null, new LinkedMultiValueMap<>())); for (String key : parameterMap.keySet()) { + // Cast to Object to ensure the String[] from parameterMap selects the queryParam(String, Object...) overload + // and avoids ambiguity or compiler warnings. uriComponentsBuilder.queryParam(key, parameterMap.get(key)); } 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 a1e0720728dd..15cbdc1eb041 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 @@ -124,10 +124,46 @@ public void downloadBitstreamByHandleMultipleFiles() throws Exception { .andExpect(content().string(content2)); } + @Test + public void downloadBitstreamByHandleForbiddenForNonAdmin() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String bitstreamContent = "RestrictedContent"; + Bitstream bitstream; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, item, is) + .withName("restricted.txt") + .withMimeType("text/plain") + .build(); + } + // Remove all read policies from the bitstream + authorizeService.removeAllPolicies(context, bitstream); + // Add a read policy only for admin + ResourcePolicyBuilder.createResourcePolicy(context, admin, null) + .withDspaceObject(bitstream) + .withAction(Constants.READ) + .build(); + context.restoreAuthSystemState(); + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + // Authenticated non-admin user should get 403 + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/restricted.txt")) + .andExpect(status().isForbidden()); + } + @Test public void downloadBitstreamByHandleInvalidHandle() throws Exception { getClient().perform(get(ENDPOINT_BASE + "/99999/99999/nonexistent.txt")) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isNotFound()); } @Test From 1ae7c897cc4188cec1a6de438233889de87385ed Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Tue, 24 Feb 2026 13:08:16 +0100 Subject: [PATCH 06/15] checkstyle --- .../process/SubmissionCCLicenseUrlResourceHalLinkFactory.java | 4 ++-- .../dspace/app/rest/BitstreamByHandleRestControllerIT.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java index 61e25bec6b5b..56956972d061 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java @@ -54,8 +54,8 @@ protected void addLinks(SubmissionCCLicenseUrlResource halResource, final Pageab SubmissionCCLicenseUrlRest.CATEGORY, SubmissionCCLicenseUrlRest.PLURAL, "rightsByQuestions", null, null, null, null, new LinkedMultiValueMap<>())); for (String key : parameterMap.keySet()) { - // Cast to Object to ensure the String[] from parameterMap selects the queryParam(String, Object...) overload - // and avoids ambiguity or compiler warnings. + // Cast to Object to ensure the String[] from parameterMap selects the queryParam(String, Object...) + // overload and avoids ambiguity or compiler warnings. uriComponentsBuilder.queryParam(key, parameterMap.get(key)); } 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 15cbdc1eb041..35496efff0f4 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 @@ -159,7 +159,7 @@ public void downloadBitstreamByHandleForbiddenForNonAdmin() throws Exception { getClient(token).perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/restricted.txt")) .andExpect(status().isForbidden()); } - + @Test public void downloadBitstreamByHandleInvalidHandle() throws Exception { getClient().perform(get(ENDPOINT_BASE + "/99999/99999/nonexistent.txt")) From 51a59cc23b8948939d0e860ba42c0615e3a2a864 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Tue, 24 Feb 2026 13:10:14 +0100 Subject: [PATCH 07/15] unauthorized instead of forbidden --- .../dspace/app/rest/BitstreamByHandleRestControllerIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 35496efff0f4..bd76f0e891a8 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 @@ -125,7 +125,7 @@ public void downloadBitstreamByHandleMultipleFiles() throws Exception { } @Test - public void downloadBitstreamByHandleForbiddenForNonAdmin() throws Exception { + public void downloadBitstreamByHandleUnauthorizedForNonAdmin() throws Exception { context.turnOffAuthorisationSystem(); parentCommunity = CommunityBuilder.createCommunity(context) .withName("Parent Community") @@ -157,7 +157,7 @@ public void downloadBitstreamByHandleForbiddenForNonAdmin() throws Exception { // Authenticated non-admin user should get 403 String token = getAuthToken(eperson.getEmail(), password); getClient(token).perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/restricted.txt")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()); } @Test From 9183b893b98bdda50694e5a6abdbefad8ff56a0e Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 24 Feb 2026 13:38:25 +0100 Subject: [PATCH 08/15] fix: use RFC 5987 Content-Disposition for non-ASCII filenames curl -J on Windows cannot create files with non-ASCII characters (e.g. diacritics like e/a) from a raw UTF-8 Content-Disposition filename header. Uses filename*=UTF-8''percent-encoded-name (RFC 5987/6266) which curl properly decodes. Also includes an ASCII fallback in filename param. --- .../rest/BitstreamByHandleRestController.java | 152 ++++++++++++++++-- .../BitstreamByHandleRestControllerIT.java | 121 +++++++++++++- 2 files changed, 256 insertions(+), 17 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 8f6c897c72be..01da0ef3fccc 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 @@ -7,19 +7,26 @@ */ package org.dspace.app.rest; +import static org.dspace.core.Constants.CONTENT_BUNDLE_NAME; + import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.List; import java.util.Objects; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.InternalServerErrorException; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpStatus; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.model.BitstreamRest; import org.dspace.app.rest.utils.ContextUtil; +import org.dspace.app.statistics.clarin.ClarinMatomoBitstreamTracker; import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.service.AuthorizeService; import org.dspace.content.Bitstream; @@ -31,9 +38,14 @@ import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.handle.service.HandleService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.EventService; +import org.dspace.storage.bitstore.S3BitStoreService; +import org.dspace.storage.bitstore.service.S3DirectDownloadService; +import org.dspace.usage.UsageEvent; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -43,10 +55,15 @@ * This controller provides a direct download endpoint for bitstreams * identified by an Item handle and the bitstream filename. * - * Endpoint: GET /api/core/bitstreams/handle/{prefix}/{suffix}/{filename} + *

Endpoint: {@code GET /api/core/bitstreams/handle/{prefix}/{suffix}/{filename}}

+ * + *

This is used by the command-line download instructions (curl commands) + * shown on the item page in the UI. Only bitstreams in ORIGINAL bundles are served.

* - * This is used by the command-line download instructions (curl commands) - * shown on the item page in the UI. + *

Note: {@code @PreAuthorize} is not used because authorization depends on the resolved + * bitstream (looked up by handle + filename), not on a UUID path variable. Authorization + * is explicitly checked via {@link AuthorizeService#authorizeAction} after the bitstream + * is resolved.

*/ @RestController @RequestMapping("/api/" + BitstreamRest.CATEGORY + "/" + BitstreamRest.PLURAL_NAME + "/handle") @@ -66,6 +83,21 @@ public class BitstreamByHandleRestController { @Autowired private AuthorizeService authorizeService; + @Autowired + private EventService eventService; + + @Autowired + private ConfigurationService configurationService; + + @Autowired + private ClarinMatomoBitstreamTracker matomoBitstreamTracker; + + @Autowired + private S3DirectDownloadService s3DirectDownloadService; + + @Autowired + private S3BitStoreService s3BitStoreService; + /** * Download a bitstream by item handle and filename. * @@ -74,6 +106,7 @@ public class BitstreamByHandleRestController { * @param filename the bitstream filename (e.g. "pdtvallex-4.5.xml") * @param request the HTTP request * @param response the HTTP response + * @throws IOException if an I/O error occurs during streaming */ @RequestMapping(method = {RequestMethod.GET, RequestMethod.HEAD}, value = "/{prefix}/{suffix}/{filename:.+}") @@ -97,7 +130,7 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, DSpaceObject dso = handleService.resolveToObject(context, handle); if (Objects.isNull(dso) || !(dso instanceof Item)) { log.warn("Handle '{}' does not resolve to a valid Item.", handle); - response.sendError(HttpStatus.SC_NOT_FOUND, + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Handle '" + handle + "' does not resolve to a valid item."); return; } @@ -112,10 +145,21 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, return; } - // Check authorization + // Authorization is checked explicitly here (not via @PreAuthorize) because the + // bitstream identity is resolved from handle+filename, not from a UUID path variable. authorizeService.authorizeAction(context, bitstream, Constants.READ); - // Retrieve content and stream it + // Fire usage event for download statistics + if (StringUtils.isBlank(request.getHeader("Range"))) { + eventService.fireEvent( + new UsageEvent( + UsageEvent.Action.VIEW, + request, + context, + bitstream)); + } + + // Retrieve content metadata BitstreamFormat format = bitstream.getFormat(context); String mimeType = (format != null) ? format.getMIMEType() : MediaType.APPLICATION_OCTET_STREAM_VALUE; String name = StringUtils.isNotBlank(bitstream.getName()) @@ -123,12 +167,34 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, response.setContentType(mimeType); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, - String.format("attachment; filename=\"%s\"", name)); + buildContentDisposition(name)); long size = bitstream.getSizeBytes(); if (size > 0) { response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(size)); } + // Track download in Matomo + matomoBitstreamTracker.trackBitstreamDownload(context, request, bitstream, false); + + // Check for S3 direct download support + boolean s3DirectDownload = configurationService + .getBooleanProperty("s3.download.direct.enabled"); + boolean s3AssetstoreEnabled = configurationService + .getBooleanProperty("assetstore.s3.enabled"); + if (s3DirectDownload && s3AssetstoreEnabled) { + boolean hasOriginalBundle = bitstream.getBundles().stream() + .anyMatch(bundle -> CONTENT_BUNDLE_NAME.equals(bundle.getName())); + if (hasOriginalBundle) { + // Close the DB connection before redirecting + context.complete(); + redirectToS3DownloadUrl(name, bitstream.getInternalId(), response); + return; + } + } + + // Close the DB connection before streaming to avoid holding it open during download + context.complete(); + if (RequestMethod.HEAD.name().equals(request.getMethod())) { // HEAD request — only headers, no body return; @@ -151,19 +217,75 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, filename, handle, e.getMessage()); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "An internal error occurred."); - } finally { - if (context != null && context.isValid()) { - try { - context.complete(); - } catch (SQLException e) { - log.error("Error completing context: {}", e.getMessage()); - } + } + } + + /** + * Redirect to an S3 presigned URL for direct download. + * + * @param bitName the bitstream filename + * @param bitInternalId the internal storage ID + * @param response the HTTP response to send the redirect on + */ + private void redirectToS3DownloadUrl(String bitName, String bitInternalId, + HttpServletResponse response) throws IOException { + try { + String bucket = configurationService.getProperty("assetstore.s3.bucketName", ""); + if (StringUtils.isBlank(bucket)) { + throw new InternalServerErrorException("S3 bucket name is not configured"); } + + String bitstreamPath = s3BitStoreService.getFullKey(bitInternalId); + if (StringUtils.isBlank(bitstreamPath)) { + throw new InternalServerErrorException( + "Failed to get bitstream path for internal ID: " + bitInternalId); + } + + int expirationTime = configurationService + .getIntProperty("s3.download.direct.expiration", 3600); + String presignedUrl = s3DirectDownloadService + .generatePresignedUrl(bucket, bitstreamPath, expirationTime, bitName); + + if (StringUtils.isBlank(presignedUrl)) { + throw new InternalServerErrorException( + "Failed to generate presigned URL for bitstream: " + bitInternalId); + } + + response.setStatus(HttpStatus.FOUND.value()); + response.setHeader(HttpHeaders.LOCATION, URI.create(presignedUrl).toString()); + } catch (Exception e) { + log.error("Error generating S3 presigned URL for bitstream: {}", bitInternalId, e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Error generating download URL."); } } + /** + * 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 + */ + 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 + String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_"); + return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + asciiFallback, encoded); + } + /** * Find a bitstream by name in the ORIGINAL bundles of an item. + * Bitstreams in other bundles (THUMBNAIL, TEXT, LICENSE, etc.) are not returned. + * + * @param item the item to search + * @param filename the exact filename to match + * @return the matching Bitstream, or null if not found */ private Bitstream findBitstreamByName(Item item, String filename) { List bundles = item.getBundles("ORIGINAL"); 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 bd76f0e891a8..c5ef0a6e7027 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 @@ -21,11 +21,13 @@ import org.dspace.app.rest.test.AbstractControllerIntegrationTest; import org.dspace.authorize.service.AuthorizeService; import org.dspace.builder.BitstreamBuilder; +import org.dspace.builder.BundleBuilder; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; import org.dspace.builder.ItemBuilder; import org.dspace.builder.ResourcePolicyBuilder; import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; import org.dspace.content.Collection; import org.dspace.content.Item; import org.dspace.content.service.BitstreamService; @@ -76,7 +78,7 @@ public void downloadBitstreamByHandle() throws Exception { getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/testfile.txt")) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, - equalTo("attachment; filename=\"testfile.txt\""))) + equalTo("attachment; filename=\"testfile.txt\"; filename*=UTF-8''testfile.txt"))) .andExpect(header().string(HttpHeaders.CONTENT_TYPE, equalTo("text/plain"))) .andExpect(content().string(bitstreamContent)); } @@ -220,6 +222,46 @@ public void downloadBitstreamByHandleSpecialCharInFilename() throws Exception { getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/my file (2).txt")) .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, + equalTo("attachment; filename=\"my file (2).txt\"; " + + "filename*=UTF-8''my%20file%20%282%29.txt"))) + .andExpect(content().string(bitstreamContent)); + } + + @Test + public void downloadBitstreamByHandleUtf8Filename() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + // Filename with diacritics: "Médiá (3).jfif" + String utf8Name = "M\u00e9di\u00e1 (3).jfif"; + String bitstreamContent = "Utf8FilenameContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName(utf8Name) + .withMimeType("image/jpeg") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + // The URL must percent-encode the UTF-8 filename + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/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\"; " + + "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif"))) .andExpect(content().string(bitstreamContent)); } @@ -290,6 +332,81 @@ public void headRequestBitstreamByHandle() throws Exception { getClient().perform(head(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/headtest.txt")) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, - equalTo("attachment; filename=\"headtest.txt\""))); + equalTo("attachment; filename=\"headtest.txt\"; filename*=UTF-8''headtest.txt"))); + } + + @Test + public void downloadBitstreamByHandleForbidden() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + + String bitstreamContent = "ForbiddenContent"; + Bitstream bitstream; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, item, is) + .withName("admin-only.txt") + .withMimeType("text/plain") + .build(); + } + + // Remove all read policies and grant access only to admin + authorizeService.removeAllPolicies(context, bitstream); + ResourcePolicyBuilder.createResourcePolicy(context, admin, null) + .withDspaceObject(bitstream) + .withAction(Constants.READ) + .build(); + + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + // Authenticated non-admin user should get 401 (AuthorizeException) + String token = getAuthToken(eperson.getEmail(), password); + getClient(token).perform( + get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/admin-only.txt")) + .andExpect(status().isUnauthorized()); + } + + @Test + public void downloadBitstreamFromNonOriginalBundle() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + + // Place a bitstream only in the TEXT bundle (not ORIGINAL) + Bundle textBundle = BundleBuilder.createBundle(context, item) + .withName("TEXT") + .build(); + String bitstreamContent = "ExtractedTextContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, textBundle, is) + .withName("extracted.txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + // Bitstream in TEXT bundle should not be found by this endpoint + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/extracted.txt")) + .andExpect(status().isNotFound()); } } From 00df133312cdc2ed337bd8a4d4ba70d5709b7778 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 24 Feb 2026 14:39:16 +0100 Subject: [PATCH 09/15] fix: move context.complete() after streaming to prevent truncated downloads context.complete() was called before bitstreamService.retrieve(), closing the DB connection and causing 'end of response with X bytes missing' errors. Now context.complete() is called only after the full content has been streamed. For S3 redirect and HEAD paths, context.complete() remains before return since no streaming is needed. --- .../dspace/app/rest/BitstreamByHandleRestController.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 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 01da0ef3fccc..c6ab3a4224ba 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 @@ -192,14 +192,14 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, } } - // Close the DB connection before streaming to avoid holding it open during download - context.complete(); - if (RequestMethod.HEAD.name().equals(request.getMethod())) { // HEAD request — only headers, no body + context.complete(); return; } + // Stream the bitstream content. The context must remain open because + // bitstreamService.retrieve() needs an active DB connection / assetstore session. try (InputStream is = bitstreamService.retrieve(context, bitstream)) { byte[] buffer = new byte[BUFFER_SIZE]; int bytesRead; @@ -208,6 +208,8 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, } response.getOutputStream().flush(); } + // Close DB connection after streaming is complete + context.complete(); } catch (AuthorizeException e) { log.warn("Unauthorized access to bitstream '{}' for handle '{}'.", filename, handle); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, From 82d4c2c5c642e859c061a3290613074ad6b51004 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 24 Feb 2026 15:13:03 +0100 Subject: [PATCH 10/15] fix: use real UTF-8 filename in Content-Disposition instead of ASCII fallback The filename parameter now contains the original name (with diacritics like e/a) instead of replacing non-ASCII chars with underscores. Characters in the ISO-8859-1 range are transmitted correctly by Tomcat and understood by curl on Western/Central-European systems. The filename* parameter still provides RFC 5987 percent-encoded UTF-8 for modern clients (curl 7.56+). --- .../app/rest/BitstreamByHandleRestController.java | 10 +++++++--- .../app/rest/BitstreamByHandleRestControllerIT.java | 4 ++-- 2 files changed, 9 insertions(+), 5 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 c6ab3a4224ba..0e7ddc7e56b0 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 @@ -275,10 +275,14 @@ 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 - String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_"); + // Use the original filename in the filename parameter — characters in the + // ISO-8859-1 range (e.g. é, á, ü) are transmitted correctly by Tomcat and + // understood by curl on Western/Central-European code pages. For full Unicode + // support, filename* provides RFC 5987 percent-encoded UTF-8 which modern + // curl (7.56+) prefers over filename per RFC 6266. + String escapedName = name.replace("\"", "\\\""); return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", - asciiFallback, encoded); + escapedName, encoded); } /** 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 c5ef0a6e7027..b08e42011cfe 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 @@ -259,8 +259,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\"; " + // filename has the original UTF-8 name; filename* has RFC 5987 encoding + equalTo("attachment; filename=\"M\u00e9di\u00e1 (3).jfif\"; " + "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif"))) .andExpect(content().string(bitstreamContent)); } From 3faa64dd3734b6b8c14a8f78dd136c2afc743c74 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 24 Feb 2026 15:34:35 +0100 Subject: [PATCH 11/15] fix: revert to ASCII fallback in Content-Disposition, add edge-case tests Content-Disposition filename parameter now uses ASCII fallback (non-ASCII replaced with underscore) per RFC 6266. Modern clients use filename* (RFC 5987) which has the full UTF-8 name. The curl command no longer relies on Content-Disposition at all (uses -o instead of -OJ). New integration tests for edge cases: - Multiple dots in filename (archive.v2.1.tar.gz) - Double quotes in filename (escaped in Content-Disposition) - CJK characters (beyond ISO-8859-1) - Same filename in ORIGINAL and TEXT bundles (only ORIGINAL served) --- .../rest/BitstreamByHandleRestController.java | 13 +- .../BitstreamByHandleRestControllerIT.java | 151 +++++++++++++++++- 2 files changed, 155 insertions(+), 9 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 0e7ddc7e56b0..9291fd3aa709 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 @@ -275,14 +275,13 @@ private String buildContentDisposition(String name) { // RFC 5987 percent-encoding for filename* String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) .replace("+", "%20"); - // Use the original filename in the filename parameter — characters in the - // ISO-8859-1 range (e.g. é, á, ü) are transmitted correctly by Tomcat and - // understood by curl on Western/Central-European code pages. For full Unicode - // support, filename* provides RFC 5987 percent-encoded UTF-8 which modern - // curl (7.56+) prefers over filename per RFC 6266. - String escapedName = name.replace("\"", "\\\""); + // 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]", "_") + .replace("\\", "\\\\") + .replace("\"", "\\\""); return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", - escapedName, encoded); + asciiFallback, encoded); } /** 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 b08e42011cfe..de57098ae0b0 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 @@ -259,8 +259,8 @@ public void downloadBitstreamByHandleUtf8Filename() throws Exception { + "/M%C3%A9di%C3%A1%20(3).jfif")) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, - // filename has the original UTF-8 name; filename* has RFC 5987 encoding - equalTo("attachment; filename=\"M\u00e9di\u00e1 (3).jfif\"; " + // ASCII fallback replaces non-ASCII with underscore; filename* has UTF-8 encoding + equalTo("attachment; filename=\"M_di_ (3).jfif\"; " + "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif"))) .andExpect(content().string(bitstreamContent)); } @@ -409,4 +409,151 @@ public void downloadBitstreamFromNonOriginalBundle() throws Exception { getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/extracted.txt")) .andExpect(status().isNotFound()); } + + @Test + public void downloadBitstreamByHandleMultipleDots() throws Exception { + // Verify that Spring {filename:.+} correctly captures filenames with multiple dots + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String bitstreamContent = "TarGzContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName("archive.v2.1.tar.gz") + .withMimeType("application/gzip") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/archive.v2.1.tar.gz")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, + equalTo("attachment; filename=\"archive.v2.1.tar.gz\"; " + + "filename*=UTF-8''archive.v2.1.tar.gz"))) + .andExpect(content().string(bitstreamContent)); + } + + @Test + public void downloadBitstreamByHandleQuoteInFilename() throws Exception { + // Verify double quotes in filename are escaped in Content-Disposition + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String bitstreamContent = "QuoteContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName("file \"quoted\".txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/file%20%22quoted%22.txt")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, + equalTo("attachment; filename=\"file \\\"quoted\\\".txt\"; " + + "filename*=UTF-8''file%20%22quoted%22.txt"))) + .andExpect(content().string(bitstreamContent)); + } + + @Test + public void downloadBitstreamByHandleCjkFilename() throws Exception { + // Verify CJK characters (beyond ISO-8859-1) are handled correctly + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + // "日本語.txt" — three CJK characters + String cjkName = "\u65e5\u672c\u8a9e.txt"; + String bitstreamContent = "CjkContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName(cjkName) + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/%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\"; " + + "filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"))) + .andExpect(content().string(bitstreamContent)); + } + + @Test + public void downloadBitstreamByHandleSameNameDifferentBundles() throws Exception { + // A file with the same name in ORIGINAL and TEXT bundles — only ORIGINAL should be served + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + String originalContent = "OriginalBundleContent"; + try (InputStream is = IOUtils.toInputStream(originalContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName("data.txt") + .withMimeType("text/plain") + .build(); + } + // Add same name in TEXT bundle + Bundle textBundle = BundleBuilder.createBundle(context, item) + .withName("TEXT") + .build(); + String textContent = "TextBundleContent"; + try (InputStream is = IOUtils.toInputStream(textContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, textBundle, is) + .withName("data.txt") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + // Should return ORIGINAL bundle content, not TEXT bundle + getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/data.txt")) + .andExpect(status().isOk()) + .andExpect(content().string(originalContent)); + } } From 79509041eb8f2c0e0ed401f248b572ed24edf5f0 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 24 Feb 2026 16:04:50 +0100 Subject: [PATCH 12/15] fix: resolve compilation errors and fix IT test assertions - Remove duplicate HttpStatus import (apache vs spring) - Add missing MediaType import (spring) - Fix Content-Type assertion to include charset=UTF-8 - Use URI.create() for pre-encoded URLs in tests to prevent double-encoding (%25) rejection by StrictHttpFirewall All 15 integration tests pass. --- .../rest/BitstreamByHandleRestController.java | 2 +- .../BitstreamByHandleRestControllerIT.java | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 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 9291fd3aa709..dd60dde06f2e 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 @@ -22,7 +22,6 @@ import javax.ws.rs.InternalServerErrorException; import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpStatus; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.model.BitstreamRest; import org.dspace.app.rest.utils.ContextUtil; @@ -46,6 +45,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; 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 de57098ae0b0..c5bdd1576c50 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 @@ -15,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.io.InputStream; +import java.net.URI; import org.apache.commons.codec.CharEncoding; import org.apache.commons.io.IOUtils; @@ -79,7 +80,7 @@ public void downloadBitstreamByHandle() throws Exception { .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, equalTo("attachment; filename=\"testfile.txt\"; filename*=UTF-8''testfile.txt"))) - .andExpect(header().string(HttpHeaders.CONTENT_TYPE, equalTo("text/plain"))) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, "text/plain;charset=UTF-8")) .andExpect(content().string(bitstreamContent)); } @@ -254,9 +255,9 @@ public void downloadBitstreamByHandleUtf8Filename() throws Exception { String handle = item.getHandle(); String[] handleParts = handle.split("/"); - // The URL must percent-encode the UTF-8 filename - getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] - + "/M%C3%A9di%C3%A1%20(3).jfif")) + // Use URI.create to pass a pre-encoded URL — get(String) would double-encode %C3 to %25C3 + getClient().perform(get(URI.create(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/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 @@ -469,8 +470,9 @@ public void downloadBitstreamByHandleQuoteInFilename() throws Exception { String handle = item.getHandle(); String[] handleParts = handle.split("/"); - getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] - + "/file%20%22quoted%22.txt")) + // Use URI.create to pass a pre-encoded URL — get(String) would double-encode %22 to %2522 + getClient().perform(get(URI.create(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/file%20%22quoted%22.txt"))) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, equalTo("attachment; filename=\"file \\\"quoted\\\".txt\"; " @@ -505,8 +507,9 @@ public void downloadBitstreamByHandleCjkFilename() throws Exception { String handle = item.getHandle(); String[] handleParts = handle.split("/"); - getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] - + "/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt")) + // Use URI.create to pass a pre-encoded URL — get(String) would double-encode CJK sequences + getClient().perform(get(URI.create(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/%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 From 523741b662a53a5fd94ef81e7660dfb2a9bdee18 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 24 Feb 2026 16:53:36 +0100 Subject: [PATCH 13/15] test: add complex filename test (diacritics, plus, hash, unmatched paren) New IT test for filename 'Media (+)#9) ano' verifying correct URL decoding, Content-Disposition encoding, and content delivery. 16/16 tests pass. --- .../BitstreamByHandleRestControllerIT.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 c5bdd1576c50..039ac545fa5a 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 @@ -559,4 +559,41 @@ public void downloadBitstreamByHandleSameNameDifferentBundles() throws Exception .andExpect(status().isOk()) .andExpect(content().string(originalContent)); } + + @Test + public void downloadBitstreamByHandleComplexFilename() throws Exception { + // Verify a filename with diacritics, plus, hash, and unmatched parenthesis + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + Item item = ItemBuilder.createItem(context, col) + .withAuthor("Test Author") + .build(); + // "M\u00e9di\u00e1 (+)#9) ano" + String complexName = "M\u00e9di\u00e1 (+)#9) ano"; + String bitstreamContent = "ComplexNameContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, item, is) + .withName(complexName) + .withMimeType("application/octet-stream") + .build(); + } + context.restoreAuthSystemState(); + + String handle = item.getHandle(); + String[] handleParts = handle.split("/"); + + // Pre-encoded URL: e=C3A9, a=C3A1, space=20, (=28, +=2B, )=29, #=23 + getClient().perform(get(URI.create(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + + "/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\"; " + + "filename*=UTF-8''M%C3%A9di%C3%A1%20%28%2B%29%239%29%20ano"))) + .andExpect(content().string(bitstreamContent)); + } } From da64e980db38b317ce654846062963e848f4319a Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 10:57:33 +0100 Subject: [PATCH 14/15] fix authorization, comments, tests --- .../rest/BitstreamByHandleRestController.java | 34 ++++++++++++++----- ...ionCCLicenseUrlResourceHalLinkFactory.java | 3 +- .../BitstreamByHandleRestControllerIT.java | 6 ++-- 3 files changed, 29 insertions(+), 14 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 dd60dde06f2e..36cdffc9b201 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 @@ -200,20 +200,36 @@ public void downloadBitstreamByHandle(@PathVariable String prefix, // Stream the bitstream content. The context must remain open because // bitstreamService.retrieve() needs an active DB connection / assetstore session. - try (InputStream is = bitstreamService.retrieve(context, bitstream)) { - byte[] buffer = new byte[BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = is.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, bytesRead); + Context downloadContext = null; + boolean downloadContextCompleted = false; + try { + downloadContext = new Context(); + try (InputStream is = bitstreamService.retrieve(downloadContext, bitstream)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, bytesRead); + } + response.getOutputStream().flush(); + } + downloadContext.complete(); + downloadContextCompleted = true; + } finally { + if (downloadContext != null && !downloadContextCompleted) { + downloadContext.abort(); } - response.getOutputStream().flush(); } // Close DB connection after streaming is complete context.complete(); } catch (AuthorizeException e) { log.warn("Unauthorized access to bitstream '{}' for handle '{}'.", filename, handle); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, - "You are not authorized to download this file."); + if (context.getCurrentUser() == null) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, + "You are not authorized to download this file."); + } else { + response.sendError(HttpServletResponse.SC_FORBIDDEN, + "You are not authorized to download this file."); + } } catch (SQLException e) { log.error("Database error while downloading bitstream '{}' for handle '{}': {}", filename, handle, e.getMessage()); @@ -293,7 +309,7 @@ private String buildContentDisposition(String name) { * @return the matching Bitstream, or null if not found */ private Bitstream findBitstreamByName(Item item, String filename) { - List bundles = item.getBundles("ORIGINAL"); + List bundles = item.getBundles(CONTENT_BUNDLE_NAME); for (Bundle bundle : bundles) { for (Bitstream bitstream : bundle.getBitstreams()) { if (StringUtils.equals(bitstream.getName(), filename)) { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java index 56956972d061..6328f1c56f3c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/link/process/SubmissionCCLicenseUrlResourceHalLinkFactory.java @@ -54,8 +54,7 @@ protected void addLinks(SubmissionCCLicenseUrlResource halResource, final Pageab SubmissionCCLicenseUrlRest.CATEGORY, SubmissionCCLicenseUrlRest.PLURAL, "rightsByQuestions", null, null, null, null, new LinkedMultiValueMap<>())); for (String key : parameterMap.keySet()) { - // Cast to Object to ensure the String[] from parameterMap selects the queryParam(String, Object...) - // overload and avoids ambiguity or compiler warnings. + // Add all current request parameters to the URI being built. uriComponentsBuilder.queryParam(key, parameterMap.get(key)); } 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 039ac545fa5a..ebd5d918a2ad 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 @@ -157,7 +157,7 @@ public void downloadBitstreamByHandleUnauthorizedForNonAdmin() throws Exception context.restoreAuthSystemState(); String handle = item.getHandle(); String[] handleParts = handle.split("/"); - // Authenticated non-admin user should get 403 + // Authenticated non-admin user should get 401 (Unauthorized) String token = getAuthToken(eperson.getEmail(), password); getClient(token).perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/restricted.txt")) .andExpect(status().isUnauthorized()); @@ -370,11 +370,11 @@ public void downloadBitstreamByHandleForbidden() throws Exception { String handle = item.getHandle(); String[] handleParts = handle.split("/"); - // Authenticated non-admin user should get 401 (AuthorizeException) + // Authenticated non-admin user should get 403 (Forbidden) String token = getAuthToken(eperson.getEmail(), password); getClient(token).perform( get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/admin-only.txt")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isForbidden()); } @Test From 078107170ebd6ff5b3eb2428a898c7463e63aed2 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Wed, 25 Feb 2026 14:22:38 +0100 Subject: [PATCH 15/15] fix: change expected status from 401 to 403 for authenticated non-admin user The test downloadBitstreamByHandleUnauthorizedForNonAdmin uses getClient(token) which means the user IS authenticated. The controller correctly returns 403 (Forbidden) for authenticated users without access, not 401 (Unauthorized). 401 is only for anonymous/unauthenticated requests. --- .../dspace/app/rest/BitstreamByHandleRestControllerIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ebd5d918a2ad..52909c42b6ec 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 @@ -157,10 +157,10 @@ public void downloadBitstreamByHandleUnauthorizedForNonAdmin() throws Exception context.restoreAuthSystemState(); String handle = item.getHandle(); String[] handleParts = handle.split("/"); - // Authenticated non-admin user should get 401 (Unauthorized) + // Authenticated non-admin user should get 403 (Forbidden) String token = getAuthToken(eperson.getEmail(), password); getClient(token).perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/restricted.txt")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isForbidden()); } @Test