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..36cdffc9b201
--- /dev/null
+++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java
@@ -0,0 +1,322 @@
+/**
+ * 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.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.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;
+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.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.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;
+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: {@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.
+ *
+ * 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")
+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;
+
+ @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.
+ *
+ * @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
+ * @throws IOException if an I/O error occurs during streaming
+ */
+ @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_NOT_FOUND,
+ "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;
+ }
+
+ // 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);
+
+ // 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())
+ ? bitstream.getName() : bitstream.getID().toString();
+
+ response.setContentType(mimeType);
+ response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
+ 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;
+ }
+ }
+
+ 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.
+ 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();
+ }
+ }
+ // Close DB connection after streaming is complete
+ context.complete();
+ } catch (AuthorizeException e) {
+ log.warn("Unauthorized access to bitstream '{}' for handle '{}'.", filename, handle);
+ 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());
+ response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "An internal error occurred.");
+ }
+ }
+
+ /**
+ * 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, 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",
+ 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(CONTENT_BUNDLE_NAME);
+ 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/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..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,6 +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()) {
+ // 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
new file mode 100644
index 000000000000..52909c42b6ec
--- /dev/null
+++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java
@@ -0,0 +1,599 @@
+/**
+ * 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 java.net.URI;
+
+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.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;
+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\"; filename*=UTF-8''testfile.txt")))
+ .andExpect(header().string(HttpHeaders.CONTENT_TYPE, "text/plain;charset=UTF-8"))
+ .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 downloadBitstreamByHandleUnauthorizedForNonAdmin() 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 (Forbidden)
+ 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().isNotFound());
+ }
+
+ @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(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("/");
+
+ // 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
+ equalTo("attachment; filename=\"M_di_ (3).jfif\"; "
+ + "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif")))
+ .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\"; 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 403 (Forbidden)
+ String token = getAuthToken(eperson.getEmail(), password);
+ getClient(token).perform(
+ get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/admin-only.txt"))
+ .andExpect(status().isForbidden());
+ }
+
+ @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());
+ }
+
+ @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("/");
+
+ // 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\"; "
+ + "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("/");
+
+ // 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
+ 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));
+ }
+
+ @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));
+ }
+}