Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
aea41d7
Request-a-copy improv: Secure media and image viewers
kshepherd Feb 13, 2025
f3bb732
Request-a-copy improv: Secure file section and download links
kshepherd Feb 13, 2025
4281267
Request-a-copy improv: Secure item view comps
kshepherd Feb 13, 2025
0de6481
Request-a-copy improv: Routing and module changes
kshepherd Feb 13, 2025
60bbcf3
Request-a-copy improv: Changes to bitstream page to support tests
kshepherd Feb 13, 2025
0c58a5b
Request-a-copy: Refactor for angular control flow changes
kshepherd Mar 13, 2025
e736bbb
Request-a-copy improv: Altcha recaptcha component and service
kshepherd Feb 13, 2025
585347b
Request-a-copy improv: English i18n
kshepherd Feb 13, 2025
bff5662
Request-a-copy improv: Add altcha dependency to package.json
kshepherd Feb 13, 2025
e928eab
Request-a-copy improv: altcha fixes
kshepherd Feb 17, 2025
c9c2a77
Request-a-copy: Merge recaptcha headers to generic x-captcha-payload
kshepherd Feb 17, 2025
ce93b84
Request-a-copy: Code cleanup and comments
kshepherd Mar 6, 2025
58d0e7f
Request-a-copy: Using a resolver to grab the RequestItem
kshepherd Mar 6, 2025
d1bcb9f
Request-a-copy: Use wrapped ItemWithSupp.. and base item comps, excep…
kshepherd Mar 11, 2025
80fafbf
Request-a-copy: Angular flow control changes
kshepherd Mar 13, 2025
1645180
Request-a-copy: Using route only in place of wrapper item
kshepherd Mar 17, 2025
57b618c
Request-a-copy: Changes to support access expiry as delta/date storage
kshepherd Mar 18, 2025
1fff3b5
Request-a-copy: Changes to support access expiry as delta/date storag…
kshepherd Mar 24, 2025
a1e7d65
Request-a-copy: Unit tests
kshepherd Mar 24, 2025
d8fb9f1
Request-a-copy: Test fixes
kshepherd Mar 24, 2025
7671595
Request-a-copy: Review feedback addressed
kshepherd Mar 25, 2025
58cee5f
Request-a-copy: Replace +1WEEK -> +7DAYS delta
kshepherd Mar 26, 2025
f070dee
Request-a-copy: Error handling (review feedback)
kshepherd Mar 27, 2025
e9cf183
Request-a-copy: Try to address theme compatibility of dropdown
kshepherd Mar 27, 2025
87624c7
Fix access duration dropdown in custom theme
ybnd Mar 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"altcha": "^0.9.0",
"angulartics2": "^12.2.0",
"axios": "^1.7.9",
"bootstrap": "^5.3",
Expand Down
40 changes: 40 additions & 0 deletions src/app/app-routing-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,41 @@
};
}

/**
* Get a bitstream download route with an access token (to provide direct access to a user) added as a query parameter
* @param bitstream the bitstream to download
* @param accessToken the access token, which should match an access_token in the requestitem table
*/
export function getBitstreamDownloadWithAccessTokenRoute(bitstream, accessToken): { routerLink: string, queryParams: any } {
const url = new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
const options = {
routerLink: url,
queryParams: {},
};
// Only add the access token if it is not empty, otherwise keep valid empty query parameters
if (hasValue(accessToken)) {
options.queryParams = { accessToken: accessToken };
}
return options;
}
/**
* Get an access token request route for a user to access approved bitstreams using a supplied access token
* @param item_uuid item UUID
* @param accessToken access token (generated by backend)
*/
export function getAccessTokenRequestRoute(item_uuid, accessToken): { routerLink: string, queryParams: any } {
const url = new URLCombiner(getItemModuleRoute(), item_uuid, getAccessByTokenModulePath()).toString();
const options = {

Check warning on line 62 in src/app/app-routing-paths.ts

View check run for this annotation

Codecov / codecov/patch

src/app/app-routing-paths.ts#L61-L62

Added lines #L61 - L62 were not covered by tests
routerLink: url,
queryParams: {
accessToken: (hasValue(accessToken) ? accessToken : undefined),
},
};
return options;

Check warning on line 68 in src/app/app-routing-paths.ts

View check run for this annotation

Codecov / codecov/patch

src/app/app-routing-paths.ts#L68

Added line #L68 was not covered by tests
}

export const COAR_NOTIFY_SUPPORT = 'coar-notify-support';

export const HOME_PAGE_PATH = 'home';

export function getHomePageRoute() {
Expand Down Expand Up @@ -128,6 +163,11 @@
return `/${REQUEST_COPY_MODULE_PATH}`;
}

export const ACCESS_BY_TOKEN_MODULE_PATH = 'access-by-token';
export function getAccessByTokenModulePath() {
return `/${ACCESS_BY_TOKEN_MODULE_PATH}`;

Check warning on line 168 in src/app/app-routing-paths.ts

View check run for this annotation

Codecov / codecov/patch

src/app/app-routing-paths.ts#L168

Added line #L168 was not covered by tests
}

export const HEALTH_PAGE_PATH = 'health';

export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,16 @@ describe('BitstreamDownloadPageComponent', () => {
self: { href: 'bitstream-self-link' },
},
});

activatedRoute = {
data: observableOf({
bitstream: createSuccessfulRemoteDataObject(
bitstream,
),
bitstream: createSuccessfulRemoteDataObject(bitstream),
}),
params: observableOf({
id: 'testid',
}),
queryParams: observableOf({
accessToken: undefined,
}),
};

