Request-a-copy improvement: Core model, DAO, service changes

This commit is contained in:
Kim Shepherd
2025-02-13 14:08:12 +01:00
parent b7527daae1
commit 6fd37832c3
4 changed files with 321 additions and 7 deletions

View File

@@ -7,25 +7,33 @@
*/
package org.dspace.app.requestitem;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Iterator;
import java.util.List;
import org.apache.http.client.utils.URIBuilder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.dspace.app.requestitem.dao.RequestItemDAO;
import org.dspace.app.requestitem.service.RequestItemService;
import org.dspace.authorize.AuthorizeException;
import org.dspace.authorize.ResourcePolicy;
import org.dspace.authorize.service.AuthorizeService;
import org.dspace.authorize.service.ResourcePolicyService;
import org.dspace.content.Bitstream;
import org.dspace.content.Bundle;
import org.dspace.content.DSpaceObject;
import org.dspace.content.Item;
import org.dspace.core.Constants;
import org.dspace.core.Context;
import org.dspace.core.LogHelper;
import org.dspace.core.Utils;
import org.dspace.eperson.EPerson;
import org.dspace.services.ConfigurationService;
import org.springframework.beans.factory.annotation.Autowired;
/**
@@ -35,6 +43,7 @@ import org.springframework.beans.factory.annotation.Autowired;
* This class should never be accessed directly.
*
* @author kevinvandevelde at atmire.com
* @author Kim Shepherd
*/
public class RequestItemServiceImpl implements RequestItemService {
@@ -49,16 +58,38 @@ public class RequestItemServiceImpl implements RequestItemService {
@Autowired(required = true)
protected ResourcePolicyService resourcePolicyService;
@Autowired
protected ConfigurationService configurationService;
private static final int DEFAULT_MINIMUM_FILE_SIZE = 20;
protected RequestItemServiceImpl() {
}
/**
* Create a new request-a-copy item request.
*
* @param context The relevant DSpace Context.
* @param bitstream The requested bitstream
* @param item The requested item
* @param allFiles true indicates that all bitstreams of this item are requested
* @param reqEmail email
* Requester email
* @param reqName Requester name
* @param reqMessage Request message text
* @return token to be used to approver for grant/deny
* @throws SQLException
*/
@Override
public String createRequest(Context context, Bitstream bitstream, Item item,
boolean allFiles, String reqEmail, String reqName, String reqMessage)
throws SQLException {
// Create an empty request item
RequestItem requestItem = requestItemDAO.create(context, new RequestItem());
// Set values of the request item based on supplied parameters
requestItem.setToken(Utils.generateHexKey());
requestItem.setBitstream(bitstream);
requestItem.setItem(item);
@@ -68,10 +99,56 @@ public class RequestItemServiceImpl implements RequestItemService {
requestItem.setReqMessage(reqMessage);
requestItem.setRequest_date(Instant.now());
// If the 'link' feature is enabled and the filesize threshold is met, pre-generate access token now
// so it can be previewed by approver and so Angular and REST services can use the existence of this token
// as an indication of which delivery method to use.
// Access period will be created upon actual approval.
if (configurationService.getBooleanProperty("request.item.grant.link", false)) {
// The 'send link' feature is enabled, is the file(s) requested over the size threshold (megabytes as int)?
// Default is 20MB minimum. For inspection purposes we convert to bytes.
long minimumSize = configurationService.getLongProperty(
"request.item.grant.link.filesize", DEFAULT_MINIMUM_FILE_SIZE) * 1024 * 1024;
// If we have a single bitstream, we will initialise the "minimum threshold reached" correctly
boolean minimumSizeThresholdReached = (null != bitstream && bitstream.getSizeBytes() >= minimumSize);
// If all files (and presumably no min reached since bitstream should be null), we look for ANY >= min size
if (!minimumSizeThresholdReached && allFiles) {
// Iterate bitstream and inspect file sizes. At each loop iteration we will break out if the min
// was already reached.
String[] bundleNames = configurationService.getArrayProperty("request.item.grant.link.bundles",
new String[]{"ORIGINAL"});
for (String bundleName : bundleNames) {
if (!minimumSizeThresholdReached) {
for (Bundle bundle : item.getBundles(bundleName)) {
if (null != bundle && !minimumSizeThresholdReached) {
for (Bitstream bitstreamToCheck : bundle.getBitstreams()) {
if (bitstreamToCheck.getSizeBytes() >= minimumSize) {
minimumSizeThresholdReached = true;
break;
}
}
}
}
}
}
}
// Now, only generate and set an access token if the minimum file size threshold was reached.
// Otherwise, an email attachment will still be used.
// From now on, the existence of an access token in the RequestItem indicates that a web link should be
// sent instead of attaching file(s) as an attachment.
if (minimumSizeThresholdReached) {
requestItem.setAccess_token(Utils.generateHexKey());
}
}
// Save the request item
requestItemDAO.save(context, requestItem);
log.debug("Created RequestItem with ID {} and token {}",
requestItem::getID, requestItem::getToken);
log.debug("Created RequestItem with ID {}, approval token {}, access token {}, access period {}",
requestItem::getID, requestItem::getToken, requestItem::getAccess_token, requestItem::getAccess_period);
// Return the approver token
return requestItem.getToken();
}
@@ -128,4 +205,148 @@ public class RequestItemServiceImpl implements RequestItemService {
}
return true;
}
/**
* Find a request item by its access token. This is the token that a requester would use
* to authenticate themselves as a granted requester.
* It is up to the RequestItemRepository to check validity of the item, access granted, data sanitization, etc.
*
* @param context current DSpace session.
* @param accessToken the token identifying the request to be temporarily accessed
* @return request item data
*/
@Override
public RequestItem findByAccessToken(Context context, String accessToken) {
try {
return requestItemDAO.findByAccessToken(context, accessToken);
} catch (SQLException e) {
log.error(e.getMessage());
return null;
}
}
/**
* Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period,
* either return cleanly or throw an AuthorizeException
*
* @param context the DSpace context
* @param requestItem the request item containing request and approval data
* @param bitstream the bitstream to which access is requested
* @param accessToken the access token supplied by the user (e.g. to REST controller)
* @throws AuthorizeException
*/
@Override
public void authorizeAccessByAccessToken(Context context, RequestItem requestItem, Bitstream bitstream,
String accessToken) throws AuthorizeException {
if (requestItem == null || bitstream == null || context == null || accessToken == null) {
throw new AuthorizeException("Null resources provided, not authorized");
}
// 1. Request is accepted
if (requestItem.isAccept_request()
// 2. Request access token is not null and matches supplied string
&& (requestItem.getAccess_token() != null && requestItem.getAccess_token().equals(accessToken))
// 3. Request is 'allfiles' or for this bitstream ID
&& (requestItem.isAllfiles() || bitstream.equals(requestItem.getBitstream()))
// 4. access period is 0 (forever), or the elapsed seconds since decision date is less than the
// access period granted
&& requestItem.accessPeriodCurrent()
) {
log.info("Authorizing access to bitstream {} by access token", bitstream.getID());
return;
}
// Default, throw authorize exception
throw new AuthorizeException("Unauthorized access to bitstream by access token for bitstream ID "
+ bitstream.getID());
}
/**
* Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period,
* either return cleanly or throw an AuthorizeException
*
* @param context the DSpace context
* @param bitstream the bitstream to which access is requested
* @param accessToken the access token supplied by the user (e.g. to REST controller)
* @throws AuthorizeException
*/
@Override
public void authorizeAccessByAccessToken(Context context, Bitstream bitstream, String accessToken)
throws AuthorizeException {
if (bitstream == null || context == null || accessToken == null) {
throw new AuthorizeException("Null resources provided, not authorized");
}
// get request item from access token
RequestItem requestItem = findByAccessToken(context, accessToken);
if (requestItem == null) {
throw new AuthorizeException("Null item request provided, not authorized");
}
// Continue with authorization check
authorizeAccessByAccessToken(context, requestItem, bitstream, accessToken);
}
/**
* Generate a link back to DSpace, to act on a request.
*
* @param token identifies the request.
* @return URL to the item request API, with the token as request parameter
* "token".
* @throws URISyntaxException passed through.
* @throws MalformedURLException passed through.
*/
@Override
public String getLinkTokenEmail(String token)
throws URISyntaxException, MalformedURLException {
final String base = configurationService.getProperty("dspace.ui.url");
URIBuilder uriBuilder = new URIBuilder(base);
String currentPath = uriBuilder.getPath();
String newPath = (currentPath == null || currentPath.isEmpty() || currentPath.equals("/"))
? "/request-a-copy/" + token
: currentPath + "/request-a-copy/" + token;
URI uri = uriBuilder.setPath(newPath).build();
return uri.toURL().toExternalForm();
}
/**
* Sanitize a RequestItem depending on the current session user. If the current user is not
* the approver, an administrator or other privileged group, the following values in the return object
* are nullified:
* - approver token (aka token)
* - requester name
* - requester email
* - requester message
*
* These properties contain personal information, or can be used to access personal information
* and are not needed except for sending the original request and grant/deny emails
*
* @param requestItem
*/
@Override
public void sanitizeRequestItem(Context context, RequestItem requestItem) {
if (null == requestItem) {
log.error("Null request item passed for sanitization, skipping");
return;
}
if (null != context) {
// Get current user, if any
EPerson currentUser = context.getCurrentUser();
// Get item
Item item = requestItem.getItem();
if (null != currentUser) {
try {
if (currentUser == requestItem.getItem().getSubmitter()
&& authorizeService.isAdmin(context, requestItem.getItem())) {
// Return original object, this person technically had full access to the request item data via
// the original approval link
log.debug("User is authorized to receive all request item data: {}", currentUser.getEmail());
}
} catch (SQLException e) {
log.error("Could not determine isAdmin for item {}: {}",item.getID(), e.getMessage());
}
}
}
// By default, sanitize (strips requester name, email, message, and the approver token)
// This is the case if we have a non-admin, non-submitter or a null user/session
requestItem.sanitizePersonalData();
}
}

View File

@@ -26,7 +26,7 @@ import org.dspace.core.GenericDAO;
*/
public interface RequestItemDAO extends GenericDAO<RequestItem> {
/**
* Fetch a request named by its unique token (passed in emails).
* Fetch a request named by its unique approval token (passed in emails).
*
* @param context the current DSpace context.
* @param token uniquely identifies the request.
@@ -35,5 +35,18 @@ public interface RequestItemDAO extends GenericDAO<RequestItem> {
*/
public RequestItem findByToken(Context context, String token) throws SQLException;
/**
* Fetch a request named by its unique access token (passed in emails).
* Note this is the token used by the requester to access an approved resource, not the token
* used by the item submitter or helpdesk to grant the access.
*
* @param context the current DSpace context.
* @param accessToken uniquely identifies the request
* @return the found request or {@code null}
* @throws SQLException passed through.
*/
public RequestItem findByAccessToken(Context context, String accessToken) throws SQLException;
public Iterator<RequestItem> findByItem(Context context, Item item) throws SQLException;
}

View File

@@ -42,6 +42,17 @@ public class RequestItemDAOImpl extends AbstractHibernateDAO<RequestItem> implem
criteriaQuery.where(criteriaBuilder.equal(requestItemRoot.get(RequestItem_.token), token));
return uniqueResult(context, criteriaQuery, false, RequestItem.class);
}
@Override
public RequestItem findByAccessToken(Context context, String accessToken) throws SQLException {
CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context);
CriteriaQuery criteriaQuery = getCriteriaQuery(criteriaBuilder, RequestItem.class);
Root<RequestItem> requestItemRoot = criteriaQuery.from(RequestItem.class);
criteriaQuery.select(requestItemRoot);
criteriaQuery.where(criteriaBuilder.equal(requestItemRoot.get(RequestItem_.access_token), accessToken));
return uniqueResult(context, criteriaQuery, true, RequestItem.class);
}
@Override
public Iterator<RequestItem> findByItem(Context context, Item item) throws SQLException {
CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context);

