diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java index 917a590caf6b..b7d8d951b093 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java @@ -1,4 +1,4 @@ - /** +/** * 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 @@ -7,29 +7,18 @@ */ package org.dspace.app.rest; -import static org.dspace.app.rest.utils.RegexUtils.REGEX_REQUESTMAPPING_IDENTIFIER_AS_UUID; - import java.io.IOException; import java.io.InputStream; import java.sql.SQLException; -import java.util.List; import java.util.Objects; -import java.util.UUID; -import java.util.zip.Deflater; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; -import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; -import org.apache.commons.compress.utils.IOUtils; -import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; -import org.dspace.app.rest.exception.DSpaceBadRequestException; import org.dspace.app.rest.exception.UnprocessableEntityException; +import org.dspace.app.rest.model.BitstreamRest; import org.dspace.app.rest.model.ItemRest; import org.dspace.app.rest.utils.ContextUtil; -import org.dspace.app.statistics.clarin.ClarinMatomoBitstreamTracker; -import org.dspace.authorize.AuthorizationBitstreamUtils; import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.service.AuthorizeService; import org.dspace.content.Bitstream; @@ -39,22 +28,25 @@ import org.dspace.content.service.BitstreamService; import org.dspace.core.Context; import org.dspace.handle.service.HandleService; -import org.dspace.services.ConfigurationService; -import org.dspace.services.RequestService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; - /** - * This CLARIN Controller download a single file or a ZIP file from the Item's bitstream. +/** + * CLARIN Controller for downloading individual bitstreams from Items. + * This controller provides endpoints to download specific bitstreams by name + * without creating ZIP archives, allowing users to download multiple files + * separately instead of as a single compressed archive. + * + * @author DSpace Community */ @RestController -@RequestMapping("/api/" + ItemRest.CATEGORY + "/" + ItemRest.PLURAL_NAME + REGEX_REQUESTMAPPING_IDENTIFIER_AS_UUID) +@RequestMapping("/api/" + ItemRest.CATEGORY + "/" + BitstreamRest.PLURAL_NAME) public class MetadataBitstreamController { private static Logger log = org.apache.logging.log4j.LogManager.getLogger(MetadataBitstreamController.class); @@ -64,83 +56,126 @@ public class MetadataBitstreamController { @Autowired private HandleService handleService; + @Autowired private AuthorizeService authorizeService; - @Autowired - private ConfigurationService configurationService; - @Autowired - AuthorizationBitstreamUtils authorizationBitstreamUtils; - @Autowired - private RequestService requestService; - @Autowired - ClarinMatomoBitstreamTracker matomoBitstreamTracker; /** - * Download all Item's bitstreams as single ZIP file. + * Downloads a specific bitstream by name from an Item identified by its handle. + * This method allows downloading individual files based on their exact names + * without creating a ZIP archive. + * + * @param prefix The prefix part of the handle identifier (before the slash) + * @param suffix The suffix part of the handle identifier (after the slash) + * @param name The exact name of the bitstream to download + * @param request The HTTP servlet request + * @param response The HTTP servlet response where the bitstream content will be written + * @throws SQLException if there is a database access error + * @throws IOException if there is an I/O error during the download process + * @throws AuthorizeException if the user does not have permission to read the Item + * @throws UnprocessableEntityException if the handle does not resolve to a valid Item + * or if the bitstream with the specified name is not found */ - @PreAuthorize("hasPermission(#uuid, 'ITEM', 'READ')") - @RequestMapping( method = {RequestMethod.GET, RequestMethod.HEAD}, value = "allzip") - public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") String handleId, - HttpServletResponse response, - HttpServletRequest request) throws IOException, SQLException, AuthorizeException { - if (StringUtils.isBlank(handleId)) { - log.error("Handle cannot be null!"); - throw new DSpaceBadRequestException("Handle cannot be null!"); - } + @PreAuthorize("hasPermission(#handleId, 'ITEM', 'READ')") + @GetMapping("/handle/{prefix}/{suffix}/{name:.+}") + public void downloadBitstreamByName( + @PathVariable String prefix, + @PathVariable String suffix, + @PathVariable String name, + HttpServletRequest request, + HttpServletResponse response) throws SQLException, IOException, AuthorizeException { + + final String handleId = prefix + "/" + suffix; Context context = ContextUtil.obtainContext(request); - if (Objects.isNull(context)) { - log.error("Cannot obtain the context from the request."); - throw new RuntimeException("Cannot obtain the context from the request."); - } - DSpaceObject dso = null; - String name = ""; try { - dso = handleService.resolveToObject(context, handleId); - } catch (Exception e) { - log.error("Cannot resolve handle: " + handleId); - throw new RuntimeException("Cannot resolve handle: " + handleId); - } + DSpaceObject dso = handleService.resolveToObject(context, handleId); - if (Objects.isNull(dso)) { - log.error("DSO is null"); - throw new UnprocessableEntityException("Retrieved DSO is null, handle: " + handleId); - } + if (Objects.isNull(dso)) { + throw new UnprocessableEntityException("No DSpace object found for handle: " + handleId); + } + + if (!(dso instanceof Item)) { + throw new UnprocessableEntityException("The handle does not resolve to an Item: " + handleId); + } + + Item item = (Item) dso; + + Bitstream targetBitstream = findBitstreamByName(item, name); - if (!(dso instanceof Item)) { - log.info("DSO is not instance of Item"); + if (Objects.isNull(targetBitstream)) { + throw new UnprocessableEntityException( + "No bitstream with name '" + name + "' found in Item " + item.getID()); + } + + // Set response headers for file download + String mime = java.util.Optional.ofNullable(targetBitstream.getFormat(context)) + .map(fmt -> fmt.getMIMEType()) + .orElse("application/octet-stream"); + + // Set content type (tests use startsWith to tolerate charset if appended) + response.setContentType(mime); + + org.springframework.http.ContentDisposition cd = + org.springframework.http.ContentDisposition.attachment() + .filename(targetBitstream.getName(), java.nio.charset.StandardCharsets.UTF_8) + .build(); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, cd.toString()); + + // Stream the bitstream content to the response + try (InputStream is = bitstreamService.retrieve(context, targetBitstream)) { + streamBitstreamToResponse(is, response); + } catch (AuthorizeException e) { + log.error("Authorization error while retrieving bitstream: {}", targetBitstream.getName(), e); + throw new AccessDeniedException( + "Access denied to bitstream: " + targetBitstream.getName(), e); + } + } finally { + if (context != null) { + try { + context.complete(); + } catch (SQLException e) { + log.error("Error completing DSpace context", e); + } + } } + } - Item item = (Item) dso; - // This bitstream is used to get it's item in the statistics tracker - Bitstream bitstreamForStatistics = null; - name = item.getName() + ".zip"; - response.setHeader(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment;filename=\"%s\"", name)); - response.setContentType("application/zip"); - List bundles = item.getBundles("ORIGINAL"); - - ZipArchiveOutputStream zip = new ZipArchiveOutputStream(response.getOutputStream()); - zip.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.ALWAYS); - zip.setLevel(Deflater.NO_COMPRESSION); - for (Bundle original : bundles) { - List bss = original.getBitstreams(); - for (Bitstream bitstream : bss) { - String filename = bitstream.getName(); - ZipArchiveEntry ze = new ZipArchiveEntry(filename); - zip.putArchiveEntry(ze); - // Get content of the bitstream - // Retrieve method authorize bitstream download action. - InputStream is = bitstreamService.retrieve(context, bitstream); - IOUtils.copy(is, zip); - zip.closeArchiveEntry(); - is.close(); - if (bitstreamForStatistics == null) { - bitstreamForStatistics = bitstream; + /** + * Finds a bitstream by name within an Item's ORIGINAL bundles. + * This method searches through all ORIGINAL bundles of the item to locate + * a bitstream with the exact matching name. + * + * @param item The Item to search for bitstreams + * @param name The exact name of the bitstream to find + * @return The matching Bitstream object, or null if not found + */ + private Bitstream findBitstreamByName(Item item, String name) { + for (Bundle bundle : item.getBundles(org.dspace.core.Constants.CONTENT_BUNDLE_NAME)) { + for (Bitstream bitstream : bundle.getBitstreams()) { + if (name.equals(bitstream.getName())) { + return bitstream; } } } - zip.close(); - matomoBitstreamTracker.trackBitstreamDownload(context, request, bitstreamForStatistics, true); + return null; + } + + /** + * Streams bitstream content to the HTTP response output stream. + * Uses a buffered approach for efficient streaming of large files. + * + * @param inputStream The input stream containing the bitstream data + * @param response The HTTP response to write the data to + * @throws IOException if an I/O error occurs during streaming + */ + private void streamBitstreamToResponse(InputStream inputStream, HttpServletResponse response) + throws IOException { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, bytesRead); + } response.getOutputStream().flush(); } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java index 63f7e62206f8..4ae0bd0b8094 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java @@ -7,48 +7,35 @@ */ package org.dspace.app.rest; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.io.ByteArrayOutputStream; import java.io.InputStream; -import java.util.zip.Deflater; import org.apache.commons.codec.CharEncoding; -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; -import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.apache.commons.io.IOUtils; -import org.dspace.app.rest.model.ItemRest; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; -import org.dspace.authorize.service.AuthorizeService; +import org.dspace.authorize.ResourcePolicy; +import org.dspace.authorize.factory.AuthorizeServiceFactory; +import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.builder.BitstreamBuilder; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; import org.dspace.builder.ItemBuilder; -import org.dspace.content.Bitstream; import org.dspace.content.Collection; import org.dspace.content.Item; -import org.dspace.content.service.BitstreamService; import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MvcResult; public class MetadataBitstreamControllerIT extends AbstractControllerIntegrationTest { - private static final String METADATABITSTREAM_ENDPOINT = "/api/" + ItemRest.CATEGORY + "/" + ItemRest.PLURAL_NAME; - private static final String ALL_ZIP_PATH = "allzip"; - private static final String HANDLE_PARAM = "handleId"; private static final String AUTHOR = "Test author name"; - private Collection col; private Item publicItem; - private Bitstream bts; - - @Autowired - AuthorizeService authorizeService; - - @Autowired - BitstreamService bitstreamService; - + private ResourcePolicyService resourcePolicyService; @Override public void setUp() throws Exception { @@ -58,7 +45,8 @@ public void setUp() throws Exception { .withName("Parent Community") .build(); - col = CollectionBuilder.createCollection(context, parentCommunity).withName("Collection").build(); + Collection col = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection").build(); publicItem = ItemBuilder.createItem(context, col) .withAuthor(AUTHOR) @@ -66,35 +54,122 @@ public void setUp() throws Exception { String bitstreamContent = "ThisIsSomeDummyText"; try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { - bts = BitstreamBuilder. - createBitstream(context, publicItem, is) + BitstreamBuilder.createBitstream(context, publicItem, is) .withName("Bitstream") .withDescription("Description") .withMimeType("application/zip") .build(); } + resourcePolicyService = AuthorizeServiceFactory.getInstance().getResourcePolicyService(); + context.restoreAuthSystemState(); + } + + /** + * Test downloading multiple bitstreams separately by name using the new endpoint. + * This test verifies that each bitstream can be downloaded individually without + * creating a ZIP archive, allowing multiple files to be downloaded as separate files. + */ + @Test + public void downloadMultipleBitstreamsSeparatelyTest() throws Exception { + context.turnOffAuthorisationSystem(); + + // Create additional bitstreams for testing multiple downloads + String content = "Document content for testing individual downloads"; + String name = "document1.txt"; + String mimeType = "text/plain"; + try (InputStream is = IOUtils.toInputStream(content, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, publicItem, is) + .withName(name) + .withDescription("First test document") + .withMimeType(mimeType) + .build(); + } + + context.restoreAuthSystemState(); + + // Generate auth token for admin user + String token = getAuthToken(admin.getEmail(), password); + + // Download bitstream by name using the new endpoint + MvcResult mvcResult = getClient(token) + .perform(get("/api/core/bitstreams/handle/" + publicItem.getHandle() + "/" + name)) + .andExpect(status().isOk()) + .andReturn(); + // Verify the downloaded content matches the expected content + String downloadedContent = mvcResult.getResponse().getContentAsString(); + assertEquals("Downloaded content should match expected content for " + name, + content, downloadedContent); + // Verify correct content type + String contentType = mvcResult.getResponse().getContentType(); + assertTrue("Content type should start with expected MIME type for " + name, + contentType.startsWith(mimeType)); + // Verify Content-Disposition header for proper file download + String contentDisposition = mvcResult.getResponse().getHeader("Content-Disposition"); + assertNotNull("Content-Disposition header should be present for " + name, + contentDisposition); + assertTrue("Content-Disposition should be attachment for " + name, + contentDisposition.startsWith("attachment")); + + // Test error cases + // Test downloading non-existent bitstream should return 422 + getClient(token) + .perform(get("/api/core/bitstreams/handle/" + publicItem.getHandle() + "/nonexistent.txt")) + .andExpect(status().isUnprocessableEntity()); + + // Test with invalid handle should return 422 + getClient(token) + .perform(get("/api/core/bitstreams/handle/invalid-prefix/handle-suffix/document1.txt")) + .andExpect(status().isUnprocessableEntity()); + + context.turnOffAuthorisationSystem(); + resourcePolicyService.removePolicies(context, publicItem, ResourcePolicy.TYPE_INHERITED); context.restoreAuthSystemState(); + + // Test unauthorized access (without token) should return 401 + getClient() + .perform(get("/api/core/bitstreams/handle/" + publicItem.getHandle() + "/document1.txt")) + .andExpect(status().isUnauthorized()); } + /** + * Test downloading bitstream with special characters in filename. + * This ensures the endpoint handles filenames with spaces, special characters correctly. + */ @Test - public void downloadAllZip() throws Exception { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - ZipArchiveOutputStream zip = new ZipArchiveOutputStream(byteArrayOutputStream); - zip.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.ALWAYS); - zip.setLevel(Deflater.NO_COMPRESSION); - ZipArchiveEntry ze = new ZipArchiveEntry(bts.getName()); - zip.putArchiveEntry(ze); - InputStream is = bitstreamService.retrieve(context, bts); - org.apache.commons.compress.utils.IOUtils.copy(is, zip); - zip.closeArchiveEntry(); - is.close(); - zip.close(); + public void downloadBitstreamWithSpecialCharactersTest() throws Exception { + context.turnOffAuthorisationSystem(); + + String specialContent = "Content of file with special characters in name"; + String specialFileName = "test file with spaces & special chars (2024).pdf"; + + try (InputStream is = IOUtils.toInputStream(specialContent, CharEncoding.UTF_8)) { + BitstreamBuilder.createBitstream(context, publicItem, is) + .withName(specialFileName) + .withDescription("File with special characters in name") + .withMimeType("application/pdf") + .build(); + } + + context.restoreAuthSystemState(); String token = getAuthToken(admin.getEmail(), password); - getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + publicItem.getID() + - "/" + ALL_ZIP_PATH).param(HANDLE_PARAM, publicItem.getHandle())) + + // Test downloading bitstream with special characters in name + MvcResult mvcResult = getClient(token) + .perform(get("/api/core/bitstreams/handle/" + publicItem.getHandle() + "/" + specialFileName)) .andExpect(status().isOk()) - .andExpect(content().bytes(byteArrayOutputStream.toByteArray())); + .andReturn(); + + // Verify content + byte[] downloaded = mvcResult.getResponse().getContentAsByteArray(); + assertArrayEquals("Downloaded bytes should match for file with special characters", + specialContent.getBytes(java.nio.charset.StandardCharsets.UTF_8), downloaded); + String contentDisposition = mvcResult.getResponse().getHeader("Content-Disposition"); + assertTrue("Content-Disposition should start with attachment", + contentDisposition.startsWith("attachment")); + assertTrue("Content-Disposition should contain filename information", + contentDisposition.contains("filename=\"" + specialFileName + "\"") || + contentDisposition.contains("filename*=")); } }