Request-a-copy: Access expiry sent as delta or date, stored as date

This commit is contained in:
Kim Shepherd
2025-03-18 14:30:41 +01:00
parent 16bda9e043
commit 8135ba25a5
12 changed files with 244 additions and 93 deletions

View File

@@ -72,6 +72,12 @@ public class RequestItem implements ReloadableEntity<Integer> {
@Column(name = "accept_request") @Column(name = "accept_request")
private boolean accept_request; private boolean accept_request;
@Column(name = "access_token", unique = true, length = 48)
private String access_token = null;
@Column(name = "access_expiry")
private Instant access_expiry = null;
/** /**
* Protected constructor, create object using: * Protected constructor, create object using:
* {@link org.dspace.app.requestitem.service.RequestItemService#createRequest( * {@link org.dspace.app.requestitem.service.RequestItemService#createRequest(
@@ -85,7 +91,7 @@ public class RequestItem implements ReloadableEntity<Integer> {
return requestitem_id; return requestitem_id;
} }
void setAllfiles(boolean allfiles) { public void setAllfiles(boolean allfiles) {
this.allfiles = allfiles; this.allfiles = allfiles;
} }
@@ -134,7 +140,8 @@ public class RequestItem implements ReloadableEntity<Integer> {
} }
/** /**
* @return a unique request identifier which can be emailed. * @return a unique request identifier which can be emailed to the *approver* of the request.
* This is not the same as the access token, which is used by the requester to access the item after approval.
*/ */
public String getToken() { public String getToken() {
return token; return token;
@@ -187,4 +194,38 @@ public class RequestItem implements ReloadableEntity<Integer> {
void setRequest_date(Instant request_date) { void setRequest_date(Instant request_date) {
this.request_date = request_date; this.request_date = request_date;
} }
/**
* @return A unique token to be used by the requester when granted access to the resource, which
* can be emailed upon approval
*/
public String getAccess_token() {
return access_token;
}
public void setAccess_token(String access_token) {
this.access_token = access_token;
}
/**
* @return The date and time when the access token expires.
*/
public Instant getAccess_expiry() {
return access_expiry;
}
public void setAccess_expiry(Instant access_expiry) {
this.access_expiry = access_expiry;
}
/**
* Sanitize personal information and the approval token, to be used when returning a RequestItem
* to Angular, especially for users clicking on the secure link
*/
public void sanitizePersonalData() {
setReqEmail("sanitized");
setReqName("sanitized");
setReqMessage("sanitized");
// Even though [approval] token is not a name, it can be used to access the original object
setToken("sanitized");
}
} }

View File

@@ -10,7 +10,9 @@ package org.dspace.app.requestitem;
import java.io.IOException; import java.io.IOException;
import java.sql.SQLException; import java.sql.SQLException;
import java.text.DateFormat; import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import jakarta.annotation.ManagedBean; import jakarta.annotation.ManagedBean;
@@ -175,6 +177,12 @@ public class RequestItemEmailNotifier {
grantorAddress = grantor.getEmail(); grantorAddress = grantor.getEmail();
} }
// Set date format for access expiry date
String accessExpiryFormat = configurationService.getProperty("request.item.grant.link.dateformat",
"yyyy-MM-dd");
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(accessExpiryFormat)
.withZone(ZoneId.of("UTC"));
Email email; Email email;
// If this item has a secure access token, send the template with that link instead of attaching files // If this item has a secure access token, send the template with that link instead of attaching files
if (ri.isAccept_request() && ri.getAccess_token() != null) { if (ri.isAccept_request() && ri.getAccess_token() != null) {
@@ -201,11 +209,10 @@ public class RequestItemEmailNotifier {
// {6} secure access link // {6} secure access link
email.addArgument(configurationService.getProperty("dspace.ui.url") email.addArgument(configurationService.getProperty("dspace.ui.url")
+ "/items/" + ri.getItem().getID() + "/items/" + ri.getItem().getID()
+ "/access-by-token?accessToken=" + ri.getAccess_token()); + "?accessToken=" + ri.getAccess_token());
// {7} access end date // {7} access end date, but only add formatted date string if it is set and not "forever"
if (ri.getAccess_period() > 0) { if (ri.getAccess_expiry() != null && !ri.getAccess_expiry().equals(Instant.MAX)) {
DateFormat dateFormat = DateFormat.getDateInstance(); email.addArgument(dateTimeFormatter.format(ri.getAccess_expiry()));
email.addArgument(dateFormat.format(ri.getAccessEndDate()));
} else { } else {
email.addArgument(null); email.addArgument(null);
} }

View File

@@ -11,9 +11,16 @@ import java.net.MalformedURLException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.sql.SQLException; import java.sql.SQLException;
import java.text.ParseException;
import java.time.DateTimeException;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.TimeZone;
import org.apache.http.client.utils.URIBuilder; import org.apache.http.client.utils.URIBuilder;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -32,8 +39,9 @@ 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.dspace.services.ConfigurationService;
import org.dspace.util.DateMathParser;
import org.dspace.util.MultiFormatDateParser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
/** /**
@@ -61,6 +69,11 @@ public class RequestItemServiceImpl implements RequestItemService {
@Autowired @Autowired
protected ConfigurationService configurationService; protected ConfigurationService configurationService;
/**
* Always set UTC for dateMathParser for consistent database date handling
*/
static DateMathParser dateMathParser = new DateMathParser(TimeZone.getTimeZone("UTC"));
private static final int DEFAULT_MINIMUM_FILE_SIZE = 20; private static final int DEFAULT_MINIMUM_FILE_SIZE = 20;
protected RequestItemServiceImpl() { protected RequestItemServiceImpl() {
@@ -145,8 +158,8 @@ public class RequestItemServiceImpl implements RequestItemService {
// Save the request item // Save the request item
requestItemDAO.save(context, requestItem); requestItemDAO.save(context, requestItem);
log.debug("Created RequestItem with ID {}, approval token {}, access token {}, access period {}", log.debug("Created RequestItem with ID {}, approval token {}, access token {}, access expiry {}",
requestItem::getID, requestItem::getToken, requestItem::getAccess_token, requestItem::getAccess_period); requestItem::getID, requestItem::getToken, requestItem::getAccess_token, requestItem::getAccess_expiry);
// Return the approver token // Return the approver token
return requestItem.getToken(); return requestItem.getToken();
@@ -225,6 +238,35 @@ public class RequestItemServiceImpl implements RequestItemService {
} }
} }
/**
* Set the access expiry date for the request item.
* @param requestItem the request item to update
* @param accessExpiry the expiry date to set
*/
@Override
public void setAccessExpiry(RequestItem requestItem, Instant accessExpiry) {
requestItem.setAccess_expiry(accessExpiry);
}
/**
* Take a string either as a formatted date, or in the "math" format expected by
* the DateMathParser, e.g. +7DAYS or +10MONTHS, and set the access expiry date accordingly.
* There are no special checks here to check that the date is in the future, or after the
* 'decision date', as there may be legitimate reasons to set past dates.
* If past dates are not allowed by some interface, then the caller should check this.
*
* @param requestItem the request item to update
* @param dateOrDelta the delta as a string in format expected by the DateMathParser
*/
@Override
public void setAccessExpiry(RequestItem requestItem, String dateOrDelta) {
try {
setAccessExpiry(requestItem, parseDateOrDelta(dateOrDelta, requestItem.getDecision_date()));
} catch (ParseException e) {
log.error("Error parsing access expiry or duration: {}", e.getMessage());
}
}
/** /**
* Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period, * Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period,
* either return cleanly or throw an AuthorizeException * either return cleanly or throw an AuthorizeException
@@ -247,9 +289,8 @@ public class RequestItemServiceImpl implements RequestItemService {
&& (requestItem.getAccess_token() != null && requestItem.getAccess_token().equals(accessToken)) && (requestItem.getAccess_token() != null && requestItem.getAccess_token().equals(accessToken))
// 3. Request is 'allfiles' or for this bitstream ID // 3. Request is 'allfiles' or for this bitstream ID
&& (requestItem.isAllfiles() || bitstream.equals(requestItem.getBitstream())) && (requestItem.isAllfiles() || bitstream.equals(requestItem.getBitstream()))
// 4. access period is 0 (forever), or the elapsed seconds since decision date is less than the // 4. access expiry timestamp is null (forever), or is *after* the current time
// access period granted && (requestItem.getAccess_expiry() == null || requestItem.getAccess_expiry().isAfter(Instant.now()))
&& requestItem.accessPeriodCurrent()
) { ) {
log.info("Authorizing access to bitstream {} by access token", bitstream.getID()); log.info("Authorizing access to bitstream {} by access token", bitstream.getID());
return; return;
@@ -306,8 +347,7 @@ public class RequestItemServiceImpl implements RequestItemService {
} }
/** /**
* Sanitize a RequestItem depending on the current session user. If the current user is not * Sanitize a RequestItem. The following values in the referenced RequestItem
* the approver, an administrator or other privileged group, the following values in the return object
* are nullified: * are nullified:
* - approver token (aka token) * - approver token (aka token)
* - requester name * - requester name
@@ -325,28 +365,39 @@ public class RequestItemServiceImpl implements RequestItemService {
log.error("Null request item passed for sanitization, skipping"); log.error("Null request item passed for sanitization, skipping");
return; 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) // Sanitized referenced data (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(); requestItem.sanitizePersonalData();
}
/**
* Parse a date or delta string into an Instant. Kept here as a static method for use in unit tests
* and other areas that might not have access to the full spring service
*
* @param dateOrDelta
* @param decisionDate
* @return parsed date as instant
* @throws ParseException
*/
public static Instant parseDateOrDelta(String dateOrDelta, Instant decisionDate) throws ParseException, DateTimeException {
// First, if dateOrDelta is a null string or "FOREVER", we will set the expiry
// date to a very distant date in the future.
if (dateOrDelta == null || dateOrDelta.equals("FOREVER")) {
return Instant.MAX;
}
// Next, try parsing as a straight date using the multiple format parser
ZonedDateTime parsedExpiryDate = MultiFormatDateParser.parse(dateOrDelta);
if (parsedExpiryDate == null) {
// That did not work, so try parsing as a delta
// Set the 'now' date to the decision date of the request item
dateMathParser.setNow(LocalDateTime.ofInstant(decisionDate, ZoneOffset.UTC));
// Parse the delta (e.g. +7DAYS) and set the new access expiry date
return dateMathParser.parseMath(dateOrDelta).toInstant(ZoneOffset.UTC);
}
else {
// The expiry date was a valid formatted date string, so set the access expiry date
return parsedExpiryDate.toInstant();
}
} }
} }

View File

@@ -10,6 +10,7 @@ package org.dspace.app.requestitem.service;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Instant;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
@@ -112,6 +113,10 @@ public interface RequestItemService {
public boolean isRestricted(Context context, DSpaceObject o) public boolean isRestricted(Context context, DSpaceObject o)
throws SQLException; throws SQLException;
void setAccessExpiry(RequestItem requestItem, Instant accessExpiry);
void setAccessExpiry(RequestItem requestItem, String delta);
/** /**
* Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period, * Taking into account 'accepted' flag, bitstream id or allfiles flag, decision date and access period,
* either return cleanly or throw an AuthorizeException * either return cleanly or throw an AuthorizeException

View File

@@ -8,6 +8,6 @@
-- Add new access_token column to hold a secure access token for the requestor to use for weblink-based access -- Add new access_token column to hold a secure access token for the requestor to use for weblink-based access
ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_token VARCHAR(48); ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_token VARCHAR(48);
-- Add new access_period column to hold a time delta in seconds (from decision_date timestamp) to calculate validity -- Add new access_expiry DATESTAMP column to hold the expiry date of the access token
-- and expiry, with NULL interpreted as 'forever' (if accept_request is true). int4 allows for 68 year period max. -- (note this is separate from the existing 'expires' column which was intended as the expiry date of the request itself)
ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_period INT4; ALTER TABLE requestitem ADD COLUMN IF NOT EXISTS access_expiry TIMESTAMP;

View File

@@ -21,6 +21,13 @@ import java.net.MalformedURLException;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.sql.Date; import java.sql.Date;
import java.sql.SQLException; import java.sql.SQLException;
import java.text.ParseException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Iterator; import java.util.Iterator;
import org.dspace.AbstractUnitTest; import org.dspace.AbstractUnitTest;
@@ -44,6 +51,7 @@ import org.dspace.handle.factory.HandleServiceFactory;
import org.dspace.handle.service.HandleService; import org.dspace.handle.service.HandleService;
import org.dspace.services.ConfigurationService; import org.dspace.services.ConfigurationService;
import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.services.factory.DSpaceServicesFactory;
import org.dspace.util.MultiFormatDateParser;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.Before; import org.junit.Before;
import org.junit.BeforeClass; import org.junit.BeforeClass;
@@ -162,12 +170,13 @@ public class RequestItemTest extends AbstractUnitTest {
@Test @Test
public void testAuthorizeWithValidPeriod() throws Exception { public void testAuthorizeWithValidPeriod() throws Exception {
Instant decisionDate = getYesterdayAsInstant();
RequestItem request = RequestItemBuilder RequestItem request = RequestItemBuilder
.createRequestItem(context, item, bitstream) .createRequestItem(context, item, bitstream)
.withAcceptRequest(true) .withAcceptRequest(true)
.withAccessToken("test-token") .withAccessToken("test-token")
.withDecisionDate(new Date(System.currentTimeMillis() + 86400000)) // Yesterday .withDecisionDate(decisionDate) // Yesterday
.withAccessPeriod(10 * 86400) // 10 day period .withAccessExpiry(getExpiryAsInstant("+10DAYS", decisionDate)) // 10 day period
.build(); .build();
// The access token should be valid so we expect no exceptions // The access token should be valid so we expect no exceptions
@@ -181,12 +190,13 @@ public class RequestItemTest extends AbstractUnitTest {
@Test(expected = AuthorizeException.class) @Test(expected = AuthorizeException.class)
public void testAuthorizeWithExpiredPeriod() throws Exception { public void testAuthorizeWithExpiredPeriod() throws Exception {
Instant decisionDate = getYesterdayAsInstant();
RequestItem request = RequestItemBuilder RequestItem request = RequestItemBuilder
.createRequestItem(context, item, bitstream) .createRequestItem(context, item, bitstream)
.withAcceptRequest(true) .withAcceptRequest(true)
.withAccessToken("test-token") .withAccessToken("test-token")
.withDecisionDate(new Date(System.currentTimeMillis() - 86400000)) // Yesterday .withDecisionDate(decisionDate) // Yesterday
.withAccessPeriod(86400) // 1 day period .withAccessExpiry(getExpiryAsInstant("+1DAY", decisionDate)) // 1 day period
.build(); .build();
// The access token should not be valid so we expect to catch an AuthorizeException // The access token should not be valid so we expect to catch an AuthorizeException
@@ -207,12 +217,13 @@ public class RequestItemTest extends AbstractUnitTest {
@Test(expected = AuthorizeException.class) @Test(expected = AuthorizeException.class)
public void testAuthorizeWithMismatchedToken() throws Exception { public void testAuthorizeWithMismatchedToken() throws Exception {
Instant decisionDate = getYesterdayAsInstant();
RequestItem request = RequestItemBuilder RequestItem request = RequestItemBuilder
.createRequestItem(context, item, bitstream) .createRequestItem(context, item, bitstream)
.withAcceptRequest(true) .withAcceptRequest(true)
.withAccessToken("test-token") .withAccessToken("test-token")
.withDecisionDate(new Date(System.currentTimeMillis() - 86400000)) // Yesterday .withDecisionDate(decisionDate) // Yesterday
.withAccessPeriod(0) // forever .withAccessExpiry(getExpiryAsInstant("FOREVER", decisionDate)) // forever
.build(); .build();
// The access token should NOT valid so we expect to catch an AuthorizeException // The access token should NOT valid so we expect to catch an AuthorizeException
@@ -262,29 +273,32 @@ public class RequestItemTest extends AbstractUnitTest {
@Test @Test
public void testGrantRequestWithAccessPeriod() throws Exception { public void testGrantRequestWithAccessPeriod() throws Exception {
Instant decisionDate = Instant.now();
Instant expectedExpiryDate = decisionDate.plus(7, ChronoUnit.DAYS);
RequestItem request = RequestItemBuilder RequestItem request = RequestItemBuilder
.createRequestItem(context, item, bitstream) .createRequestItem(context, item, bitstream)
.build(); .build();
request.setAccept_request(true); request.setAccept_request(true);
request.setDecision_date(new Date(System.currentTimeMillis())); request.setDecision_date(decisionDate);
request.setAccess_period(7); // 7 day access request.setAccess_expiry(getExpiryAsInstant("+7DAYS", decisionDate)); // 7 day access
requestItemService.update(context, request); requestItemService.update(context, request);
RequestItem found = requestItemService.findByToken(context, request.getToken()); RequestItem found = requestItemService.findByToken(context, request.getToken());
assertTrue(found.isAccept_request()); assertTrue(found.isAccept_request());
assertNotNull(found.getDecision_date()); assertEquals(decisionDate, found.getDecision_date());
assertEquals(7, found.getAccess_period()); assertEquals(expectedExpiryDate, found.getAccess_expiry());
} }
@Test @Test
public void testDenyRequest() throws Exception { public void testDenyRequest() throws Exception {
Instant decisionDate = Instant.now();
RequestItem request = RequestItemBuilder RequestItem request = RequestItemBuilder
.createRequestItem(context, item, bitstream) .createRequestItem(context, item, bitstream)
.build(); .build();
request.setAccept_request(false); request.setAccept_request(false);
request.setDecision_date(new Date(System.currentTimeMillis())); request.setDecision_date(decisionDate);
requestItemService.update(context, request); requestItemService.update(context, request);
RequestItem found = requestItemService.findByToken(context, request.getToken()); RequestItem found = requestItemService.findByToken(context, request.getToken());
@@ -314,19 +328,22 @@ public class RequestItemTest extends AbstractUnitTest {
@Test @Test
public void testModifyGrantedRequest() throws Exception { public void testModifyGrantedRequest() throws Exception {
Instant decisionDate = Instant.now();
Instant expectedExpiryDate = decisionDate.plus(10, ChronoUnit.DAYS);
RequestItem request = RequestItemBuilder RequestItem request = RequestItemBuilder
.createRequestItem(context, item, bitstream) .createRequestItem(context, item, bitstream)
.withAcceptRequest(true) .withAcceptRequest(true)
.withDecisionDate(new Date(System.currentTimeMillis())) .withDecisionDate(decisionDate)
.withAccessPeriod(30) .withAccessExpiry(getExpiryAsInstant("+1DAY", decisionDate))
.build(); .build();
request.setAccess_period(60); // Manually set new expiry date
request.setAccess_expiry(getExpiryAsInstant("+10DAYS", decisionDate));
request.setAllfiles(true); request.setAllfiles(true);
requestItemService.update(context, request); requestItemService.update(context, request);
RequestItem found = requestItemService.findByToken(context, request.getToken()); RequestItem found = requestItemService.findByToken(context, request.getToken());
assertEquals(60, found.getAccess_period()); assertEquals(expectedExpiryDate, found.getAccess_expiry());
assertTrue(found.isAccept_request()); assertTrue(found.isAccept_request());
assertTrue(found.isAllfiles()); assertTrue(found.isAllfiles());
} }
@@ -359,6 +376,17 @@ public class RequestItemTest extends AbstractUnitTest {
configurationService.reloadConfig(); configurationService.reloadConfig();
} }
private Instant getYesterdayAsInstant() {
return Instant.now().minus(Duration.ofDays(1));
}
private Instant getExpiryAsInstant(String dateOrDelta, Instant decision) {
try {
return RequestItemServiceImpl.parseDateOrDelta(dateOrDelta, decision);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
} }

View File

@@ -40,7 +40,7 @@ public class RequestItemBuilder
private Instant decisionDate; private Instant decisionDate;
private boolean accepted; private boolean accepted;
private String accessToken = null; private String accessToken = null;
private int accessPeriod; private Instant accessExpiry = null;
private boolean allFiles; private boolean allFiles;
protected RequestItemBuilder(Context context) { protected RequestItemBuilder(Context context) {
@@ -95,8 +95,8 @@ public class RequestItemBuilder
return this; return this;
} }
public RequestItemBuilder withAccessPeriod(int accessPeriod) { public RequestItemBuilder withAccessExpiry(Instant accessExpiry) {
this.accessPeriod = accessPeriod; this.accessExpiry = accessExpiry;
return this; return this;
} }
@@ -128,7 +128,7 @@ public class RequestItemBuilder
if (accessToken != null) { if (accessToken != null) {
requestItem.setAccess_token(accessToken); requestItem.setAccess_token(accessToken);
} }
requestItem.setAccess_period(accessPeriod); requestItem.setAccess_expiry(accessExpiry);
requestItem.setAllfiles(allFiles); requestItem.setAllfiles(allFiles);
requestItemService.update(context, requestItem); requestItemService.update(context, requestItem);

View File

@@ -46,7 +46,7 @@ public class RequestItemConverter
requestItemRest.setRequestDate(requestItem.getRequest_date()); requestItemRest.setRequestDate(requestItem.getRequest_date());
requestItemRest.setToken(requestItem.getToken()); requestItemRest.setToken(requestItem.getToken());
requestItemRest.setAccessToken(requestItem.getAccess_token()); requestItemRest.setAccessToken(requestItem.getAccess_token());
requestItemRest.setAccessPeriod(requestItem.getAccess_period()); requestItemRest.setAccessExpiry(requestItem.getAccess_expiry());
return requestItemRest; return requestItemRest;
} }

View File

@@ -56,9 +56,7 @@ public class RequestItemRest extends BaseObjectRest<Integer> {
@JsonProperty(access = JsonProperty.Access.READ_ONLY) @JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String accessToken; protected String accessToken;
protected int accessPeriod; protected Instant accessExpiry;
protected String decisionNote;
/** /**
* @return the bitstream requested. * @return the bitstream requested.
@@ -229,17 +227,17 @@ public class RequestItemRest extends BaseObjectRest<Integer> {
} }
/** /**
* @return access period (in seconds) which indicates how long this access is valid from the decision date * @return the date the access token expires.
*/ */
public int getAccessPeriod() { public Instant getAccessExpiry() {
return accessPeriod; return this.accessExpiry;
} }
/** /**
* @param accessPeriod access period, in seconds, indicating how long this access is valid from the decision date * @param accessExpiry the date the access token expires.
*/ */
public void setAccessPeriod(int accessPeriod) { public void setAccessExpiry(Instant accessExpiry) {
this.accessPeriod = accessPeriod; this.accessExpiry = accessExpiry;
} }
/* /*

View File

@@ -14,14 +14,19 @@ import java.io.IOException;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.sql.SQLException; import java.sql.SQLException;
import java.text.ParseException;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.TimeZone;
import java.util.UUID; import java.util.UUID;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import net.bytebuddy.asm.Advice;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.validator.routines.EmailValidator; import org.apache.commons.validator.routines.EmailValidator;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -49,6 +54,10 @@ import org.dspace.eperson.InvalidReCaptchaException;
import org.dspace.eperson.factory.CaptchaServiceFactory; import org.dspace.eperson.factory.CaptchaServiceFactory;
import org.dspace.eperson.service.CaptchaService; import org.dspace.eperson.service.CaptchaService;
import org.dspace.services.ConfigurationService; import org.dspace.services.ConfigurationService;
import org.dspace.util.DateMathParser;
import static org.dspace.util.MultiFormatDateParser.parse;
import org.dspace.util.MultiFormatDateParser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -291,15 +300,13 @@ public class RequestItemRepository
// Set the decision date (now)` // Set the decision date (now)`
ri.setDecision_date(Instant.now()); ri.setDecision_date(Instant.now());
// If the (optional) access period was included, extract it here and set accordingly // If the (optional) access expiry period was included, extract it here and set accordingly
JsonNode accessPeriodNode = requestBody.findValue("accessPeriod"); // We expect it to be sent either as a timestamp or as a delta math like +7DAYS
int accessPeriod = 0; JsonNode accessPeriod = requestBody.findValue("accessPeriod");
if (accessPeriodNode != null && !accessPeriodNode.isNull()) { if (accessPeriod != null && !accessPeriod.isNull()) {
accessPeriod = accessPeriodNode.asInt(0); // The request item service is responsible for parsing and setting the expiry date based
} // on a delta like "+7DAYS" or special string like "FOREVER", or a formatted date
// If a valid access period was set, update the request, otherwise we will leave it as null requestItemService.setAccessExpiry(ri, accessPeriod.asText());
if (accessPeriod > 0) {
ri.setAccess_period(accessPeriod);
} }
JsonNode responseSubjectNode = requestBody.findValue("subject"); JsonNode responseSubjectNode = requestBody.findValue("subject");
@@ -307,7 +314,6 @@ public class RequestItemRepository
if (responseSubjectNode != null && !responseSubjectNode.isNull()) { if (responseSubjectNode != null && !responseSubjectNode.isNull()) {
subject = responseSubjectNode.asText(); subject = responseSubjectNode.asText();
} }
ri.setDecision_date(Instant.now());
requestItemService.update(context, ri); requestItemService.update(context, ri);
// Send the response email // Send the response email
@@ -356,8 +362,10 @@ public class RequestItemRepository
throw new ResourceNotFoundException("No such request item for accessToken=" + accessToken); throw new ResourceNotFoundException("No such request item for accessToken=" + accessToken);
} }
// Send 403 FORBIDDEN if request access has not been granted or access period is in the past // Send 403 FORBIDDEN if request access has not been granted or access period is null or in the past
if (!requestItem.isAccept_request() || !requestItem.accessPeriodCurrent()) { if (!requestItem.isAccept_request() ||
requestItem.getAccess_expiry() == null ||
requestItem.getAccess_expiry().isBefore(Instant.now())) {
throw new AccessDeniedException("Access has not been granted for this item request"); throw new AccessDeniedException("Access has not been granted for this item request");
} }

View File

@@ -34,6 +34,7 @@ import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;

View File

@@ -7,37 +7,49 @@
# logged - Login is mandatory to request an item # logged - Login is mandatory to request an item
# empty/commented out - request-copy not allowed # empty/commented out - request-copy not allowed
#request.item.type = all #request.item.type = all
# Should all Request Copy emails go to the helpdesk instead of the item submitter? # Should all Request Copy emails go to the helpdesk instead of the item submitter?
#request.item.helpdesk.override = false #request.item.helpdesk.override = false
# Should a rejection of a copy request send an email back to the requester? # Should a rejection of a copy request send an email back to the requester?
# Defaults to "true", which means a rejection email is sent back. # Defaults to "true", which means a rejection email is sent back.
# Setting it to "false" results in a silent rejection. # Setting it to "false" results in a silent rejection.
#request.item.reject.email = true #request.item.reject.email = true
# Bundles to use when granting access and inspecting file size, if "all files" is indicated
# Default: ORIGINAL ## Bundles to use when granting access and inspecting file size, if "all files" is indicated
#request.item.grant.bundles = ORIGINAL ## Default: ORIGINAL
# By default a granted request will send the file as an attachment. request.item.grant.bundles = ORIGINAL
## By default a granted request will send the file as an attachment.
# If a secure web link to the bitstream(s) should be sent in some cases, enable the following property # If a secure web link to the bitstream(s) should be sent in some cases, enable the following property
# Default: false # Default: false
#request.item.grant.link = true #request.item.grant.link = true
# If request.item.grant.link is enabled, you can specify a minimum file size (in megabytes) to use as # If request.item.grant.link is enabled, you can specify a minimum file size (in megabytes) to use as
# a threshold, or else the default attachment functionality will be used instead. # a threshold, or else the default attachment functionality will be used instead.
# If 'all files' was indicated in the request, this threshold will be activated if any of the files # If 'all files' was indicated in the request, this threshold will be activated if any of the files
# meet the configured size minimum. # meet the configured size minimum.
# To send links instead of attachments for all files, set this property to 0. # To send links instead of attachments for all files, set this property to 0.
# Default: 20 # Default: 20
#request.item.grant.link.filesize = 0 request.item.grant.link.filesize = 0
# Valid access periods, in seconds, to allow the approver to select when using links. # Valid access periods, in seconds, to allow the approver to select when using links.
# These are presented to users with friendly labels via i18n keys in the angular frontend. # These are presented to users with friendly labels via i18n keys in the angular frontend.
# A 0 (or null) access period will be interpreted as 'forever'. # These should be in a format like +<n><unit>, where <n> is the number of units and <unit> is one of
# "SECONDS", "MINUTES", "HOURS", "DAYS", "WEEKS", "MONTHS", "YEARS". (or singular if n=1)
# The default is FOREVER, which will set a very distant expiry date for permanent access.
# The first access period in the list will be the default option in the grant form dropdown. # The first access period in the list will be the default option in the grant form dropdown.
# 1 day = 86400 #
# 7 days = 604800 request.item.grant.link.period = FOREVER
#request.item.grant.link.period = 0 request.item.grant.link.period = +1DAY
#request.item.grant.link.period = 120 request.item.grant.link.period = +1WEEK
#request.item.grant.link.period = 240 request.item.grant.link.period = +1MONTH
#request.item.grant.link.period = 86400 request.item.grant.link.period = +3MONTHS
#request.item.grant.link.period = 604800
# Date format to use for the access expiry date in the grant email
request.item.grant.link.dateformat = dd/MM/yyyy HH:mm:ss
#request.item.grant.link.dateformat = MM/dd/yy
# Require a captcha for item request creation # Require a captcha for item request creation
# Default: false # Default: false
#request.item.create.captcha=true #request.item.create.captcha=true