diff --git a/package-lock.json b/package-lock.json index 27a9b18050..4848772d0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,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", @@ -164,6 +165,12 @@ "version": "0.0.0", "dev": true }, + "node_modules/@altcha/crypto": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@altcha/crypto/-/crypto-0.0.1.tgz", + "integrity": "sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==", + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -8895,6 +8902,31 @@ "ajv": "^8.8.2" } }, + "node_modules/altcha": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/altcha/-/altcha-0.9.0.tgz", + "integrity": "sha512-W83eEYpBw5lg37O9c/rtBpp0AaW3+6uiMHifSW8VKFRs2afps16UMO6B93Kaqbr/xA9KNSPEW3q0PwwA01+Ugg==", + "license": "MIT", + "dependencies": { + "@altcha/crypto": "^0.0.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.18.0" + } + }, + "node_modules/altcha/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/angulartics2": { "version": "12.2.1", "resolved": "https://registry.npmjs.org/angulartics2/-/angulartics2-12.2.1.tgz", diff --git a/package.json b/package.json index c289171423..3a7f765f9d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 7d202f16e9..4ef99559a8 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -35,6 +35,41 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st }; } +/** + * 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 = { + routerLink: url, + queryParams: { + accessToken: (hasValue(accessToken) ? accessToken : undefined), + }, + }; + return options; +} + +export const COAR_NOTIFY_SUPPORT = 'coar-notify-support'; + export const HOME_PAGE_PATH = 'home'; export function getHomePageRoute() { @@ -128,6 +163,11 @@ export function getRequestCopyModulePath() { return `/${REQUEST_COPY_MODULE_PATH}`; } +export const ACCESS_BY_TOKEN_MODULE_PATH = 'access-by-token'; +export function getAccessByTokenModulePath() { + return `/${ACCESS_BY_TOKEN_MODULE_PATH}`; +} + export const HEALTH_PAGE_PATH = 'health'; export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions'; diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts index 0cc293c6f7..b32510cba7 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts @@ -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']); diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts index ee329df16e..41a53e75df 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { ActivatedRoute, + Params, Router, } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; @@ -83,6 +84,10 @@ export class BitstreamDownloadPageComponent implements OnInit { } ngOnInit(): void { + const accessToken$: Observable = this.route.queryParams.pipe( + map((queryParams: Params) => queryParams?.accessToken || null), + take(1), + ); this.bitstreamRD$ = this.route.data.pipe( map((data) => data.bitstream)); @@ -96,11 +101,11 @@ export class BitstreamDownloadPageComponent implements OnInit { 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)), @@ -108,20 +113,28 @@ export class BitstreamDownloadPageComponent implements OnInit { map((fileLink) => { return [isAuthorized, isLoggedIn, bitstream, fileLink]; })); + } else if (hasValue(accessToken)) { + return [[isAuthorized, !isLoggedIn, bitstream, '', accessToken]]; } 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); + } else if (isLoggedIn) { + this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true }); + } else if (!isLoggedIn) { + this.auth.setRedirectUrl(this.router.url); + this.router.navigateByUrl('login'); + } } }); } diff --git a/src/app/core/auth/access-token.resolver.ts b/src/app/core/auth/access-token.resolver.ts new file mode 100644 index 0000000000..a0646d72e8 --- /dev/null +++ b/src/app/core/auth/access-token.resolver.ts @@ -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 = ( + route, + state, + router: Router = inject(Router), + authService: AuthService = inject(AuthService), + itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService), +): Observable => { + 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), + map((rd: RemoteData) => 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; + }), + ); +}; diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index acf9ab284a..b06f614139 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -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)); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 29c110d80e..a6a7f9478c 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -69,7 +69,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> { const registration = new Registration(); @@ -82,7 +82,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; diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index 68577ae6e2..59a497777f 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -1,10 +1,17 @@ +import { HttpHeaders } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { MockBitstream1 } from '../../shared/mocks/item.mock'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemRequest } from '../shared/item-request.model'; +import { ConfigurationDataService } from './configuration-data.service'; +import { AuthorizationDataService } from './feature-authorization/authorization-data.service'; +import { FeatureID } from './feature-authorization/feature-id'; +import { FindListOptions } from './find-list-options.model'; import { ItemRequestDataService } from './item-request-data.service'; import { PostRequest } from './request.models'; import { RequestService } from './request.service'; @@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => { let requestService: RequestService; let rdbService: RemoteDataBuildService; let halService: HALEndpointService; + let configService: ConfigurationDataService; + let authorizationDataService: AuthorizationDataService; const restApiEndpoint = 'rest/api/endpoint/'; const requestId = 'request-id'; let itemRequest: ItemRequest; beforeEach(() => { + configService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']); + (configService.findByPropertyName as jasmine.Spy).and.callFake((propertyName: string) => { + switch (propertyName) { + case 'request.item.create.captcha': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.create.captcha', + values: ['true'], + })); + case 'request.item.grant.link.period': + return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.grant.link.period', + values: ['FOREVER', '+1DAY', '+1MONTH'], + })); + default: + return createSuccessfulRemoteDataObject$(new ConfigurationProperty()); + } + }); + + + authorizationDataService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(false), + }); itemRequest = Object.assign(new ItemRequest(), { token: 'item-request-token', }); @@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => { getEndpoint: observableOf(restApiEndpoint), }); - service = new ItemRequestDataService(requestService, rdbService, null, halService); + service = new ItemRequestDataService(requestService, rdbService, null, halService, configService, authorizationDataService); + }); + + describe('searchBy', () => { + it('should use searchData to perform search operations', () => { + const searchMethod = 'testMethod'; + const options = new FindListOptions(); + + const searchDataSpy = spyOn((service as any).searchData, 'searchBy').and.returnValue(observableOf(null)); + + service.searchBy(searchMethod, options); + + expect(searchDataSpy).toHaveBeenCalledWith( + searchMethod, + options, + undefined, + undefined, + ); + }); }); describe('requestACopy', () => { it('should send a POST request containing the provided item request', (done) => { - service.requestACopy(itemRequest).subscribe(() => { - expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest)); + const captchaPayload = 'payload'; + service.requestACopy(itemRequest, captchaPayload).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith( + new PostRequest( + requestId, + restApiEndpoint, + itemRequest, + { + headers: new HttpHeaders().set('x-captcha-payload', captchaPayload), + }, + ), + false, + ); done(); }); }); @@ -56,14 +116,19 @@ describe('ItemRequestDataService', () => { }); it('should send a PUT request containing the correct properties', (done) => { - service.grant(itemRequest.token, email, true).subscribe(() => { + service.grant(itemRequest.token, email, true, '+1DAY').subscribe(() => { expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ method: RestRequestMethod.PUT, + href: `${restApiEndpoint}/${itemRequest.token}`, body: JSON.stringify({ acceptRequest: true, responseMessage: email.message, subject: email.subject, suggestOpenAccess: true, + accessPeriod: '+1DAY', + }), + options: jasmine.objectContaining({ + headers: jasmine.any(HttpHeaders), }), })); done(); @@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => { service.deny(itemRequest.token, email).subscribe(() => { expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ method: RestRequestMethod.PUT, + href: `${restApiEndpoint}/${itemRequest.token}`, body: JSON.stringify({ acceptRequest: false, responseMessage: email.message, subject: email.subject, suggestOpenAccess: false, + accessPeriod: null, + }), + options: jasmine.objectContaining({ + headers: jasmine.any(HttpHeaders), }), })); done(); }); }); }); + + describe('requestACopy', () => { + it('should send a POST request containing the provided item request', (done) => { + const captchaPayload = 'payload'; + service.requestACopy(itemRequest, captchaPayload).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith( + new PostRequest( + requestId, + restApiEndpoint, + itemRequest, + { + headers: new HttpHeaders().set('x-captcha-payload', captchaPayload), + }, + ), + false, + ); + done(); + }); + }); + }); + + describe('getConfiguredAccessPeriods', () => { + it('should return parsed integer values from config', () => { + service.getConfiguredAccessPeriods().subscribe(periods => { + expect(periods).toEqual(['FOREVER', '+1DAY', '+1MONTH']); + }); + }); + }); + describe('isProtectedByCaptcha', () => { + it('should return true when config value is "true"', () => { + const mockConfigProperty = { + name: 'request.item.create.captcha', + values: ['true'], + } as ConfigurationProperty; + service.isProtectedByCaptcha().subscribe(result => { + expect(result).toBe(true); + }); + }); + }); + + describe('canDownload', () => { + it('should check authorization for bitstream download', () => { + service.canDownload(MockBitstream1).subscribe(result => { + expect(authorizationDataService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanDownload, MockBitstream1.self); + expect(result).toBe(false); + }); + }); + }); + + }); diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 5c85ed1471..26fd4923b4 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -13,14 +13,27 @@ import { hasValue, isNotEmpty, } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemRequest } from '../shared/item-request.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { ConfigurationDataService } from './configuration-data.service'; +import { AuthorizationDataService } from './feature-authorization/authorization-data.service'; +import { FeatureID } from './feature-authorization/feature-id'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { PostRequest, @@ -34,14 +47,20 @@ import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', }) -export class ItemRequestDataService extends IdentifiableDataService { +export class ItemRequestDataService extends IdentifiableDataService implements SearchData { + + private searchData: SearchDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected configService: ConfigurationDataService, + protected authorizationService: AuthorizationDataService, ) { super('itemrequests', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } getItemRequestEndpoint(): Observable { @@ -61,17 +80,26 @@ export class ItemRequestDataService extends IdentifiableDataService /** * Request a copy of an item * @param itemRequest + * @param captchaPayload payload of captcha verification */ - requestACopy(itemRequest: ItemRequest): Observable> { + requestACopy(itemRequest: ItemRequest, captchaPayload: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getItemRequestEndpoint(); + // Inject captcha payload into headers + const options: HttpOptions = Object.create({}); + if (captchaPayload) { + let headers = new HttpHeaders(); + headers = headers.set('x-captcha-payload', captchaPayload); + options.headers = headers; + } + href$.pipe( find((href: string) => hasValue(href)), map((href: string) => { - const request = new PostRequest(requestId, href, itemRequest); - this.requestService.send(request); + const request = new PostRequest(requestId, href, itemRequest, options); + this.requestService.send(request, false); }), ).subscribe(); @@ -94,9 +122,10 @@ export class ItemRequestDataService extends IdentifiableDataService * @param token Token of the {@link ItemRequest} * @param email Email to send back to the user requesting the item * @param suggestOpenAccess Whether or not to suggest the item to become open access + * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable> { - return this.process(token, email, true, suggestOpenAccess); + grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod: string = null): Observable> { + return this.process(token, email, true, suggestOpenAccess, accessPeriod); } /** @@ -105,8 +134,9 @@ export class ItemRequestDataService extends IdentifiableDataService * @param email Email to send back to the user requesting the item * @param grant Grant or deny the request (true = grant, false = deny) * @param suggestOpenAccess Whether or not to suggest the item to become open access + * @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments) */ - process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable> { + process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod: string = null): Observable> { const requestId = this.requestService.generateRequestId(); this.getItemRequestEndpointByToken(token).pipe( @@ -121,6 +151,7 @@ export class ItemRequestDataService extends IdentifiableDataService responseMessage: email.message, subject: email.subject, suggestOpenAccess, + accessPeriod: accessPeriod, }), options); }), sendRequest(this.requestService), @@ -128,4 +159,81 @@ export class ItemRequestDataService extends IdentifiableDataService return this.rdbService.buildFromRequestUUID(requestId); } + + /** + * Get a sanitized item request using the searchBy method and the access token sent to the original requester. + * + * @param accessToken access token contained in the secure link sent to a requester + */ + getSanitizedRequestByAccessToken(accessToken: string): Observable> { + const findListOptions = Object.assign({}, new FindListOptions(), { + searchParams: [ + new RequestParam('accessToken', accessToken), + ], + }); + const hrefObs = this.getSearchByHref( + 'byAccessToken', + findListOptions, + ); + + return this.searchData.findByHref( + hrefObs, + ); + } + + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale); + } + + /** + * Get configured access periods (in seconds) to populate the dropdown in the item request approval form + * if the 'send secure link' feature is configured. + * Expects integer values, conversion to number is done in this processing + */ + getConfiguredAccessPeriods(): Observable { + return this.configService.findByPropertyName('request.item.grant.link.period').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []), + ); + } + + /** + * Is the request copy form protected by a captcha? This will be used to decide whether to render the captcha + * component in bitstream-request-a-copy-page component + */ + isProtectedByCaptcha(): Observable { + return this.configService.findByPropertyName('request.item.create.captcha').pipe( + getFirstCompletedRemoteData(), + map((rd) => { + if (rd.hasSucceeded) { + return rd.payload.values.length > 0 && rd.payload.values[0] === 'true'; + } else { + return false; + } + })); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Authorization check to see if the user already has download access to the given bitstream. + * Wrapped in this service to give it a central place and make it easy to mock for testing. + * + * @param bitstream The bitstream to be downloaded + * @return {Observable} true if user may download, false if not + */ + canDownload(bitstream: Bitstream): Observable { + return this.authorizationService.isAuthorized(FeatureID.CanDownload, bitstream?.self); + } } diff --git a/src/app/core/data/proof-of-work-captcha-data.service.ts b/src/app/core/data/proof-of-work-captcha-data.service.ts new file mode 100644 index 0000000000..4f445c4dde --- /dev/null +++ b/src/app/core/data/proof-of-work-captcha-data.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +/** + * Service for retrieving captcha challenge data, so proof-of-work calculations can be performed + * and returned with protected form data. + */ +@Injectable({ providedIn: 'root' }) +export class ProofOfWorkCaptchaDataService { + + private linkPath = 'captcha'; + + constructor( + private halService: HALEndpointService) { + } + + /** + * Get the endpoint for retrieving a new captcha challenge, to be passed + * to the Altcha captcha component as an input property + */ + public getChallengeHref(): Observable { + return this.getEndpoint().pipe( + map((endpoint) => endpoint + '/challenge'), + ); + } + + /** + * Get the base CAPTCHA endpoint URL + * @protected + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts index 5a4f912363..5180be1fe7 100644 --- a/src/app/core/shared/item-request.model.ts +++ b/src/app/core/shared/item-request.model.ts @@ -80,7 +80,19 @@ export class ItemRequest implements CacheableObject { */ @autoserialize bitstreamId: string; + /** + * Access token of the request (read-only) + */ + @autoserialize + accessToken: string; + /** + * Access expiry date of the request + */ + @autoserialize + accessExpiry: string; + @autoserialize + accessExpired: boolean; /** * The {@link HALLink}s for this ItemRequest */ diff --git a/src/app/core/shared/media-viewer-item.model.ts b/src/app/core/shared/media-viewer-item.model.ts index 1cf4948408..dd4fafeb34 100644 --- a/src/app/core/shared/media-viewer-item.model.ts +++ b/src/app/core/shared/media-viewer-item.model.ts @@ -23,4 +23,9 @@ export class MediaViewerItem { * Incoming Bitsream thumbnail */ thumbnail: string; + + /** + * Access token, if accessed via a Request-a-Copy link + */ + accessToken: string; } diff --git a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.html b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.html new file mode 100644 index 0000000000..c164c99b36 --- /dev/null +++ b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.html @@ -0,0 +1,6 @@ + diff --git a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.spec.ts b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.spec.ts new file mode 100644 index 0000000000..98a2fe1586 --- /dev/null +++ b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.spec.ts @@ -0,0 +1,46 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { AltchaCaptchaComponent } from './altcha-captcha.component'; + +describe('AltchaCaptchaComponent', () => { + let component: AltchaCaptchaComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + AltchaCaptchaComponent, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AltchaCaptchaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create component successfully', () => { + expect(component).toBeTruthy(); + }); + + it('should emit payload when verification is successful', () => { + const testPayload = 'test-payload'; + const payloadSpy = jasmine.createSpy('payloadSpy'); + component.payload.subscribe(payloadSpy); + + const event = new CustomEvent('statechange', { + detail: { + state: 'verified', + payload: testPayload, + }, + }); + + document.querySelector('#altcha-widget').dispatchEvent(event); + + expect(payloadSpy).toHaveBeenCalledWith(testPayload); + }); +}); diff --git a/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts new file mode 100644 index 0000000000..a9d5fd77f3 --- /dev/null +++ b/src/app/item-page/bitstreams/request-a-copy/altcha-captcha.component.ts @@ -0,0 +1,61 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + CUSTOM_ELEMENTS_SCHEMA, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { VarDirective } from '../../../shared/utils/var.directive'; + +@Component({ + selector: 'ds-altcha-captcha', + templateUrl: './altcha-captcha.component.html', + imports: [ + TranslateModule, + RouterLink, + AsyncPipe, + ReactiveFormsModule, + NgIf, + VarDirective, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + standalone: true, +}) + +/** + * Component that renders the ALTCHA captcha widget. GDPR-compliant, no cookies, proof-of-work based anti-spam captcha. + * See: https://altcha.org/ + * + * Once the proof of work is verified, the final payload is emitted to the parent component for inclusion in the form submission. + */ +export class AltchaCaptchaComponent implements OnInit { + + // Challenge URL, to query the backend (or other remote) for a challenge + @Input() challengeUrl: string; + // Whether / how to autoload the widget, e.g. 'onload', 'onsubmit', 'onfocus', 'off' + @Input() autoload = 'onload'; + // Whether to debug altcha activity to the javascript console + @Input() debug: boolean; + // The final calculated payload (containing, challenge, salt, number) to be sent with the protected form submission for validation + @Output() payload = new EventEmitter; + + ngOnInit(): void { + document.querySelector('#altcha-widget').addEventListener('statechange', (ev: any) => { + // state can be: unverified, verifying, verified, error + if (ev.detail.state === 'verified') { + // payload contains base64 encoded data for the server + this.payload.emit(ev.detail.payload); + } + }); + } + +} diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html index 4e40883602..1f39afbdcf 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html @@ -84,6 +84,14 @@ + + @if (!!(captchaEnabled$ | async)) { +
+ + +
+ } +
diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts index 5a5a8e6fca..7456ec6200 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.spec.ts @@ -16,19 +16,27 @@ import { ActivatedRoute, Router, } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; +import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; import { AuthService } from '../../../core/auth/auth.service'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RestResponse } from '../../../core/cache/response.models'; import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { ItemRequestDataService } from '../../../core/data/item-request-data.service'; +import { RequestService } from '../../../core/data/request.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { Item } from '../../../core/shared/item.model'; +import { ITEM } from '../../../core/shared/item.resource-type'; import { ItemRequest } from '../../../core/shared/item-request.model'; import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { createFailedRemoteDataObject$, @@ -39,6 +47,9 @@ import { NotificationsServiceStub } from '../../../shared/testing/notifications- import { RouterStub } from '../../../shared/testing/router.stub'; import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component'; +const mockDataServiceMap: any = new Map([ + [ITEM.value, () => import('../../../shared/testing/test-data-service.mock').then(m => m.TestDataService)], +]); describe('BitstreamRequestACopyPageComponent', () => { let component: BitstreamRequestACopyPageComponent; @@ -48,10 +59,11 @@ describe('BitstreamRequestACopyPageComponent', () => { let authorizationService: AuthorizationDataService; let activatedRoute; let router; - let itemRequestDataService; + let itemRequestDataService: ItemRequestDataService; let notificationsService; let location; let bitstreamDataService; + let requestService; let item: Item; let bitstream: Bitstream; @@ -75,8 +87,20 @@ describe('BitstreamRequestACopyPageComponent', () => { itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { requestACopy: createSuccessfulRemoteDataObject$({}), + isProtectedByCaptcha: observableOf(true), }); + requestService = Object.assign(getMockRequestService(), { + getByHref(requestHref: string) { + const responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'OK'); + return observableOf(responseCacheEntry); + }, + removeByHrefSubstring(href: string) { + // Do nothing + }, + }) as RequestService; + location = jasmine.createSpyObj('location', { back: {}, }); @@ -124,6 +148,9 @@ describe('BitstreamRequestACopyPageComponent', () => { { provide: NotificationsService, useValue: notificationsService }, { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: BitstreamDataService, useValue: bitstreamDataService }, + { provide: Store, useValue: provideMockStore() }, + { provide: RequestService, useValue: requestService }, + { provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap }, ], }) .compileComponents(); @@ -246,6 +273,7 @@ describe('BitstreamRequestACopyPageComponent', () => { component.email.patchValue('user@name.org'); component.allfiles.patchValue('false'); component.message.patchValue('I would like to request a copy'); + component.captchaPayload.patchValue('payload'); component.onSubmit(); const itemRequest = Object.assign(new ItemRequest(), @@ -258,7 +286,7 @@ describe('BitstreamRequestACopyPageComponent', () => { requestMessage: 'I would like to request a copy', }); - expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload'); expect(notificationsService.success).toHaveBeenCalled(); expect(location.back).toHaveBeenCalled(); }); @@ -280,6 +308,7 @@ describe('BitstreamRequestACopyPageComponent', () => { component.email.patchValue('user@name.org'); component.allfiles.patchValue('false'); component.message.patchValue('I would like to request a copy'); + component.captchaPayload.patchValue('payload'); component.onSubmit(); const itemRequest = Object.assign(new ItemRequest(), @@ -292,7 +321,7 @@ describe('BitstreamRequestACopyPageComponent', () => { requestMessage: 'I would like to request a copy', }); - expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest, 'payload'); expect(notificationsService.error).toHaveBeenCalled(); expect(location.back).not.toHaveBeenCalled(); }); diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts index dfbcef4111..9d4975f9bb 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts @@ -1,9 +1,13 @@ +import 'altcha'; + import { AsyncPipe, Location, } from '@angular/common'; import { + ChangeDetectorRef, Component, + CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit, } from '@angular/core'; @@ -46,6 +50,7 @@ import { BitstreamDataService } from '../../../core/data/bitstream-data.service' import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { ItemRequestDataService } from '../../../core/data/item-request-data.service'; +import { ProofOfWorkCaptchaDataService } from '../../../core/data/proof-of-work-captcha-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { Item } from '../../../core/shared/item.model'; @@ -60,7 +65,9 @@ import { isNotEmpty, } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { VarDirective } from '../../../shared/utils/var.directive'; import { getItemPageRoute } from '../../item-page-routing-paths'; +import { AltchaCaptchaComponent } from './altcha-captcha.component'; @Component({ selector: 'ds-bitstream-request-a-copy-page', @@ -71,7 +78,10 @@ import { getItemPageRoute } from '../../item-page-routing-paths'; AsyncPipe, ReactiveFormsModule, BtnDisabledDirective, + VarDirective, + AltchaCaptchaComponent, ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], standalone: true, }) /** @@ -92,6 +102,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { bitstream: Bitstream; bitstreamName: string; + // Captcha settings + captchaEnabled$: Observable; + challengeHref$: Observable; + constructor(private location: Location, private translateService: TranslateService, private route: ActivatedRoute, @@ -103,6 +117,8 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { private notificationsService: NotificationsService, private dsoNameService: DSONameService, private bitstreamService: BitstreamDataService, + private captchaService: ProofOfWorkCaptchaDataService, + private changeDetectorRef: ChangeDetectorRef, ) { } @@ -117,8 +133,15 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { }), allfiles: new UntypedFormControl(''), message: new UntypedFormControl(''), + // Payload here is initialised as "required", but this validator will be cleared + // if the config property comes back as 'captcha not enabled' + captchaPayload: new UntypedFormControl('', { + validators: [Validators.required], + }), }); + this.captchaEnabled$ = this.itemRequestDataService.isProtectedByCaptcha(); + this.challengeHref$ = this.captchaService.getChallengeHref(); this.item$ = this.route.data.pipe( map((data) => data.dso), @@ -172,6 +195,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { return this.requestCopyForm.get('allfiles'); } + get captchaPayload() { + return this.requestCopyForm.get('captchaPayload'); + } + /** * Initialise the form values based on the current user. */ @@ -185,6 +212,17 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { this.bitstream$.pipe(take(1)).subscribe((bitstream) => { this.requestCopyForm.patchValue({ allfiles: 'false' }); }); + this.subs.push(this.captchaEnabled$.pipe( + take(1), + ).subscribe((enabled) => { + if (!enabled) { + // Captcha not required? Clear validators to allow the form to be submitted normally + this.requestCopyForm.get('captchaPayload').clearValidators(); + this.requestCopyForm.get('captchaPayload').reset(); + this.requestCopyForm.updateValueAndValidity(); + } + this.changeDetectorRef.detectChanges(); + })); } /** @@ -218,8 +256,9 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { itemRequest.requestEmail = this.email.value; itemRequest.requestName = this.name.value; itemRequest.requestMessage = this.message.value; + const captchaPayloadString: string = this.captchaPayload.value; - this.itemRequestDataService.requestACopy(itemRequest).pipe( + this.itemRequestDataService.requestACopy(itemRequest, captchaPayloadString).pipe( getFirstCompletedRemoteData(), ).subscribe((rd) => { if (rd.hasSucceeded) { @@ -231,6 +270,10 @@ export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { }); } + handlePayload(event): void { + this.requestCopyForm.patchValue({ captchaPayload: event }); + } + ngOnDestroy(): void { if (hasValue(this.subs)) { this.subs.forEach((sub) => { diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index 854d66fabe..632d979457 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -1,6 +1,7 @@ import { Route } from '@angular/router'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; +import { accessTokenResolver } from '../core/auth/access-token.resolver'; import { authenticatedGuard } from '../core/auth/authenticated.guard'; import { itemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver'; import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; @@ -11,6 +12,7 @@ import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.c import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { itemPageResolver } from './item-page.resolver'; import { + ITEM_ACCESS_BY_TOKEN_PATH, ITEM_EDIT_PATH, ORCID_PATH, UPLOAD_BITSTREAM_PATH, @@ -26,6 +28,7 @@ export const ROUTES: Route[] = [ path: ':id', resolve: { dso: itemPageResolver, + itemRequest: accessTokenResolver, breadcrumb: itemBreadcrumbResolver, }, runGuardsAndResolvers: 'always', @@ -64,6 +67,13 @@ export const ROUTES: Route[] = [ component: OrcidPageComponent, canActivate: [authenticatedGuard, orcidPageGuard], }, + { + path: ITEM_ACCESS_BY_TOKEN_PATH, + component: ThemedFullItemPageComponent, + resolve: { + menu: accessTokenResolver, + }, + }, ], data: { menu: { diff --git a/src/app/item-page/item-page-routing-paths.ts b/src/app/item-page/item-page-routing-paths.ts index cded1dd74e..7f414b9c1a 100644 --- a/src/app/item-page/item-page-routing-paths.ts +++ b/src/app/item-page/item-page-routing-paths.ts @@ -51,3 +51,5 @@ export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory'; export const ITEM_VERSION_PATH = 'version'; export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; export const ORCID_PATH = 'orcid'; + +export const ITEM_ACCESS_BY_TOKEN_PATH = 'access-by-token'; diff --git a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts index 3acb14d3ee..cf1fd64855 100644 --- a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts +++ b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts @@ -15,6 +15,7 @@ import { Observable } from 'rxjs'; import { AuthService } from '../../../core/auth/auth.service'; import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { hasValue } from '../../../shared/empty.util'; /** * This component render an image gallery for the image viewer @@ -99,7 +100,7 @@ export class MediaViewerImageComponent implements OnChanges, OnInit { medium: image.thumbnail ? image.thumbnail : this.thumbnailPlaceholder, - big: image.bitstream._links.content.href, + big: image.bitstream._links.content.href + (hasValue(image.accessToken) ? ('?accessToken=' + image.accessToken) : ''), }); } } diff --git a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html index a3d87c780d..d9ddcbf6d9 100644 --- a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html +++ b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html @@ -1,6 +1,6 @@