-
Notifications
You must be signed in to change notification settings - Fork 528
[DSpace-CRIS] Download button & Allow authors to download restricted files #5132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5bd912c
64e866e
4f72074
6878cd6
d361cc3
e738016
a77a7a4
7ad401d
de6955b
04a1e9f
3684066
8bceb03
f2e0b3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -706,6 +706,28 @@ layout: | |
| default: | ||
| icon: fas fa-project-diagram | ||
| style: text-success | ||
| # If true the download link in item page will be rendered as an advanced attachment, the view can be then configured with the layout.advancedAttachmentRendering config | ||
| showDownloadLinkAsAttachment: false | ||
| # Configuration for advanced attachment rendering in item pages. This controls how files are displayed when showDownloadLinkAsAttachment is enabled. | ||
| # Defines which metadata/attributes to display for bitstream attachments. | ||
| advancedAttachmentRendering: | ||
| # Metadata and attributes to display for each attachment | ||
| metadata: | ||
| - name: dc.title | ||
| type: metadata | ||
| truncatable: false | ||
| - name: dc.type | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These metadata settings don't appear to work for me? For example, if I remove some of the fields like this: I'd expect to then no longer see the checksum and size in the display. However, I still see the same display as before.
Am I misunderstanding how these settings should work? |
||
| type: metadata | ||
| truncatable: false | ||
| - name: dc.description | ||
| type: metadata | ||
| truncatable: true | ||
| - name: size | ||
| type: attribute | ||
| - name: format | ||
| type: attribute | ||
| - name: checksum | ||
| type: attribute | ||
|
|
||
| # Configuration for customization of search results | ||
| searchResults: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| import { PLATFORM_ID } from '@angular/core'; | ||
| import { | ||
| TestBed, | ||
| waitForAsync, | ||
| } from '@angular/core/testing'; | ||
| import { Router } from '@angular/router'; | ||
| import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; | ||
| import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths'; | ||
| import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; | ||
| import { Store } from '@ngrx/store'; | ||
| import { of } from 'rxjs'; | ||
|
|
||
| import { AuthService } from '../core/auth/auth.service'; | ||
| import { RemoteDataBuildService } from '../core/cache/builders/remote-data-build.service'; | ||
| import { ObjectCacheService } from '../core/cache/object-cache.service'; | ||
| import { BitstreamDataService } from '../core/data/bitstream-data.service'; | ||
| import { BitstreamFormatDataService } from '../core/data/bitstream-format-data.service'; | ||
| import { DSOChangeAnalyzer } from '../core/data/dso-change-analyzer.service'; | ||
| import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; | ||
| import { SignpostingDataService } from '../core/data/signposting-data.service'; | ||
| import { HardRedirectService } from '../core/services/hard-redirect.service'; | ||
| import { ServerResponseService } from '../core/services/server-response.service'; | ||
| import { | ||
| NativeWindowRef, | ||
| NativeWindowService, | ||
| } from '../core/services/window.service'; | ||
| import { Bitstream } from '../core/shared/bitstream.model'; | ||
| import { FileService } from '../core/shared/file.service'; | ||
| import { HALEndpointService } from '../core/shared/hal-endpoint.service'; | ||
| import { UUIDService } from '../core/shared/uuid.service'; | ||
| import { bitstreamDownloadRedirectGuard } from './bitstream-download-redirect.guard'; | ||
|
|
||
| describe('BitstreamDownloadRedirectGuard', () => { | ||
| let resolver: any; | ||
|
|
||
| let authService: AuthService; | ||
| let authorizationService: AuthorizationDataService; | ||
| let bitstreamDataService: BitstreamDataService; | ||
| let fileService: FileService; | ||
| let halEndpointService: HALEndpointService; | ||
| let hardRedirectService: HardRedirectService; | ||
| let remoteDataBuildService: RemoteDataBuildService; | ||
| let uuidService: UUIDService; | ||
| let objectCacheService: ObjectCacheService; | ||
| let router: Router; | ||
| let store: Store; | ||
| let bitstream: Bitstream; | ||
| let serverResponseService: jasmine.SpyObj<ServerResponseService>; | ||
| let signpostingDataService: jasmine.SpyObj<SignpostingDataService>; | ||
|
|
||
| let route = { | ||
| params: {}, | ||
| queryParams: {}, | ||
| }; | ||
| let state = {}; | ||
|
|
||
| const mocklink = { | ||
| href: 'http://test.org', | ||
| rel: 'test', | ||
| type: 'test', | ||
| }; | ||
|
|
||
| const mocklink2 = { | ||
| href: 'http://test2.org', | ||
| rel: 'test', | ||
| type: 'test', | ||
| }; | ||
|
|
||
| function init() { | ||
| authService = jasmine.createSpyObj('authService', { | ||
| isAuthenticated: of(true), | ||
| setRedirectUrl: {}, | ||
| }); | ||
| authorizationService = jasmine.createSpyObj('authorizationSerivice', { | ||
| isAuthorized: of(true), | ||
| }); | ||
|
|
||
| fileService = jasmine.createSpyObj('fileService', { | ||
| retrieveFileDownloadLink: of('content-url-with-headers'), | ||
| }); | ||
|
|
||
| hardRedirectService = jasmine.createSpyObj('fileService', { | ||
| redirect: {}, | ||
| }); | ||
|
|
||
| halEndpointService = jasmine.createSpyObj('halEndpointService', { | ||
| getEndpoint: of('https://rest.api/core'), | ||
| }); | ||
|
|
||
| remoteDataBuildService = jasmine.createSpyObj('remoteDataBuildService', { | ||
| buildSingle: of(new Bitstream()), | ||
| }); | ||
|
|
||
| uuidService = jasmine.createSpyObj('uuidService', { | ||
| generate: 'test-id', | ||
| }); | ||
|
|
||
| bitstream = Object.assign(new Bitstream(), { | ||
| uuid: 'bitstreamUuid', | ||
| _links: { | ||
| content: { href: 'bitstream-content-link' }, | ||
| self: { href: 'bitstream-self-link' }, | ||
| }, | ||
| }); | ||
|
|
||
| router = jasmine.createSpyObj('router', ['navigateByUrl', 'createUrlTree']); | ||
|
|
||
| store = jasmine.createSpyObj('store', { | ||
| dispatch: {}, | ||
| pipe: of(true), | ||
| }); | ||
|
|
||
| serverResponseService = jasmine.createSpyObj('ServerResponseService', { | ||
| setHeader: jasmine.createSpy('setHeader'), | ||
| }); | ||
|
|
||
| signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { | ||
| getLinks: of([mocklink, mocklink2]), | ||
| }); | ||
|
|
||
| objectCacheService = jasmine.createSpyObj('objectCacheService', { | ||
| getByHref: of(null), | ||
| }); | ||
|
|
||
| bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { | ||
| findById: createSuccessfulRemoteDataObject$(Object.assign(new Bitstream(), { | ||
| _links: { | ||
| content: { href: 'bitstream-content-link' }, | ||
| self: { href: 'bitstream-self-link' }, | ||
| }, | ||
| })), | ||
| }); | ||
|
|
||
| resolver = bitstreamDownloadRedirectGuard; | ||
| } | ||
|
|
||
| function initTestbed() { | ||
| TestBed.configureTestingModule({ | ||
| providers: [ | ||
| { provide: NativeWindowService, useValue: new NativeWindowRef() }, | ||
| { provide: Router, useValue: router }, | ||
| { provide: AuthorizationDataService, useValue: authorizationService }, | ||
| { provide: AuthService, useValue: authService }, | ||
| { provide: FileService, useValue: fileService }, | ||
| { provide: HardRedirectService, useValue: hardRedirectService }, | ||
| { provide: ServerResponseService, useValue: serverResponseService }, | ||
| { provide: SignpostingDataService, useValue: signpostingDataService }, | ||
| { provide: ObjectCacheService, useValue: objectCacheService }, | ||
| { provide: PLATFORM_ID, useValue: 'server' }, | ||
| { provide: UUIDService, useValue: uuidService }, | ||
| { provide: Store, useValue: store }, | ||
| { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, | ||
| { provide: HALEndpointService, useValue: halEndpointService }, | ||
| { provide: DSOChangeAnalyzer, useValue: {} }, | ||
| { provide: BitstreamFormatDataService, useValue: {} }, | ||
| { provide: NotificationsService, useValue: {} }, | ||
| { provide: BitstreamDataService, useValue: bitstreamDataService }, | ||
| ], | ||
| }); | ||
| } | ||
|
|
||
| describe('bitstream retrieval', () => { | ||
| describe('when the user is authorized and not logged in', () => { | ||
| beforeEach(() => { | ||
| init(); | ||
| (authService.isAuthenticated as jasmine.Spy).and.returnValue(of(false)); | ||
| initTestbed(); | ||
| }); | ||
| it('should redirect to the content link', waitForAsync(() => { | ||
| TestBed.runInInjectionContext(() => { | ||
| resolver(route, state).subscribe(() => { | ||
| expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link', null, true); | ||
| }, | ||
| ); | ||
| }); | ||
| })); | ||
| }); | ||
| describe('when the user is authorized and logged in', () => { | ||
| beforeEach(() => { | ||
| init(); | ||
| initTestbed(); | ||
| }); | ||
| it('should redirect to an updated content link', waitForAsync(() => { | ||
| TestBed.runInInjectionContext(() => { | ||
| resolver(route, state).subscribe(() => { | ||
| expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers', null, true); | ||
| }); | ||
| }); | ||
| })); | ||
| }); | ||
| describe('when the user is not authorized and logged in', () => { | ||
| beforeEach(() => { | ||
| init(); | ||
| (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); | ||
| initTestbed(); | ||
| }); | ||
| it('should navigate to the forbidden route', waitForAsync(() => { | ||
| TestBed.runInInjectionContext(() => { | ||
| resolver(route, state).subscribe(() => { | ||
| expect(router.createUrlTree).toHaveBeenCalledWith([getForbiddenRoute()]); | ||
| }); | ||
| }); | ||
| })); | ||
| }); | ||
| describe('when the user is not authorized and not logged in', () => { | ||
| beforeEach(() => { | ||
| init(); | ||
| (authService.isAuthenticated as jasmine.Spy).and.returnValue(of(false)); | ||
| (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); | ||
| initTestbed(); | ||
| }); | ||
| it('should navigate to the login page', waitForAsync(() => { | ||
|
|
||
| TestBed.runInInjectionContext(() => { | ||
| resolver(route, state).subscribe(() => { | ||
| expect(authService.setRedirectUrl).toHaveBeenCalled(); | ||
| expect(router.createUrlTree).toHaveBeenCalledWith(['login']); | ||
| }); | ||
| }); | ||
| })); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { inject } from '@angular/core'; | ||
| import { | ||
| ActivatedRouteSnapshot, | ||
| CanActivateFn, | ||
| Router, | ||
| RouterStateSnapshot, | ||
| UrlTree, | ||
| } from '@angular/router'; | ||
| import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths'; | ||
| import { | ||
| combineLatest, | ||
| Observable, | ||
| of, | ||
| } from 'rxjs'; | ||
| import { | ||
| filter, | ||
| map, | ||
| switchMap, | ||
| take, | ||
| } from 'rxjs/operators'; | ||
|
|
||
| import { AuthService } from '../core/auth/auth.service'; | ||
| 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 { RemoteData } from '../core/data/remote-data'; | ||
| import { HardRedirectService } from '../core/services/hard-redirect.service'; | ||
| import { redirectOn4xx } from '../core/shared/authorized.operators'; | ||
| import { | ||
| Bitstream, | ||
| BITSTREAM_PAGE_LINKS_TO_FOLLOW, | ||
| } from '../core/shared/bitstream.model'; | ||
| import { FileService } from '../core/shared/file.service'; | ||
| import { getFirstCompletedRemoteData } from '../core/shared/operators'; | ||
| import { | ||
| hasValue, | ||
| isNotEmpty, | ||
| } from '../utils/empty.util'; | ||
|
|
||
| /** | ||
| * Guard that handles bitstream download authorization and redirection logic. | ||
| * This guard intercepts bitstream download requests and performs the following checks and actions: | ||
| * | ||
| * 1. **Retrieves the bitstream** by ID from the route parameters | ||
| * 2. **Checks authorization** using the CanDownload feature permission | ||
| * 3. **Determines authentication status** of the current user | ||
| * 4. **Handles different scenarios**: | ||
| * - **Authorized + Logged in**: Retrieves a secure download link and redirects to it | ||
| * - **Authorized + Not logged in + No access token**: Direct redirect to bitstream content URL | ||
| * - **Not authorized + Has access token**: Redirect to content URL with access token appended | ||
| * - **Not authorized + Logged in**: Redirect to forbidden page | ||
| * - **Not authorized + Not logged in**: Store current URL and redirect to login page | ||
| * | ||
| * @param route - The activated route snapshot containing the bitstream ID and optional access token | ||
| * @param state - The router state snapshot | ||
| * @param bitstreamDataService - Service for retrieving bitstream data | ||
| * @param authorizationService - Service for checking download authorization | ||
| * @param auth - Service for authentication operations | ||
| * @param fileService - Service for retrieving secure file download links | ||
| * @param hardRedirectService - Service for performing hard redirects to download URLs | ||
| * @param router - Angular router for navigation | ||
| * @returns Observable that emits a UrlTree for navigation or boolean to allow/prevent route activation | ||
| */ | ||
| export const bitstreamDownloadRedirectGuard: CanActivateFn = ( | ||
tdonohue marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| route: ActivatedRouteSnapshot, | ||
| state: RouterStateSnapshot, | ||
| bitstreamDataService: BitstreamDataService = inject(BitstreamDataService), | ||
| authorizationService: AuthorizationDataService = inject(AuthorizationDataService), | ||
| auth: AuthService = inject(AuthService), | ||
| fileService: FileService = inject(FileService), | ||
| hardRedirectService: HardRedirectService = inject(HardRedirectService), | ||
| router: Router = inject(Router), | ||
| ): Observable<UrlTree | boolean> => { | ||
|
|
||
| const bitstreamId = route.params.id; | ||
| const accessToken: string = route.queryParams.accessToken; | ||
|
|
||
| return bitstreamDataService.findById(bitstreamId, true, false, ...BITSTREAM_PAGE_LINKS_TO_FOLLOW).pipe( | ||
| getFirstCompletedRemoteData(), | ||
| redirectOn4xx(router, auth), | ||
| switchMap((rd: RemoteData<Bitstream>) => { | ||
| if (rd.hasSucceeded && !rd.hasNoContent) { | ||
| const bitstream = rd.payload; | ||
| const isAuthorized$ = authorizationService.isAuthorized(FeatureID.CanDownload, bitstream.self); | ||
| const isLoggedIn$ = auth.isAuthenticated(); | ||
| return combineLatest([isAuthorized$, isLoggedIn$, of(bitstream)]); | ||
| } else { | ||
| return of([false, false, null]); | ||
| } | ||
| }), | ||
| filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)), | ||
| take(1), | ||
| switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => { | ||
| if (isAuthorized && isLoggedIn) { | ||
| return fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe( | ||
| filter((fileLink) => hasValue(fileLink)), | ||
| take(1), | ||
| map((fileLink) => { | ||
| return [isAuthorized, isLoggedIn, bitstream, fileLink]; | ||
| })); | ||
| } else { | ||
| return of([isAuthorized, isLoggedIn, bitstream, '']); | ||
| } | ||
| }), | ||
| map(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => { | ||
| if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) { | ||
| hardRedirectService.redirect(fileLink, null, true); | ||
| return false; | ||
| } else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) { | ||
| hardRedirectService.redirect(bitstream._links.content.href, null, true); | ||
| return false; | ||
| } else if (!isAuthorized) { | ||
| if (hasValue(accessToken)) { | ||
| hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken, null, true); | ||
| return false; | ||
| } else if (isLoggedIn) { | ||
| return router.createUrlTree([getForbiddenRoute()]); | ||
| } else if (!isLoggedIn) { | ||
| auth.setRedirectUrl(router.url); | ||
| return router.createUrlTree(['login']); | ||
| } | ||
| } | ||
| }), | ||
| ); | ||
| }; | ||

Uh oh!
There was an error while loading. Please reload this page.