Skip to content

Commit

Permalink
Bxc 4313 single use links api (#1654)
Browse files Browse the repository at this point in the history
* BXC-4313 starting API and getting tests to pass

* BXC-4313 starting on download endpoint

* BXC-4313 progress on download endpoint

* BXC-4313 update download endpoint to use streamData method

* BXC-4313 download endpoint test works

* BXC-4313 cleanup

* BXC-4313 fix datastream controller tests

* BXC-4313 more cleanup

* BXC-4360 address comments in PR

* BXC-4360 fix exception message

* BXC-4360 sync front and and back end

* BXC-4360 fix js test

* BXC-4360 update front end after suggestions

---------

Co-authored-by: Sharon Luong <[email protected]>
  • Loading branch information
sharonluong and Sharon Luong authored Jan 19, 2024
1 parent b048ac5 commit a342d34
Show file tree
Hide file tree
Showing 12 changed files with 441 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
<ul>
<li v-for="single_use_link in single_use_links">
<div class="download-link-wrapper">
<div>{{ $t('full_record.created_link', { link: single_use_link.link, expire_time: single_use_link.expires }) }}</div>
<a @click.prevent="copyUrl(single_use_link.link)" href="#" class="download button action">Copy</a>
<div>{{ $t('full_record.created_link', { link: single_use_link.accessCode, expire_time: single_use_link.expires }) }}</div>
<a @click.prevent="copyUrl(single_use_link.link)" href="#" class="download button action">Copy Link</a>
</div>
</li>
</ul>
Expand All @@ -19,6 +19,8 @@

<script>
import axios from 'axios';
import {formatDistanceToNow} from "date-fns";
import {toDate} from "date-fns";
export default {
name: 'singleUseLink',
Expand All @@ -40,7 +42,12 @@ export default {
method: 'post',
url: `/services/api/single_use_link/create/${this.uuid}`
}).then((response) => {
this.single_use_links.push(response.data)
let basePath = window.location.hostname;
let accessCode = response.data.key;
this.single_use_links.push({"link": this.generateUrl(basePath, accessCode),
"accessCode": accessCode.substring(0, 8),
"expires": this.formatTimestamp(response.data.expires)
});
}).catch((error) => {
console.log(error);
this.message = this.$t('full_record.created_link_failed', { uuid: this.uuid});
Expand All @@ -60,6 +67,14 @@ export default {
fadeOutMsg() {
setTimeout(() => this.message = '', 3000);
},
formatTimestamp(timestamp) {
return formatDistanceToNow(toDate(parseInt(timestamp)));
},
generateUrl(basePath, accessCode) {
return "https://" + basePath + "/services/api/single_use_link/" + accessCode;
}
}
}
Expand Down Expand Up @@ -97,6 +112,8 @@ export default {
.single-use-msg-text {
display: none;
word-break: break-word;
padding: 5px;
}
.display-msg {
Expand All @@ -110,7 +127,7 @@ export default {
right: 10px;
text-align: center;
top: 10px;
width: 250px;
width: auto;
z-index: 599;
}
}
Expand Down
5 changes: 3 additions & 2 deletions static/js/vue-cdr-access/tests/unit/singleUseLink.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import translations from '@/translations';
import moxios from 'moxios';

const uuid = '9f7f3746-0237-4261-96a2-4b4765d4ae03';
const response_date = { link: `https://test.edu`, expires: '24hrs', id: uuid };
const oneDay = 86400000;
const response_date = { key: `12345`, expires: Date.now() + oneDay, id: uuid };
let wrapper;

describe('singleUseLink.vue', () => {
Expand Down Expand Up @@ -44,7 +45,7 @@ describe('singleUseLink.vue', () => {
await wrapper.find('#single-use-link').trigger('click');
expect(wrapper.find('.download-link-wrapper').exists()).toBe(true);
expect(wrapper.find('.download-link-wrapper div').text())
.toEqual(`Created link ${response_date.link} expires in ${response_date.expires}`);
.toEqual(`Created link ${response_date.key} expires in 1 day`);
expect(wrapper.find('.download-link-wrapper a').exists()).toBe(true); // Copy button
done();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import edu.unc.lib.boxc.auth.api.exceptions.AccessRestrictionException;
import edu.unc.lib.boxc.auth.api.models.AccessGroupSet;
import edu.unc.lib.boxc.auth.api.services.AccessControlService;
import edu.unc.lib.boxc.model.api.exceptions.InvalidPidException;
import edu.unc.lib.boxc.model.api.exceptions.NotFoundException;
import edu.unc.lib.boxc.model.api.exceptions.ObjectTypeMismatchException;
Expand Down Expand Up @@ -33,6 +34,7 @@
import java.io.IOException;
import java.util.concurrent.TimeoutException;

import static edu.unc.lib.boxc.auth.api.services.DatastreamPermissionUtil.getPermissionForDatastream;
import static edu.unc.lib.boxc.auth.fcrepo.services.GroupsThreadStore.getAgentPrincipals;
import static edu.unc.lib.boxc.model.api.DatastreamType.ORIGINAL_FILE;

Expand All @@ -52,6 +54,8 @@ public class FedoraContentController {
private AnalyticsTrackerUtil analyticsTracker;
@Autowired
private RepositoryObjectLoader repoObjLoader;
@Autowired
private AccessControlService accessControlService;

@RequestMapping(value = {"/content/{pid}", "/indexablecontent/{pid}"})
public void getDefaultDatastream(@PathVariable("pid") String pid,
Expand Down Expand Up @@ -89,8 +93,11 @@ private void streamData(String pidString, String datastream, boolean asAttachmen
}
}

accessControlService.assertHasAccess("Insufficient permissions to access " + datastream + " for object " + pid,
pid, principals, getPermissionForDatastream(datastream));

try {
fedoraContentService.streamData(pid, datastream, principals, asAttachment, response);
fedoraContentService.streamData(pid, datastream, asAttachment, response);
recordDownloadEvent(pid, datastream, principals, request);
} catch (IOException e) {
handleIOException(pid, datastream, e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public class FedoraContentService {
* @param response response content and headers will be added to.
* @throws IOException if unable to stream content to the response.
*/
public void streamData(PID pid, String dsName, AccessGroupSet principals, boolean asAttachment,
public void streamData(PID pid, String dsName, boolean asAttachment,
HttpServletResponse response) throws IOException {
// Default datastream is DATA_FILE
String datastream = dsName == null ? ORIGINAL_FILE.getId() : dsName;
Expand All @@ -75,12 +75,8 @@ public void streamData(PID pid, String dsName, AccessGroupSet principals, boolea
throw new IllegalArgumentException("Cannot stream external datastream " + datastream);
}

accessControlService.assertHasAccess("Insufficient permissions to access " + datastream + " for object " + pid,
pid, principals, getPermissionForDatastream(datastream));

LOG.debug("Streaming datastream {} from object {}", datastream, pid);


BinaryObject binObj;
if (ORIGINAL_FILE.getId().equals(datastream)) {
FileObject fileObj = repositoryObjectLoader.getFileObject(pid);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;

Expand All @@ -23,6 +25,7 @@ public class SingleUseKeyService {
public static final String TIMESTAMP = "Expiration Timestamp";
public static final String[] CSV_HEADERS = new String[] {ID, ACCESS_KEY, TIMESTAMP};
public static final long DAY_MILLISECONDS = 86400000;
public static final String KEY = "key";
private Path csvPath;
private ReentrantLock lock = new ReentrantLock();

Expand All @@ -31,7 +34,7 @@ public class SingleUseKeyService {
* @param id UUID of the record
* @return generated access key
*/
public String generate(String id) {
public Map<String, String> generate(String id) {
var key = getKey();
lock.lock();
var expirationInMilliseconds = System.currentTimeMillis() + DAY_MILLISECONDS;
Expand All @@ -42,7 +45,7 @@ public String generate(String id) {
} finally {
lock.unlock();
}
return key;
return keyToMap(key, id, expirationInMilliseconds);
}

/**
Expand Down Expand Up @@ -102,6 +105,25 @@ public static String getKey() {
return UUID.randomUUID().toString().replace("-", "") + Long.toHexString(System.nanoTime());
}

public String getId(String key) throws IOException {
var csvRecords = parseCsv(CSV_HEADERS, csvPath);
for (CSVRecord record : csvRecords) {
if (key.equals(record.get(ACCESS_KEY))) {
return record.get(ID);
}
}
return null;
}

private Map<String, String> keyToMap(String key, String id, long expirationTimestamp) {
Map<String, String> result = new HashMap<>();
result.put(KEY, key);
result.put("target_id", id);
result.put("expires", String.valueOf(expirationTimestamp));

return result;
}

public void setCsvPath(Path csvPath) {
this.csvPath = csvPath;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import edu.unc.lib.boxc.auth.api.exceptions.AccessRestrictionException;
import edu.unc.lib.boxc.auth.api.models.AccessGroupSet;
import edu.unc.lib.boxc.auth.api.services.AccessControlService;
import edu.unc.lib.boxc.model.api.DatastreamType;
import edu.unc.lib.boxc.model.api.ResourceType;
import edu.unc.lib.boxc.model.api.exceptions.NotFoundException;
Expand Down Expand Up @@ -35,6 +36,7 @@
import java.util.Arrays;
import java.util.List;

import static edu.unc.lib.boxc.auth.api.services.DatastreamPermissionUtil.getPermissionForDatastream;
import static edu.unc.lib.boxc.auth.fcrepo.services.GroupsThreadStore.getAgentPrincipals;
import static edu.unc.lib.boxc.model.api.DatastreamType.ORIGINAL_FILE;
import static edu.unc.lib.boxc.model.fcrepo.services.DerivativeService.isDerivative;
Expand All @@ -60,6 +62,8 @@ public class DatastreamController {
private SolrQueryLayerService solrQueryLayerService;
@Autowired
private AccessCopiesService accessCopiesService;
@Autowired
private AccessControlService accessControlService;

private static final List<String> THUMB_QUERY_FIELDS = Arrays.asList(
SearchFieldKey.ID.name(), SearchFieldKey.DATASTREAM.name(),
Expand All @@ -71,7 +75,7 @@ public void getDatastream(@PathVariable("pid") String pidString,
@RequestParam(value = "dl", defaultValue = "false") boolean download,
HttpServletRequest request,
HttpServletResponse response) {
getDatastream(pidString, null, download, request, response);
getDatastream(pidString, ORIGINAL_FILE.getId(), download, request, response);
}

@RequestMapping("/file/{pid}/{datastream}")
Expand All @@ -90,8 +94,10 @@ public void getDatastream(@PathVariable("pid") String pidString,
} else if (DatastreamType.MD_EVENTS.getId().equals(datastream)) {
fedoraContentService.streamEventLog(pid, principals, download, response);
} else {
fedoraContentService.streamData(pid, datastream, principals, download, response);
if (datastream == null || DatastreamType.ORIGINAL_FILE.getId().equals(datastream)) {
accessControlService.assertHasAccess("Insufficient permissions to access " + datastream + " for object " + pid,
pid, principals, getPermissionForDatastream(datastream));
fedoraContentService.streamData(pid, datastream, download, response);
if (DatastreamType.ORIGINAL_FILE.getId().equals(datastream)) {
recordDownloadEvent(pid, datastream, principals, request);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package edu.unc.lib.boxc.web.services.rest;

import edu.unc.lib.boxc.auth.api.Permission;
import edu.unc.lib.boxc.auth.api.models.AccessGroupSet;
import edu.unc.lib.boxc.auth.api.services.AccessControlService;
import edu.unc.lib.boxc.model.api.exceptions.InvalidOperationForObjectType;
import edu.unc.lib.boxc.model.api.exceptions.NotFoundException;
import edu.unc.lib.boxc.model.api.exceptions.RepositoryException;
import edu.unc.lib.boxc.model.api.ids.PID;
import edu.unc.lib.boxc.model.api.objects.ContentObject;
import edu.unc.lib.boxc.model.api.objects.FileObject;
import edu.unc.lib.boxc.model.api.objects.RepositoryObjectLoader;
import edu.unc.lib.boxc.model.fcrepo.ids.PIDs;
import edu.unc.lib.boxc.web.common.services.FedoraContentService;
import edu.unc.lib.boxc.web.common.utils.AnalyticsTrackerUtil;
import edu.unc.lib.boxc.web.services.processing.SingleUseKeyService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;

import static edu.unc.lib.boxc.auth.fcrepo.services.GroupsThreadStore.getAgentPrincipals;
import static edu.unc.lib.boxc.model.api.DatastreamType.ORIGINAL_FILE;

/**
* Controller for generating and utilizing single use links for a specific UUID
*
* @author snluong
*/
@Controller
public class SingleUseKeyController {
private static final Logger log = LoggerFactory.getLogger(SingleUseKeyController.class);
@Autowired
private AccessControlService aclService;
@Autowired
private SingleUseKeyService singleUseKeyService;
@Autowired
private RepositoryObjectLoader repositoryObjectLoader;
@Autowired
private FedoraContentService fedoraContentService;
@Autowired
private AnalyticsTrackerUtil analyticsTracker;

@RequestMapping(value = "/single_use_link/create/{id}", method = RequestMethod.POST)
public ResponseEntity<Object> generate(@PathVariable("id") String id) {
var pid = PIDs.get(id);
// requester must have the right permission
var agent= getAgentPrincipals();
aclService.assertHasAccess("Insufficient permissions to generate single use link for " + id,
pid, agent.getPrincipals(), Permission.viewHidden);

// check if object is a FileObject
ContentObject obj = (ContentObject) repositoryObjectLoader.getRepositoryObject(pid);
if (!(obj instanceof FileObject)) {
throw new InvalidOperationForObjectType("Single use link cannot be generated for " +
obj.getClass().getName() + " objects.");
}

var keyInfo = singleUseKeyService.generate(id);
log.info("Single use link created for UUID {} by user {}", id, agent.getUsername());
return new ResponseEntity<>(keyInfo, HttpStatus.OK);
}

@RequestMapping(value = "/single_use_link/{key}", method = RequestMethod.GET)
public void download(@PathVariable("key") String accessKey, HttpServletRequest request,
HttpServletResponse response) {
try {
if (singleUseKeyService.keyIsValid(accessKey)) {
var id = singleUseKeyService.getId(accessKey);
var pid = PIDs.get(id);
var datastream = ORIGINAL_FILE.getId();
var principals = getAgentPrincipals().getPrincipals();

singleUseKeyService.invalidate(accessKey);
fedoraContentService.streamData(pid, datastream, true, response);
log.info("Single use link used. Access Key: {}, UUID: {}", accessKey, id);
analyticsTracker.trackEvent(request, "download", pid, principals);
} else {
throw new NotFoundException("Single use key is not valid: " + accessKey);
}
} catch (IOException e) {
log.error("Download single use link did not work:", e);
throw new RepositoryException("Failed to download file using single use access key: " + accessKey);
}
}
}
4 changes: 4 additions & 0 deletions web-services-app/src/main/webapp/WEB-INF/service-context.xml
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,10 @@
<bean id="memberOrderCsvTransformer" class="edu.unc.lib.boxc.web.services.processing.MemberOrderCsvTransformer">
</bean>

<bean id="singleUseKeyService" class="edu.unc.lib.boxc.web.services.processing.SingleUseKeyService">
<property name="csvPath" value="${singleUseLink.csv.path}"/>
</bean>

<bean id="importMemberOrderJmsTemplate" class="org.springframework.jms.core.JmsTemplate">
<property name="connectionFactory" ref="jmsFactory" />
<property name="defaultDestinationName" value="${cdr.ordermembers.stream}" />
Expand Down
Loading

0 comments on commit a342d34

Please sign in to comment.