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; package org.dspace.app.requestitem;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Instant; import java.time.Instant;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import org.apache.http.client.utils.URIBuilder;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.dspace.app.requestitem.dao.RequestItemDAO; import org.dspace.app.requestitem.dao.RequestItemDAO;
import org.dspace.app.requestitem.service.RequestItemService; import org.dspace.app.requestitem.service.RequestItemService;
import org.dspace.authorize.AuthorizeException;
import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.ResourcePolicy;
import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.AuthorizeService;
import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.authorize.service.ResourcePolicyService;
import org.dspace.content.Bitstream; import org.dspace.content.Bitstream;
import org.dspace.content.Bundle;
import org.dspace.content.DSpaceObject; import org.dspace.content.DSpaceObject;
import org.dspace.content.Item; import org.dspace.content.Item;
import org.dspace.core.Constants; import org.dspace.core.Constants;
import org.dspace.core.Context; import org.dspace.core.Context;
import org.dspace.core.LogHelper; import org.dspace.core.LogHelper;
import org.dspace.core.Utils; import org.dspace.core.Utils;
import org.dspace.eperson.EPerson;
import org.dspace.services.ConfigurationService;
import org.springframework.beans.factory.annotation.Autowired; 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. * This class should never be accessed directly.
* *
* @author kevinvandevelde at atmire.com * @author kevinvandevelde at atmire.com
* @author Kim Shepherd
*/ */
public class RequestItemServiceImpl implements RequestItemService { public class RequestItemServiceImpl implements RequestItemService {
@@ -49,16 +58,38 @@ public class RequestItemServiceImpl implements RequestItemService {
@Autowired(required = true) @Autowired(required = true)
protected ResourcePolicyService resourcePolicyService; protected ResourcePolicyService resourcePolicyService;
@Autowired
protected ConfigurationService configurationService;
private static final int DEFAULT_MINIMUM_FILE_SIZE = 20;
protected RequestItemServiceImpl() { 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 @Override
public String createRequest(Context context, Bitstream bitstream, Item item, public String createRequest(Context context, Bitstream bitstream, Item item,
boolean allFiles, String reqEmail, String reqName, String reqMessage) boolean allFiles, String reqEmail, String reqName, String reqMessage)
throws SQLException { throws SQLException {
// Create an empty request item
RequestItem requestItem = requestItemDAO.create(context, new RequestItem()); RequestItem requestItem = requestItemDAO.create(context, new RequestItem());
// Set values of the request item based on supplied parameters
requestItem.setToken(Utils.generateHexKey()); requestItem.setToken(Utils.generateHexKey());
requestItem.setBitstream(bitstream); requestItem.setBitstream(bitstream);
requestItem.setItem(item); requestItem.setItem(item);
@@ -68,10 +99,56 @@ public class RequestItemServiceImpl implements RequestItemService {
requestItem.setReqMessage(reqMessage); requestItem.setReqMessage(reqMessage);
requestItem.setRequest_date(Instant.now()); 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); requestItemDAO.save(context, requestItem);
log.debug("Created RequestItem with ID {} and token {}", log.debug("Created RequestItem with ID {}, approval token {}, access token {}, access period {}",
requestItem::getID, requestItem::getToken); requestItem::getID, requestItem::getToken, requestItem::getAccess_token, requestItem::getAccess_period);
// Return the approver token
return requestItem.getToken(); return requestItem.getToken();
} }
@@ -128,4 +205,148 @@ public class RequestItemServiceImpl implements RequestItemService {
} }
return true; 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> { 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 context the current DSpace context.
* @param token uniquely identifies the request. * @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; 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; 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)); criteriaQuery.where(criteriaBuilder.equal(requestItemRoot.get(RequestItem_.token), token));
return uniqueResult(context, criteriaQuery, false, RequestItem.class); 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 @Override
public Iterator<RequestItem> findByItem(Context context, Item item) throws SQLException { public Iterator<RequestItem> findByItem(Context context, Item item) throws SQLException {
CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context);

View File

@@ -7,11 +7,14 @@
*/ */
package org.dspace.app.requestitem.service; package org.dspace.app.requestitem.service;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import org.dspace.app.requestitem.RequestItem; import org.dspace.app.requestitem.RequestItem;
import org.dspace.authorize.AuthorizeException;
import org.dspace.content.Bitstream; import org.dspace.content.Bitstream;
import org.dspace.content.DSpaceObject; import org.dspace.content.DSpaceObject;
import org.dspace.content.Item; import org.dspace.content.Item;
@@ -23,6 +26,7 @@ import org.dspace.core.Context;
* for the RequestItem object and is autowired by Spring. * for the RequestItem object and is autowired by Spring.
* *
* @author kevinvandevelde at atmire.com * @author kevinvandevelde at atmire.com
* @author Kim Shepherd
*/ */
public interface RequestItemService { public interface RequestItemService {
@@ -49,20 +53,28 @@ public interface RequestItemService {
* *
* @param context current DSpace session. * @param context current DSpace session.
* @return all item requests. * @return all item requests.
* @throws java.sql.SQLException passed through. * @throws SQLException passed through.
*/ */
public List<RequestItem> findAll(Context context) public List<RequestItem> findAll(Context context)
throws SQLException; throws SQLException;
/** /**
* Retrieve a request by its token. * Retrieve a request by its approver token.
* *
* @param context current DSpace session. * @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. * @return the matching request, or null if not found.
*/ */
public RequestItem findByToken(Context context, String token); 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. * Retrieve a request based on the item.
* @param context current DSpace session. * @param context current DSpace session.
@@ -72,7 +84,10 @@ public interface RequestItemService {
public Iterator<RequestItem> findByItem(Context context, Item item) throws SQLException; 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 context The relevant DSpace Context.
* @param requestItem requested item * @param requestItem requested item
@@ -96,4 +111,58 @@ public interface RequestItemService {
*/ */
public boolean isRestricted(Context context, DSpaceObject o) public boolean isRestricted(Context context, DSpaceObject o)
throws SQLException; 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);
} }