From 6fd37832c3f98fe6c10bacbd4fd8533e725c2496 Mon Sep 17 00:00:00 2001 From: Kim Shepherd Date: Thu, 13 Feb 2025 14:08:12 +0100 Subject: [PATCH] Request-a-copy improvement: Core model, DAO, service changes --- .../requestitem/RequestItemServiceImpl.java | 225 +++++++++++++++++- .../app/requestitem/dao/RequestItemDAO.java | 15 +- .../dao/impl/RequestItemDAOImpl.java | 11 + .../service/RequestItemService.java | 77 +++++- 4 files changed, 321 insertions(+), 7 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java index e23b30ba03..996fdf4b4c 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java @@ -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(); + + } } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java index b36ae58e0c..9a7419bc9c 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java @@ -26,7 +26,7 @@ import org.dspace.core.GenericDAO; */ public interface RequestItemDAO extends GenericDAO { /** - * 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 { */ 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 findByItem(Context context, Item item) throws SQLException; + } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java index c76bd50d19..000559b14d 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java @@ -42,6 +42,17 @@ public class RequestItemDAOImpl extends AbstractHibernateDAO 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 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 findByItem(Context context, Item item) throws SQLException { CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java b/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java index efac3b18bc..010a57e7ca 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java @@ -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 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 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); }