From 7623cd158f43e79f5f5e81793eec384c19543491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Wed, 18 Feb 2026 09:26:46 +0100 Subject: [PATCH 1/7] UFAL/Fixed failing integration test (ufal/clarin-dspace#1332) (#1249) * Add debug messages to fauling test (cherry picked from commit 4cc3694b1f75124f5d945f26e256a4b91f34d2d9) Co-authored-by: Milan Kuchtiak --- .../test/java/org/dspace/workflow/WorkflowCurationIT.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dspace-api/src/test/java/org/dspace/workflow/WorkflowCurationIT.java b/dspace-api/src/test/java/org/dspace/workflow/WorkflowCurationIT.java index 66dd2cee807f..dfe61a30b2b2 100644 --- a/dspace-api/src/test/java/org/dspace/workflow/WorkflowCurationIT.java +++ b/dspace-api/src/test/java/org/dspace/workflow/WorkflowCurationIT.java @@ -22,6 +22,7 @@ import org.dspace.content.Community; import org.dspace.content.MetadataValue; import org.dspace.content.service.ItemService; +import org.dspace.core.LegacyPluginServiceImpl; import org.dspace.ctask.testing.MarkerTask; import org.dspace.eperson.EPerson; import org.dspace.util.DSpaceConfigurationInitializer; @@ -29,6 +30,7 @@ import org.dspace.xmlworkflow.storedcomponents.XmlWorkflowItem; import org.junit.Test; import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; @@ -46,6 +48,8 @@ public class WorkflowCurationIT extends AbstractIntegrationTestWithDatabase { @Inject private ItemService itemService; + @Autowired + private LegacyPluginServiceImpl legacyPluginService; /** * Basic smoke test of a curation task attached to a workflow step. @@ -56,6 +60,7 @@ public class WorkflowCurationIT public void curationTest() throws Exception { context.turnOffAuthorisationSystem(); + legacyPluginService.clearNamedPluginClasses(); //** GIVEN ** From 78f9648a682622cd5428f469b60b99d7650aff57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Thu, 19 Feb 2026 09:51:08 +0100 Subject: [PATCH 2/7] [Port to dtq-dev] Fix OpenAIRE integration: null handling and HTTP client lifecycle (#1248) * Fix OpenAIRE integration: null handling and HTTP client lifecycle (ufal/clarin-dspace#1330) * Add test for OpenAIRE connector * Initial plan * Add null check for OpenAIRE response to prevent NullPointerException Co-authored-by: kosarko <1842385+kosarko@users.noreply.github.com> * Fix HTTP client lifecycle to prevent premature connection closure Co-authored-by: kosarko <1842385+kosarko@users.noreply.github.com> * Keep the try with resources but copy the response into an in memory stream and return that * license:check --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kosarko <1842385+kosarko@users.noreply.github.com> (cherry picked from commit 02984dbe314ad80e192624cda7f8bc7c99a0eba8) * Handle NumberFormatException in OpenAIREFundingDataProvider.getNumberOfResults and use explicit UTF-8 charset in OpenAIRERestConnectorTest --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: kosarko <1842385+kosarko@users.noreply.github.com> Co-authored-by: milanmajchrak --- checkstyle-suppressions.xml | 1 + .../external/OpenAIRERestConnector.java | 7 ++- .../impl/OpenAIREFundingDataProvider.java | 14 ++++- .../external/OpenAIRERestConnectorTest.java | 63 +++++++++++++++++++ .../impl/OpenAIREFundingDataProviderTest.java | 19 ++++++ 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 dspace-api/src/test/java/org/dspace/external/OpenAIRERestConnectorTest.java diff --git a/checkstyle-suppressions.xml b/checkstyle-suppressions.xml index 46bd9ca80d62..963165f75cef 100644 --- a/checkstyle-suppressions.xml +++ b/checkstyle-suppressions.xml @@ -8,4 +8,5 @@ on JMockIt Expectations blocks and similar. See https://github.com/checkstyle/checkstyle/issues/3739 --> + diff --git a/dspace-api/src/main/java/org/dspace/external/OpenAIRERestConnector.java b/dspace-api/src/main/java/org/dspace/external/OpenAIRERestConnector.java index 8b5fb1e523c5..7d1d40b03359 100644 --- a/dspace-api/src/main/java/org/dspace/external/OpenAIRERestConnector.java +++ b/dspace-api/src/main/java/org/dspace/external/OpenAIRERestConnector.java @@ -207,8 +207,11 @@ public InputStream get(String file, String accessToken) { break; } - // do not close this httpClient - result = getResponse.getEntity().getContent(); + // the client will be closed, we need to copy the response stream to a new one that we can return + try (InputStream is = getResponse.getEntity().getContent()) { + byte[] bytes = is.readAllBytes(); + result = new java.io.ByteArrayInputStream(bytes); + } } } catch (MalformedURLException e1) { getGotError(e1, url + '/' + file); diff --git a/dspace-api/src/main/java/org/dspace/external/provider/impl/OpenAIREFundingDataProvider.java b/dspace-api/src/main/java/org/dspace/external/provider/impl/OpenAIREFundingDataProvider.java index 8ca5b7c0ea5c..a46080698811 100644 --- a/dspace-api/src/main/java/org/dspace/external/provider/impl/OpenAIREFundingDataProvider.java +++ b/dspace-api/src/main/java/org/dspace/external/provider/impl/OpenAIREFundingDataProvider.java @@ -169,7 +169,19 @@ public int getNumberOfResults(String query) { String encodedQuery = encodeValue(query); Response projectResponse = connector.searchProjectByKeywords(0, 0, encodedQuery); - return Integer.parseInt(projectResponse.getHeader().getTotal()); + if (projectResponse == null || projectResponse.getHeader() == null) { + return 0; + } + String total = projectResponse.getHeader().getTotal(); + if (StringUtils.isBlank(total)) { + return 0; + } + try { + return Integer.parseInt(total); + } catch (NumberFormatException e) { + log.error("Failed to parse search result count from OpenAIRE: {}", e.getMessage()); + return 0; + } } /** diff --git a/dspace-api/src/test/java/org/dspace/external/OpenAIRERestConnectorTest.java b/dspace-api/src/test/java/org/dspace/external/OpenAIRERestConnectorTest.java new file mode 100644 index 000000000000..940ccb93fec6 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/external/OpenAIRERestConnectorTest.java @@ -0,0 +1,63 @@ +/** + * 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.external; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import eu.openaire.jaxb.model.Response; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.dspace.app.client.DSpaceHttpClientFactory; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + + +public class OpenAIRERestConnectorTest { + + @Test + public void searchProjectByKeywords() { + try (InputStream is = this.getClass().getResourceAsStream("openaire-projects.xml"); + MockWebServer mockServer = new MockWebServer()) { + String projects = new String(is.readAllBytes(), StandardCharsets.UTF_8) + .replaceAll("( mushroom)", "( DEADBEEF)"); + mockServer.enqueue(new MockResponse().setResponseCode(200).setBody(projects)); + + // setup mocks so we don't have to set whole DSpace kernel etc. + // still, the idea is to test how the get method behaves + CloseableHttpClient httpClient = spy(HttpClientBuilder.create().build()); + doReturn(httpClient.execute(new HttpGet(mockServer.url("").toString()))) + .when(httpClient).execute(Mockito.any()); + + DSpaceHttpClientFactory mock = Mockito.mock(DSpaceHttpClientFactory.class); + when(mock.build()).thenReturn(httpClient); + + try (MockedStatic mockedFactory = + Mockito.mockStatic(DSpaceHttpClientFactory.class)) { + mockedFactory.when(DSpaceHttpClientFactory::getInstance).thenReturn(mock); + OpenAIRERestConnector connector = new OpenAIRERestConnector(mockServer.url("").toString()); + Response response = connector.searchProjectByKeywords(0, 10, "keyword"); + // Basically check it doesn't throw UnmarshallerException and that we are getting our mocked response + assertTrue("Expected the query to contain the replaced keyword", + response.getHeader().getQuery().contains("DEADBEEF")); + } + } catch (IOException e) { + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/dspace-api/src/test/java/org/dspace/external/provider/impl/OpenAIREFundingDataProviderTest.java b/dspace-api/src/test/java/org/dspace/external/provider/impl/OpenAIREFundingDataProviderTest.java index 5e96f06ac8ae..fb78313dec57 100644 --- a/dspace-api/src/test/java/org/dspace/external/provider/impl/OpenAIREFundingDataProviderTest.java +++ b/dspace-api/src/test/java/org/dspace/external/provider/impl/OpenAIREFundingDataProviderTest.java @@ -14,7 +14,9 @@ import java.util.List; import java.util.Optional; +import eu.openaire.jaxb.model.Response; import org.dspace.AbstractDSpaceTest; +import org.dspace.external.OpenAIRERestConnector; import org.dspace.external.factory.ExternalServiceFactory; import org.dspace.external.model.ExternalDataObject; import org.dspace.external.provider.ExternalDataProvider; @@ -102,4 +104,21 @@ public void testGetDataObjectWInvalidId() { assertTrue("openAIREFunding.getExternalDataObject.notExists:WRONGID", result.isEmpty()); } + + @Test + public void testGetNumberOfResultsWhenResponseIsNull() { + // Create a mock connector that returns null + OpenAIREFundingDataProvider provider = new OpenAIREFundingDataProvider(); + provider.setSourceIdentifier("test"); + provider.setConnector(new OpenAIRERestConnector("test") { + @Override + public Response searchProjectByKeywords(int page, int size, String... keywords) { + return null; + } + }); + + // Should return 0 when response is null, not throw NullPointerException + int result = provider.getNumberOfResults("test"); + assertEquals("Should return 0 when response is null", 0, result); + } } From 98edc1d737a0cbcce302923a82c395015073df91 Mon Sep 17 00:00:00 2001 From: milanmajchrak <90026355+milanmajchrak@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:02:12 +0100 Subject: [PATCH 3/7] UFAL/Added a comment to do not forget mounting the file which is changed via ocnfiguration feature (#1247) --- dspace/config/dspace.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index 9599647369a1..8330d7ee3cf1 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -1697,4 +1697,5 @@ include = ${module_dir}/external-providers.cfg # Configuration files that can be updated via the admin API # Comma-separated list of file names relative to ${dspace.dir}/config directory # Only these files will be allowed for reading and updating through the REST API +# NOTE! This file should be mounted because after restarting the backend those changes will be lost config.admin.updateable.files = item-submission.xml \ No newline at end of file From 475b25aa88ed756c09804530f47880ea291e8e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Thu, 19 Feb 2026 12:54:50 +0100 Subject: [PATCH 4/7] UFAL/Issue 1315: Store file preview to database when file preview is created on Item Page load. (ufal/clarin-dspace#1316) (#1241) * Issue ufal/clarin-dspace1315: Store file preview to database when file preview is created on item page load * assert text improvement * PR comments: commit context only when any of the file preview is successfully created * change variable name (cherry picked from commit aab626b39ffff1da38f65aa5dffa0ad834856eeb) Co-authored-by: Milan Kuchtiak --- .../MetadataBitstreamRestRepository.java | 11 ++++++-- .../MetadataBitstreamRestRepositoryIT.java | 27 ++++++++++--------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/MetadataBitstreamRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/MetadataBitstreamRestRepository.java index 7fea50431b3a..008635fbeeee 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/MetadataBitstreamRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/MetadataBitstreamRestRepository.java @@ -66,8 +66,7 @@ public class MetadataBitstreamRestRepository extends DSpaceRestRepository findByHandle(@Parameter(value = "handle", required = true) String handle, @Parameter(value = "fileGrpType") String fileGrpType, - Pageable pageable) - throws Exception { + Pageable pageable) throws Exception { if (StringUtils.isBlank(handle)) { throw new DSpaceBadRequestException("handle cannot be null!"); } @@ -80,6 +79,8 @@ public Page findByHandle(@Parameter(value = "handl List rs = new ArrayList<>(); DSpaceObject dso; + boolean previewContentCreated = false; + try { dso = handleService.resolveToObject(context, handle); } catch (Exception e) { @@ -127,6 +128,7 @@ public Page findByHandle(@Parameter(value = "handl for (FileInfo fi : fileInfos) { previewContentService.createPreviewContent(context, bitstream, fi); } + previewContentCreated = true; } } } else { @@ -147,6 +149,11 @@ public Page findByHandle(@Parameter(value = "handl } } + // commit changes if any preview content was generated + if (previewContentCreated) { + context.commit(); + } + return new PageImpl<>(rs, pageable, rs.size()); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamRestRepositoryIT.java index d28c814f4468..544240beffcc 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamRestRepositoryIT.java @@ -12,6 +12,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertFalse; +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.jsonPath; @@ -84,16 +85,16 @@ public void setup() throws Exception { .build(); // create empty THUMBNAIL bundle - bundleService.create(context, publicItem, "THUMBNAIL"); - - String bitstreamContent = "ThisIsSomeDummyText"; - InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8); - bts = BitstreamBuilder. - createBitstream(context, publicItem, is) - .withName("Bitstream") - .withDescription("Description") - .withMimeType("application/x-gzip") - .build(); + bundleService.create(context, publicItem, "ORIGINAL"); + + try (InputStream is = getClass().getResourceAsStream("assetstore/logos.tgz")) { + bts = BitstreamBuilder. + createBitstream(context, publicItem, is) + .withName("Bitstream") + .withDescription("Description") + .withMimeType("application/x-gtar") + .build(); + } // Allow composing of file preview in the config configurationService.setProperty("create.file-preview.on-item-page-load", true); @@ -116,6 +117,8 @@ public void findByHandle() throws Exception { // There is no restriction, so the user could preview the file boolean canPreview = true; + assertFalse("Expects preview content not created yet.", previewContentService.hasPreview(context, bts)); + getClient().perform(get(METADATABITSTREAM_SEARCH_BY_HANDLE_ENDPOINT) .param("handle", publicItem.getHandle()) .param("fileGrpType", FILE_GRP_TYPE)) @@ -135,13 +138,13 @@ public void findByHandle() throws Exception { .value(hasItem(is((int) bts.getSizeBytes())))) .andExpect(jsonPath("$._embedded.metadatabitstreams[*].canPreview") .value(Matchers.containsInAnyOrder(Matchers.is(canPreview)))) - .andExpect(jsonPath("$._embedded.metadatabitstreams[*].fileInfo").exists()) + .andExpect(jsonPath("$._embedded.metadatabitstreams[0].fileInfo").value(Matchers.hasSize(2))) .andExpect(jsonPath("$._embedded.metadatabitstreams[*].checksum") .value(Matchers.containsInAnyOrder(Matchers.containsString(bts.getChecksum())))) .andExpect(jsonPath("$._embedded.metadatabitstreams[*].href") .value(Matchers.containsInAnyOrder(Matchers.containsString(url)))); - + assertTrue("Expects preview content created and stored.", previewContentService.hasPreview(context, bts)); } @Test From ff0427ebebe9abecddad985c50ca31f2fdb0680c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Thu, 19 Feb 2026 13:01:14 +0100 Subject: [PATCH 5/7] UFAL/Issue 1313: fixed error when file preview is not generated for bitstream with store_number = 77 (ufal/clarin-dspace#1318) (#1240) * Issue ufal/clarin-dspace#1313: fixed error when file preview is not generated for bitstream with store number = 77 * resolve MR comments (cherry picked from commit 04d64f718f3964ed2174ca36d0ade324fe20e997) Co-authored-by: Milan Kuchtiak --- .../SyncBitstreamStorageServiceImpl.java | 10 +- .../dspace/builder/WorkspaceItemBuilder.java | 23 +++++ .../scripts/filepreview/FilePreviewIT.java | 87 ++++++++++++++---- .../org/dspace/scripts/filepreview/logos.tgz | Bin 0 -> 18980 bytes 4 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 dspace-api/src/test/resources/org/dspace/scripts/filepreview/logos.tgz diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncBitstreamStorageServiceImpl.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncBitstreamStorageServiceImpl.java index d2266f02d75c..2ea0ffe6aaa4 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncBitstreamStorageServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncBitstreamStorageServiceImpl.java @@ -7,6 +7,7 @@ */ package org.dspace.storage.bitstore; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.sql.SQLException; @@ -185,12 +186,17 @@ public Map computeChecksumSpecStore(Context context, Bitstream bitstream, int st } @Override - public InputStream retrieve(Context context, Bitstream bitstream) - throws SQLException, IOException { + public InputStream retrieve(Context context, Bitstream bitstream) throws SQLException, IOException { int storeNumber = this.whichStoreNumber(bitstream); return this.getStore(storeNumber).get(bitstream); } + @Override + public File retrieveFile(Context context, Bitstream bitstream) throws IOException { + int storeNumber = whichStoreNumber(bitstream); + return this.getStore(storeNumber).getFile(bitstream); + } + @Override public void cleanup(boolean deleteDbRecords, boolean verbose) throws SQLException, IOException, AuthorizeException { Context context = new Context(Context.Mode.BATCH_EDIT); diff --git a/dspace-api/src/test/java/org/dspace/builder/WorkspaceItemBuilder.java b/dspace-api/src/test/java/org/dspace/builder/WorkspaceItemBuilder.java index 580e4dfef61f..75c8ea886a2a 100644 --- a/dspace-api/src/test/java/org/dspace/builder/WorkspaceItemBuilder.java +++ b/dspace-api/src/test/java/org/dspace/builder/WorkspaceItemBuilder.java @@ -249,6 +249,29 @@ public WorkspaceItemBuilder withFulltext(String name, String source, InputStream return this; } + /** + * Add bitstream with specific store number. + * + * @param name bitstream name + * @param source bitstream test source location + * @param is input stream of the bitstream + * @param storeNumber store number + * + * @return this WorkspaceItemBuilder + */ + public WorkspaceItemBuilder withBitstream(String name, String source, InputStream is, int storeNumber) { + try { + Item item = workspaceItem.getItem(); + Bitstream b = itemService.createSingleBitstream(context, is, item); + b.setStoreNumber(storeNumber); + b.setName(context, name); + b.setSource(context, source); + } catch (Exception e) { + handleException(e); + } + return this; + } + /** * Create workspaceItem with any metadata * @param schema metadataSchema name e.g. `dc` diff --git a/dspace-api/src/test/java/org/dspace/scripts/filepreview/FilePreviewIT.java b/dspace-api/src/test/java/org/dspace/scripts/filepreview/FilePreviewIT.java index 7d3cad26fc0a..d03384c25d7b 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/filepreview/FilePreviewIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/filepreview/FilePreviewIT.java @@ -13,6 +13,8 @@ import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasSize; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import java.io.InputStream; import java.sql.SQLException; @@ -36,7 +38,11 @@ import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.BitstreamFormatService; import org.dspace.content.service.BitstreamService; +import org.dspace.content.service.PreviewContentService; import org.dspace.eperson.EPerson; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.dspace.storage.bitstore.SyncBitstreamStorageServiceImpl; import org.junit.Before; import org.junit.Test; @@ -45,10 +51,14 @@ * @author Milan Majchrak (milan.majchrak at dataquest.sk) */ public class FilePreviewIT extends AbstractIntegrationTestWithDatabase { - BitstreamService bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); + private static final int SYNC_STORE_NUMBER = SyncBitstreamStorageServiceImpl.SYNCHRONIZED_STORES_NUMBER; + BitstreamService bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); BitstreamFormatService bitstreamFormatService = ContentServiceFactory.getInstance().getBitstreamFormatService(); + PreviewContentService previewContentService = ContentServiceFactory.getInstance().getPreviewContentService(); + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + Collection collection; Item item; EPerson eperson; String PASSWORD = "test"; @@ -61,7 +71,7 @@ public void setup() throws SQLException, AuthorizeException { eperson = EPersonBuilder.createEPerson(context) .withEmail("test@test.edu").withPassword(PASSWORD).build(); Community community = CommunityBuilder.createCommunity(context).withName("Com").build(); - Collection collection = CollectionBuilder.createCollection(context, community).withName("Col").build(); + collection = CollectionBuilder.createCollection(context, community).withName("Col").build(); WorkspaceItem wItem = WorkspaceItemBuilder.createWorkspaceItem(context, collection) .withFulltext("preview-file-test.zip", "/local/path/preview-file-test.zip", previewZipIs) .build(); @@ -116,22 +126,45 @@ public void testWhenNoFilesRun() throws Exception { @Test public void testForSpecificItem() throws Exception { // Run the script - TestDSpaceRunnableHandler testDSpaceRunnableHandler = new TestDSpaceRunnableHandler(); - String[] args = new String[] { "file-preview", "-u", item.getID().toString(), - "-e", eperson.getEmail(), "-p", PASSWORD}; - int run = ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), - testDSpaceRunnableHandler, kernelImpl); - assertEquals(0, run); - // There should be no errors or warnings - checkNoError(testDSpaceRunnableHandler); + runScriptForItemWithBitstreams(item); + } - // There should be an info message about generating the file previews for the specified item - List messages = testDSpaceRunnableHandler.getInfoMessages(); - assertThat(messages, hasSize(2)); - assertThat(messages, hasItem(containsString("Generate the file previews for the specified item with " + - "the given UUID: " + item.getID()))); - assertThat(messages, - hasItem(containsString("Authentication by user: " + eperson.getEmail()))); + @Test + public void testPreviewWithSyncStorage() throws Exception { + configurationService.setProperty("sync.storage.service.enabled", true); + + context.turnOffAuthorisationSystem(); + + WorkspaceItem wItem2; + try (InputStream tgzFile = getClass().getResourceAsStream("logos.tgz")) { + wItem2 = WorkspaceItemBuilder.createWorkspaceItem(context, collection) + .withBitstream("logos.tgz", "/local/path/logos.tgz", tgzFile, SYNC_STORE_NUMBER) + .build(); + } + + context.restoreAuthSystemState(); + + // Get the item and its bitstream + Item item2 = wItem2.getItem(); + List bundles = item2.getBundles(); + Bitstream bitstream2 = bundles.get(0).getBitstreams().get(0); + + // Set the bitstream format to application/zip + BitstreamFormat bitstreamFormat = bitstreamFormatService.findByMIMEType(context, "application/x-gtar"); + bitstream2.setFormat(context, bitstreamFormat); + bitstreamService.update(context, bitstream2); + context.commit(); + context.reloadEntity(bitstream2); + context.reloadEntity(item2); + + runScriptForItemWithBitstreams(item2); + + Bitstream b2 = bitstreamService.findAll(context).stream() + .filter(b -> b.getStoreNumber() == SYNC_STORE_NUMBER) + .findFirst().orElse(null); + + assertNotNull(b2); + assertTrue("Expects preview content created and stored.", previewContentService.hasPreview(context, b2)); } @Test @@ -150,4 +183,24 @@ private void checkNoError(TestDSpaceRunnableHandler testDSpaceRunnableHandler) { assertThat(testDSpaceRunnableHandler.getErrorMessages(), empty()); assertThat(testDSpaceRunnableHandler.getWarningMessages(), empty()); } + + private void runScriptForItemWithBitstreams(Item item) throws Exception { + // Run the script + TestDSpaceRunnableHandler testDSpaceRunnableHandler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "file-preview", "-u", item.getID().toString(), + "-e", eperson.getEmail(), "-p", PASSWORD}; + int run = ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), + testDSpaceRunnableHandler, kernelImpl); + assertEquals(0, run); + // There should be no errors or warnings + checkNoError(testDSpaceRunnableHandler); + + // There should be an info message about generating the file previews for the specified item + List messages = testDSpaceRunnableHandler.getInfoMessages(); + assertThat(messages, hasSize(2)); + assertThat(messages, hasItem(containsString("Generate the file previews for the specified item with " + + "the given UUID: " + item.getID()))); + assertThat(messages, + hasItem(containsString("Authentication by user: " + eperson.getEmail()))); + } } diff --git a/dspace-api/src/test/resources/org/dspace/scripts/filepreview/logos.tgz b/dspace-api/src/test/resources/org/dspace/scripts/filepreview/logos.tgz new file mode 100644 index 0000000000000000000000000000000000000000..ad1e02dc605973cb27944b5002a12952163b0a5d GIT binary patch literal 18980 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 literal 0 HcmV?d00001 From 2f034085bdad48c192fe5d166092b23f0a7d5e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Thu, 19 Feb 2026 14:33:10 +0100 Subject: [PATCH 6/7] UFAL/Nw version metadata issues (#1236) * Issue ufal/clarin-dspace#1266: dc.date.available and dc.relation.replaces metadata not cleared properly (ufal/clarin-dspace#1307) * Issue ufal/clarin-dspace#1266: dc.date.available and dc.relation.replaces metadata not cleaned properly in new item version * resolve MR comments - update ignoredMetadataFields in versioning-service.xml * update ClarinVersionedHandleIdentifierProviderIT test to check dc.identifier.uri metadata for new version (cherry picked from commit 7ffaf9a807d94da0cec77b67a9cad2d1fc7c5f38) * Issue 1319: do not copy dc.identifier.doi metadata when new item version is created (cherry picked from commit 1b7ed17228c5f1260b250361fa87f49d9af0e14a) --------- Co-authored-by: Milan Kuchtiak --- .../DefaultItemVersionProvider.java | 2 +- ...inVersionedHandleIdentifierProviderIT.java | 155 ++++++++++++++++++ .../config/spring/api/versioning-service.xml | 5 +- 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 dspace-api/src/test/java/org/dspace/identifier/ClarinVersionedHandleIdentifierProviderIT.java diff --git a/dspace-api/src/main/java/org/dspace/versioning/DefaultItemVersionProvider.java b/dspace-api/src/main/java/org/dspace/versioning/DefaultItemVersionProvider.java index 5a2695b9a61e..07d57496cfe7 100644 --- a/dspace-api/src/main/java/org/dspace/versioning/DefaultItemVersionProvider.java +++ b/dspace-api/src/main/java/org/dspace/versioning/DefaultItemVersionProvider.java @@ -202,7 +202,7 @@ protected void copyRelationships( */ private void manageRelationMetadata(Context c, Item itemNew, Item previousItem) throws SQLException { // Remove copied `dc.relation.replaces` metadata for the new item. - itemService.clearMetadata(c, itemNew, "dc", "relation", "replaces", null); + itemService.clearMetadata(c, itemNew, "dc", "relation", "replaces", Item.ANY); // Add metadata `dc.relation.replaces` to the new item. // The metadata value is: `dc.identifier.uri` from the previous item. diff --git a/dspace-api/src/test/java/org/dspace/identifier/ClarinVersionedHandleIdentifierProviderIT.java b/dspace-api/src/test/java/org/dspace/identifier/ClarinVersionedHandleIdentifierProviderIT.java new file mode 100644 index 000000000000..355ed2a8fb90 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/identifier/ClarinVersionedHandleIdentifierProviderIT.java @@ -0,0 +1,155 @@ +/** + * 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.identifier; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.TimeZone; + +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.builder.VersionBuilder; +import org.dspace.content.Collection; +import org.dspace.content.Item; +import org.dspace.content.MetadataValue; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.InstallItemService; +import org.dspace.content.service.ItemService; +import org.dspace.kernel.ServiceManager; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.dspace.workflow.WorkflowItem; +import org.dspace.workflow.WorkflowItemService; +import org.dspace.workflow.factory.WorkflowServiceFactory; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit Tests for ClarinVersionedHandleIdentifierProvider + * + * @authorMilan Kuchtiak + */ +public class ClarinVersionedHandleIdentifierProviderIT extends AbstractIntegrationTestWithDatabase { + private IdentifierServiceImpl identifierService; + private InstallItemService installItemService; + private ItemService itemService; + private WorkflowItemService workflowItemService; + + private Collection collection; + + @Before + @Override + public void setUp() throws Exception { + super.setUp(); + context.turnOffAuthorisationSystem(); + + ServiceManager serviceManager = DSpaceServicesFactory.getInstance().getServiceManager(); + identifierService = serviceManager.getServicesByType(IdentifierServiceImpl.class).get(0); + + itemService = ContentServiceFactory.getInstance().getItemService(); + installItemService = ContentServiceFactory.getInstance().getInstallItemService(); + workflowItemService = WorkflowServiceFactory.getInstance().getWorkflowItemService(); + + // Clean out providers to avoid any being used for creation of community and collection + identifierService.setProviders(new ArrayList<>()); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + collection = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + } + + @Test + public void testNewVersionMetadata() throws Exception { + registerProvider(ClarinVersionedHandleIdentifierProvider.class); + Item itemV1 = ItemBuilder.createItem(context, collection) + .withTitle("First version") + .build(); + + // new item "dc.relation.replaces" metadata has to be set to this value + String itemV1HandleRef = itemService.getMetadataFirstValue(itemV1, "dc", "identifier", "uri", Item.ANY); + + // set "dc.relation.replaces" metadata on itemV1 + itemService.addMetadata(context, itemV1, "dc", "relation", "replaces", null, "some_value"); + // replace "dc.date.available" metadata on itemV1 to some old value + itemService.clearMetadata(context, itemV1, "dc", "date", "available", Item.ANY); + itemService.addMetadata(context, itemV1, "dc", "date", "available", null, "2020-01-01"); + // simulate itemV1 having a DOI identifier assigned + itemService.addMetadata(context, itemV1, "dc", "identifier", "doi", null, + "https://handle.stage.datacite.org/10.5072/dspace-1"); + + Item itemV2 = VersionBuilder.createVersion(context, itemV1, "Second version").build().getItem(); + + // check that "dc.date.available", metadata is not copied to itemV2 + assertThat(itemService.getMetadata(itemV2, "dc", "date", "available", Item.ANY).size(), equalTo(0)); + + // check that "dc.identifier.uri", metadata is not copied to itemV2 + assertThat(itemService.getMetadata(itemV2, "dc", "identifier", "uri", Item.ANY).size(), equalTo(0)); + + // check that "dc.identifier.doi", metadata is not copied to itemV2 + assertThat(itemService.getMetadata(itemV2, "dc", "identifier", "doi", Item.ANY).size(), equalTo(0)); + + // check that "dc.relation.replaces" points to itemV1 + List metadataValues = itemService.getMetadata(itemV2, "dc", "relation", "replaces", Item.ANY); + assertThat(metadataValues.size(), equalTo(1)); + assertThat(metadataValues.get(0).getValue(), equalTo(itemV1HandleRef)); + + WorkflowItem workflowItem = workflowItemService.create(context, itemV2, collection); + Item installedItem = installItemService.installItem(context, workflowItem); + + // get current date + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(System.currentTimeMillis()); + calendar.setTimeZone(TimeZone.getTimeZone("UTC")); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + String date = sdf.format(calendar.getTime()); + + // check that "dc.relation.replaces" points to itemV1 + metadataValues = itemService.getMetadata(installedItem, "dc", "relation", "replaces", Item.ANY); + assertThat(metadataValues.size(), equalTo(1)); + assertThat(metadataValues.get(0).getValue(), equalTo(itemV1HandleRef)); + + // Check that itemV2 has the correct "dc.date.available" metadata set to current date + metadataValues = itemService.getMetadata(installedItem, "dc", "date", "available", Item.ANY); + assertThat(metadataValues.size(), equalTo(1)); + assertThat(metadataValues.get(0).getValue(), startsWith(date)); + + // check "dc.identifier.uri" metadata has new value different from itemV1 + metadataValues = itemService.getMetadata(installedItem, "dc", "identifier", "uri", Item.ANY); + assertThat(metadataValues.size(), equalTo(1)); + assertThat(metadataValues.get(0).getValue(), not(itemV1HandleRef)); + } + + private void registerProvider(Class type) { + // Register our new provider + IdentifierProvider identifierProvider = + (IdentifierProvider) DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName(type.getName(), type); + if (identifierProvider == null) { + DSpaceServicesFactory.getInstance().getServiceManager().registerServiceClass(type.getName(), type); + identifierProvider = (IdentifierProvider) DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName(type.getName(), type); + } + + // Overwrite the identifier-service's providers with the new one to ensure only this provider is used + identifierService = DSpaceServicesFactory.getInstance().getServiceManager() + .getServicesByType(IdentifierServiceImpl.class).get(0); + identifierService.setProviders(new ArrayList<>()); + identifierService.setProviders(List.of(identifierProvider)); + } +} diff --git a/dspace/config/spring/api/versioning-service.xml b/dspace/config/spring/api/versioning-service.xml index 1a5358edd777..d8b76299e432 100644 --- a/dspace/config/spring/api/versioning-service.xml +++ b/dspace/config/spring/api/versioning-service.xml @@ -21,11 +21,14 @@ dc.date.accessioned + dc.date.available dc.description.provenance + dc.identifier.doi dc.identifier.uri + dc.relation.replaces - + From 0f1ff8b57e772013778b6521c65240bba39816e7 Mon Sep 17 00:00:00 2001 From: milanmajchrak <90026355+milanmajchrak@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:29:04 +0100 Subject: [PATCH 7/7] UFAL/Fix: add bitstream download-by-handle endpoint for curl instructions (#1252) * 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 * Fixed compliing errors * Small refactoring - use constants and removed unnecessary changes * added comments, return 404 status instead of 402 * unauthorized instead of forbidden * 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. * 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. * 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+). * 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) * 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. * 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. * fix authorization, comments, tests * 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. --------- Co-authored-by: Paurikova2 --- .../rest/BitstreamByHandleRestController.java | 322 ++++++++++ ...ionCCLicenseUrlResourceHalLinkFactory.java | 1 + .../BitstreamByHandleRestControllerIT.java | 599 ++++++++++++++++++ 3 files changed, 922 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..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)); + } +}