Skip to content

Commit bb18278

Browse files
authored
Merge pull request #3984 from the-library-code/request-a-copy-secure-links_main
Request-a-copy improvements: Support access by secure link
2 parents 11e02c9 + 87624c7 commit bb18278

File tree

54 files changed

+1376
-111
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1376
-111
lines changed

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"@ngrx/store": "^18.1.1",
115115
"@ngx-translate/core": "^16.0.3",
116116
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
117+
"altcha": "^0.9.0",
117118
"angulartics2": "^12.2.0",
118119
"axios": "^1.7.9",
119120
"bootstrap": "^5.3",

src/app/app-routing-paths.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,41 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st
3535
};
3636
}
3737

38+
/**
39+
* Get a bitstream download route with an access token (to provide direct access to a user) added as a query parameter
40+
* @param bitstream the bitstream to download
41+
* @param accessToken the access token, which should match an access_token in the requestitem table
42+
*/
43+
export function getBitstreamDownloadWithAccessTokenRoute(bitstream, accessToken): { routerLink: string, queryParams: any } {
44+
const url = new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
45+
const options = {
46+
routerLink: url,
47+
queryParams: {},
48+
};
49+
// Only add the access token if it is not empty, otherwise keep valid empty query parameters
50+
if (hasValue(accessToken)) {
51+
options.queryParams = { accessToken: accessToken };
52+
}
53+
return options;
54+
}
55+
/**
56+
* Get an access token request route for a user to access approved bitstreams using a supplied access token
57+
* @param item_uuid item UUID
58+
* @param accessToken access token (generated by backend)
59+
*/
60+
export function getAccessTokenRequestRoute(item_uuid, accessToken): { routerLink: string, queryParams: any } {
61+
const url = new URLCombiner(getItemModuleRoute(), item_uuid, getAccessByTokenModulePath()).toString();
62+
const options = {
63+
routerLink: url,
64+
queryParams: {
65+
accessToken: (hasValue(accessToken) ? accessToken : undefined),
66+
},
67+
};
68+
return options;
69+
}
70+
71+
export const COAR_NOTIFY_SUPPORT = 'coar-notify-support';
72+
3873
export const HOME_PAGE_PATH = 'home';
3974

4075
export function getHomePageRoute() {
@@ -128,6 +163,11 @@ export function getRequestCopyModulePath() {
128163
return `/${REQUEST_COPY_MODULE_PATH}`;
129164
}
130165

166+
export const ACCESS_BY_TOKEN_MODULE_PATH = 'access-by-token';
167+
export function getAccessByTokenModulePath() {
168+
return `/${ACCESS_BY_TOKEN_MODULE_PATH}`;
169+
}
170+
131171
export const HEALTH_PAGE_PATH = 'health';
132172

133173
export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions';

src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,16 @@ describe('BitstreamDownloadPageComponent', () => {
7373
self: { href: 'bitstream-self-link' },
7474
},
7575
});
76-
7776
activatedRoute = {
7877
data: observableOf({
79-
bitstream: createSuccessfulRemoteDataObject(
80-
bitstream,
81-
),
78+
bitstream: createSuccessfulRemoteDataObject(bitstream),
8279
}),
8380
params: observableOf({
8481
id: 'testid',
8582
}),
83+
queryParams: observableOf({
84+
accessToken: undefined,
85+
}),
8686
};
8787

8888
router = jasmine.createSpyObj('router', ['navigateByUrl']);

src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@angular/core';
1212
import {
1313
ActivatedRoute,
14+
Params,
1415
Router,
1516
} from '@angular/router';
1617
import { TranslateModule } from '@ngx-translate/core';
@@ -83,6 +84,10 @@ export class BitstreamDownloadPageComponent implements OnInit {
8384
}
8485

