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/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 **
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 000000000000..ad1e02dc6059
Binary files /dev/null and b/dspace-api/src/test/resources/org/dspace/scripts/filepreview/logos.tgz differ
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/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/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));
+ }
+}
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
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
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
-
+