View File

@@ -7,11 +7,14 @@
*/
package org.dspace.app.requestitem.service;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.sql.SQLException;
import java.util.Iterator;
import java.util.List;
import org.dspace.app.requestitem.RequestItem;
import org.dspace.authorize.AuthorizeException;
import org.dspace.content.Bitstream;
import org.dspace.content.DSpaceObject;
import org.dspace.content.Item;
@@ -23,6 +26,7 @@ import org.dspace.core.Context;
* for the RequestItem object and is autowired by Spring.
*
* @author kevinvandevelde at atmire.com
* @author Kim Shepherd
*/
public interface RequestItemService {
@@ -49,20 +53,28 @@ public interface RequestItemService {
*
* @param context current DSpace session.
* @return all item requests.
* @throws java.sql.SQLException passed through.
* @throws SQLException passed through.
*/
public List<RequestItem> findAll(Context context)
throws SQLException;
/**
* Retrieve a request by its token.
* Retrieve a request by its approver token.
*
* @param context current DSpace session.
* @param token the token identifying the request.
* @param token the token identifying the request to be approved.
* @return the matching request, or null if not found.
*/
public RequestItem findByToken(Context context, String token);
/**
* Retrieve a request by its access token, for use by the requester
*
* @param context current DSpace session.
* @param token the token identifying the request to be temporarily accessed
* @return the matching request, or null if not found.
*/
public RequestItem findByAccessToken(Context context, String token);
/**
* Retrieve a request based on the item.
* @param context current DSpace session.
@@ -72,7 +84,10 @@ public interface RequestItemService {
public Iterator<RequestItem> findByItem(Context context, Item item) throws SQLException;
/**
* Save updates to the record. Only accept_request, and decision_date are set-able.
* Save updates to the record. Only accept_request, decision_date, access_period are settable.
*
* Note: the "is settable" rules mentioned here are enforced in RequestItemRest with annotations meaning that
* these JSON properties are considered READ-ONLY by the core DSpaceRestRepository methods
*
* @param context The relevant DSpace Context.
* @param requestItem requested item
@@ -96,4 +111,58 @@ public interface RequestItemService {
*/
public boolean isRestricted(Context context, DSpaceObject o)
throws SQLException;
/**
* Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period,
* either return cleanly or throw an AuthorizeException
*
* @param context the DSpace context
* @param requestItem the request item containing request and approval data
* @param bitstream the bitstream to which access is requested
* @param accessToken the access token supplied by the user (e.g. to REST controller)
* @throws AuthorizeException
*/
public void authorizeAccessByAccessToken(Context context, RequestItem requestItem, Bitstream bitstream,
String accessToken)
throws AuthorizeException;
/**
* Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period,
* either return cleanly or throw an AuthorizeException
*
* @param context the DSpace context
* @param bitstream the bitstream to which access is requested
* @param accessToken the access token supplied by the user (e.g. to REST controller)
* @throws AuthorizeException
*/
public void authorizeAccessByAccessToken(Context context, Bitstream bitstream, String accessToken)
throws AuthorizeException;
/**
* Generate a link back to DSpace, to act on a request.
*
* @param token identifies the request.
* @return URL to the item request API, with the token as request parameter
* "token".
* @throws URISyntaxException passed through.
* @throws MalformedURLException passed through.
*/
String getLinkTokenEmail(String token)
throws URISyntaxException, MalformedURLException;
/**
* Sanitize a RequestItem depending on the current session user. If the current user is not
* the approver, an administrator or other privileged group, the following values in the return object
* are nullified:
* - approver token (aka token)
* - requester name
* - requester email
* - requester message
*
* These properties contain personal information, or can be used to access personal information
* and are not needed except for sending the original request and grant/deny emails
*
* @param requestItem
*/
void sanitizeRequestItem(Context context, RequestItem requestItem);
}