8586
ngOnInit(): void {
87+
const accessToken$: Observable<string> = this.route.queryParams.pipe(
88+
map((queryParams: Params) => queryParams?.accessToken || null),
89+
take(1),
90+
);
8691

8792
this.bitstreamRD$ = this.route.data.pipe(
8893
map((data) => data.bitstream));
@@ -96,32 +101,40 @@ export class BitstreamDownloadPageComponent implements OnInit {
96101
switchMap((bitstream: Bitstream) => {
97102
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
98103
const isLoggedIn$ = this.auth.isAuthenticated();
99-
return observableCombineLatest([isAuthorized$, isLoggedIn$, observableOf(bitstream)]);
104+
return observableCombineLatest([isAuthorized$, isLoggedIn$, accessToken$, observableOf(bitstream)]);
100105
}),
101-
filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)),
106+
filter(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)),
102107
take(1),
103-
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => {
108+
switchMap(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => {
104109
if (isAuthorized && isLoggedIn) {
105110
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
106111
filter((fileLink) => hasValue(fileLink)),
107112
take(1),
108113
map((fileLink) => {
109114
return [isAuthorized, isLoggedIn, bitstream, fileLink];
110115
}));
116+
} else if (hasValue(accessToken)) {
117+
return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]];
111118
} else {
112119
return [[isAuthorized, isLoggedIn, bitstream, '']];
113120
}
114121
}),
115-
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => {
122+
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => {
116123
if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
117124
this.hardRedirectService.redirect(fileLink);
118-
} else if (isAuthorized && !isLoggedIn) {
125+
} else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) {
119126
this.hardRedirectService.redirect(bitstream._links.content.href);
120-
} else if (!isAuthorized && isLoggedIn) {
121-
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
122-
} else if (!isAuthorized && !isLoggedIn) {
123-
this.auth.setRedirectUrl(this.router.url);
124-
this.router.navigateByUrl('login');
127+
} else if (!isAuthorized) {
128+
// Either we have an access token, or we are logged in, or we are not logged in.
129+
// For now, the access token does not care if we are logged in or not.
130+
if (hasValue(accessToken)) {
131+
this.hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken);
132+
} else if (isLoggedIn) {
133+
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
134+
} else if (!isLoggedIn) {
135+
this.auth.setRedirectUrl(this.router.url);
136+
this.router.navigateByUrl('login');
137+
}
125138
}
126139
});
127140
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { inject } from '@angular/core';
2+
import {
3+
ResolveFn,
4+
Router,
5+
} from '@angular/router';
6+
import { Observable } from 'rxjs';
7+
import {
8+
map,
9+
tap,
10+
} from 'rxjs/operators';
11+
12+
import { getForbiddenRoute } from '../../app-routing-paths';
13+
import { hasValue } from '../../shared/empty.util';
14+
import { ItemRequestDataService } from '../data/item-request-data.service';
15+
import { RemoteData } from '../data/remote-data';
16+
import { redirectOn4xx } from '../shared/authorized.operators';
17+
import { ItemRequest } from '../shared/item-request.model';
18+
import {
19+
getFirstCompletedRemoteData,
20+
getFirstSucceededRemoteDataPayload,
21+
} from '../shared/operators';
22+
import { AuthService } from './auth.service';
23+
24+
/**
25+
* Resolve an ItemRequest based on the accessToken in the query params
26+
* Used in item-page-routes.ts to resolve the item request for all Item page components
27+
* @param route
28+
* @param state
29+
* @param router
30+
* @param authService
31+
* @param itemRequestDataService
32+
*/
33+
export const accessTokenResolver: ResolveFn<ItemRequest> = (
34+
route,
35+
state,
36+
router: Router = inject(Router),
37+
authService: AuthService = inject(AuthService),
38+
itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService),
39+
): Observable<ItemRequest> => {
40+
const accessToken = route.queryParams.accessToken;
41+
// Set null object if accesstoken is empty
42+
if ( !hasValue(accessToken) ) {
43+
return null;
44+
}
45+
// Get the item request from the server
46+
return itemRequestDataService.getSanitizedRequestByAccessToken(accessToken).pipe(
47+
getFirstCompletedRemoteData(),
48+
// Handle authorization errors, not found errors and forbidden errors as normal
49+
redirectOn4xx(router, authService),
50+
map((rd: RemoteData<ItemRequest>) => rd),
51+
// Get payload of the item request
52+
getFirstSucceededRemoteDataPayload(),
53+
tap(request => {
54+
if (!hasValue(request)) {
55+
// If the request is not found, redirect to 403 Forbidden
56+
router.navigateByUrl(getForbiddenRoute());
57+
}
58+
// Return the resolved item request object
59+
return request;
60+
}),
61+
);
62+
};

src/app/core/data/eperson-registration.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ describe('EpersonRegistrationService', () => {
9595
const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken');
9696
let headers = new HttpHeaders();
9797
const options: HttpOptions = Object.create({});
98-
headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken');
98+
headers = headers.append('x-captcha-payload', 'afreshcaptchatoken');
9999
options.headers = headers;
100100

101101
expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options));

src/app/core/data/eperson-registration.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class EpersonRegistrationService {
6969
/**
7070
* Register a new email address
7171
* @param email
72-
* @param captchaToken the value of x-recaptcha-token header
72+
* @param captchaToken the value of x-captcha-payload header
7373
*/
7474
registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
7575
const registration = new Registration();
@@ -82,7 +82,7 @@ export class EpersonRegistrationService {
8282
const options: HttpOptions = Object.create({});
8383
let headers = new HttpHeaders();
8484
if (captchaToken) {
85-
headers = headers.append('x-recaptcha-token', captchaToken);
85+
headers = headers.append('x-captcha-payload', captchaToken);
8686
}
8787
options.headers = headers;
8888

0 commit comments

Comments
 (0)