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)); + } +}