router = jasmine.createSpyObj('router', ['navigateByUrl']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
} from '@angular/core';
import {
ActivatedRoute,
Params,
Router,
} from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
Expand Down Expand Up @@ -83,6 +84,10 @@
}

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

this.bitstreamRD$ = this.route.data.pipe(
map((data) => data.bitstream));
Expand All @@ -96,32 +101,40 @@
switchMap((bitstream: Bitstream) => {
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
const isLoggedIn$ = this.auth.isAuthenticated();
return observableCombineLatest([isAuthorized$, isLoggedIn$, observableOf(bitstream)]);
return observableCombineLatest([isAuthorized$, isLoggedIn$, accessToken$, observableOf(bitstream)]);
}),
filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)),
filter(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)),
take(1),
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => {
switchMap(([isAuthorized, isLoggedIn, accessToken, bitstream]: [boolean, boolean, string, Bitstream]) => {
if (isAuthorized && isLoggedIn) {
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
filter((fileLink) => hasValue(fileLink)),
take(1),
map((fileLink) => {
return [isAuthorized, isLoggedIn, bitstream, fileLink];
}));
} else if (hasValue(accessToken)) {
return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]];

Check warning on line 117 in src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L117 was not covered by tests
} else {
return [[isAuthorized, isLoggedIn, bitstream, '']];
}
}),
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => {
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => {
if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
this.hardRedirectService.redirect(fileLink);
} else if (isAuthorized && !isLoggedIn) {
} else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) {
this.hardRedirectService.redirect(bitstream._links.content.href);
} else if (!isAuthorized && isLoggedIn) {
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
} else if (!isAuthorized && !isLoggedIn) {
this.auth.setRedirectUrl(this.router.url);
this.router.navigateByUrl('login');
} else if (!isAuthorized) {
// Either we have an access token, or we are logged in, or we are not logged in.
// For now, the access token does not care if we are logged in or not.
if (hasValue(accessToken)) {
this.hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken);

Check warning on line 131 in src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L131 was not covered by tests
} else if (isLoggedIn) {
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
} else if (!isLoggedIn) {
this.auth.setRedirectUrl(this.router.url);
this.router.navigateByUrl('login');
}
}
});
}
Expand Down
62 changes: 62 additions & 0 deletions src/app/core/auth/access-token.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { inject } from '@angular/core';
import {
ResolveFn,
Router,
} from '@angular/router';
import { Observable } from 'rxjs';
import {
map,
tap,
} from 'rxjs/operators';

import { getForbiddenRoute } from '../../app-routing-paths';
import { hasValue } from '../../shared/empty.util';
import { ItemRequestDataService } from '../data/item-request-data.service';
import { RemoteData } from '../data/remote-data';
import { redirectOn4xx } from '../shared/authorized.operators';
import { ItemRequest } from '../shared/item-request.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
} from '../shared/operators';
import { AuthService } from './auth.service';

/**
* Resolve an ItemRequest based on the accessToken in the query params
* Used in item-page-routes.ts to resolve the item request for all Item page components
* @param route
* @param state
* @param router
* @param authService
* @param itemRequestDataService
*/
export const accessTokenResolver: ResolveFn<ItemRequest> = (
route,
state,
router: Router = inject(Router),
authService: AuthService = inject(AuthService),
itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService),
): Observable<ItemRequest> => {
const accessToken = route.queryParams.accessToken;
// Set null object if accesstoken is empty
if ( !hasValue(accessToken) ) {
return null;
}
// Get the item request from the server
return itemRequestDataService.getSanitizedRequestByAccessToken(accessToken).pipe(
getFirstCompletedRemoteData(),
// Handle authorization errors, not found errors and forbidden errors as normal
redirectOn4xx(router, authService),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now this means that you'll be shown a login page if you try to follow the access link after it expires.

It would be more informative if we could still show the Item page, but with a notice to explain what's going on.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notifications now show 2 kinds of errors, depending on if the token is expired, or just was not granted or was revoked. the redirect operator here is still in place, but won't trigger in those scenarios because the backend returns 200 for those cases in the findByAccessToken method (of course, it still throws a hard authorize error in the actual bitstream download check)

map((rd: RemoteData<ItemRequest>) => rd),
// Get payload of the item request
getFirstSucceededRemoteDataPayload(),
tap(request => {
if (!hasValue(request)) {
// If the request is not found, redirect to 403 Forbidden
router.navigateByUrl(getForbiddenRoute());
}
// Return the resolved item request object
return request;
}),
);
};
2 changes: 1 addition & 1 deletion src/app/core/data/eperson-registration.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe('EpersonRegistrationService', () => {
const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken');
let headers = new HttpHeaders();
const options: HttpOptions = Object.create({});
headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken');
headers = headers.append('x-captcha-payload', 'afreshcaptchatoken');
options.headers = headers;

expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options));
Expand Down
4 changes: 2 additions & 2 deletions src/app/core/data/eperson-registration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class EpersonRegistrationService {
/**
* Register a new email address
* @param email
* @param captchaToken the value of x-recaptcha-token header
* @param captchaToken the value of x-captcha-payload header
*/
registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
const registration = new Registration();
Expand All @@ -80,7 +80,7 @@ export class EpersonRegistrationService {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
if (captchaToken) {
headers = headers.append('x-recaptcha-token', captchaToken);
headers = headers.append('x-captcha-payload', captchaToken);
}
options.headers = headers;

Expand Down
Loading
Loading