Skip to content

Commit 201b238

Browse files
authored
Merge pull request DSpace#5132 from 4Science/task/main/DURACOM-455
[DSpace-CRIS] Download button & Allow authors to download restricted files
2 parents f8b8ac0 + f2e0b3b commit 201b238

File tree

40 files changed

+1473
-31
lines changed

40 files changed

+1473
-31
lines changed

config/config.example.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,28 @@ layout:
713713
default:
714714
icon: fas fa-project-diagram
715715
style: text-success
716+
# 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
717+
showDownloadLinkAsAttachment: false
718+
# Configuration for advanced attachment rendering in item pages. This controls how files are displayed when showDownloadLinkAsAttachment is enabled.
719+
# Defines which metadata/attributes to display for bitstream attachments.
720+
advancedAttachmentRendering:
721+
# Metadata and attributes to display for each attachment
722+
metadata:
723+
- name: dc.title
724+
type: metadata
725+
truncatable: false
726+
- name: dc.type
727+
type: metadata
728+
truncatable: false
729+
- name: dc.description
730+
type: metadata
731+
truncatable: true
732+
- name: size
733+
type: attribute
734+
- name: format
735+
type: attribute
736+
- name: checksum
737+
type: attribute
716738

717739
# Configuration for customization of search results
718740
searchResults:
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { PLATFORM_ID } from '@angular/core';
2+
import {
3+
TestBed,
4+
waitForAsync,
5+
} from '@angular/core/testing';
6+
import { Router } from '@angular/router';
7+
import { NotificationsService } from '@dspace/core/notification-system/notifications.service';
8+
import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths';
9+
import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
10+
import { Store } from '@ngrx/store';
11+
import { of } from 'rxjs';
12+
13+
import { AuthService } from '../core/auth/auth.service';
14+
import { RemoteDataBuildService } from '../core/cache/builders/remote-data-build.service';
15+
import { ObjectCacheService } from '../core/cache/object-cache.service';
16+
import { BitstreamDataService } from '../core/data/bitstream-data.service';
17+
import { BitstreamFormatDataService } from '../core/data/bitstream-format-data.service';
18+
import { DSOChangeAnalyzer } from '../core/data/dso-change-analyzer.service';
19+
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
20+
import { SignpostingDataService } from '../core/data/signposting-data.service';
21+
import { HardRedirectService } from '../core/services/hard-redirect.service';
22+
import { ServerResponseService } from '../core/services/server-response.service';
23+
import {
24+
NativeWindowRef,
25+
NativeWindowService,
26+
} from '../core/services/window.service';
27+
import { Bitstream } from '../core/shared/bitstream.model';
28+
import { FileService } from '../core/shared/file.service';
29+
import { HALEndpointService } from '../core/shared/hal-endpoint.service';
30+
import { UUIDService } from '../core/shared/uuid.service';
31+
import { bitstreamDownloadRedirectGuard } from './bitstream-download-redirect.guard';
32+
33+
describe('BitstreamDownloadRedirectGuard', () => {
34+
let resolver: any;
35+
36+
let authService: AuthService;
37+
let authorizationService: AuthorizationDataService;
38+
let bitstreamDataService: BitstreamDataService;
39+
let fileService: FileService;
40+
let halEndpointService: HALEndpointService;
41+
let hardRedirectService: HardRedirectService;
42+
let remoteDataBuildService: RemoteDataBuildService;
43+
let uuidService: UUIDService;
44+
let objectCacheService: ObjectCacheService;
45+
let router: Router;
46+
let store: Store;
47+
let bitstream: Bitstream;
48+
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
49+
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
50+
51+
let route = {
52+
params: {},
53+
queryParams: {},
54+
};
55+
let state = {};
56+
57+
const mocklink = {
58+
href: 'http://test.org',
59+
rel: 'test',
60+
type: 'test',
61+
};
62+
63+
const mocklink2 = {
64+
href: 'http://test2.org',
65+
rel: 'test',
66+
type: 'test',
67+
};
68+
69+
function init() {
70+
authService = jasmine.createSpyObj('authService', {
71+
isAuthenticated: of(true),
72+
setRedirectUrl: {},
73+
});
74+
authorizationService = jasmine.createSpyObj('authorizationSerivice', {
75+
isAuthorized: of(true),
76+
});
77+
78+
fileService = jasmine.createSpyObj('fileService', {
79+
retrieveFileDownloadLink: of('content-url-with-headers'),
80+
});
81+
82+
hardRedirectService = jasmine.createSpyObj('fileService', {
83+
redirect: {},
84+
});
85+
86+
halEndpointService = jasmine.createSpyObj('halEndpointService', {
87+
getEndpoint: of('https://rest.api/core'),
88+
});
89+
90+
remoteDataBuildService = jasmine.createSpyObj('remoteDataBuildService', {
91+
buildSingle: of(new Bitstream()),
92+
});
93+
94+
uuidService = jasmine.createSpyObj('uuidService', {
95+
generate: 'test-id',
96+
});
97+
98+
bitstream = Object.assign(new Bitstream(), {
99+
uuid: 'bitstreamUuid',
100+
_links: {
101+
content: { href: 'bitstream-content-link' },
102+
self: { href: 'bitstream-self-link' },
103+
},
104+
});
105+
106+
router = jasmine.createSpyObj('router', ['navigateByUrl', 'createUrlTree']);
107+
108+
store = jasmine.createSpyObj('store', {
109+
dispatch: {},
110+
pipe: of(true),
111+
});
112+
113+
serverResponseService = jasmine.createSpyObj('ServerResponseService', {
114+
setHeader: jasmine.createSpy('setHeader'),
115+
});
116+
117+
signpostingDataService = jasmine.createSpyObj('SignpostingDataService', {
118+
getLinks: of([mocklink, mocklink2]),
119+
});
120+
121+
objectCacheService = jasmine.createSpyObj('objectCacheService', {
122+
getByHref: of(null),
123+
});
124+
125+
bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
126+
findById: createSuccessfulRemoteDataObject$(Object.assign(new Bitstream(), {
127+
_links: {
128+
content: { href: 'bitstream-content-link' },
129+
self: { href: 'bitstream-self-link' },
130+
},
131+
})),
132+
});
133+
134+
resolver = bitstreamDownloadRedirectGuard;
135+
}
136+
137+
function initTestbed() {
138+
TestBed.configureTestingModule({
139+
providers: [
140+
{ provide: NativeWindowService, useValue: new NativeWindowRef() },
141+
{ provide: Router, useValue: router },
142+
{ provide: AuthorizationDataService, useValue: authorizationService },
143+
{ provide: AuthService, useValue: authService },
144+
{ provide: FileService, useValue: fileService },
145+
{ provide: HardRedirectService, useValue: hardRedirectService },
146+
{ provide: ServerResponseService, useValue: serverResponseService },
147+
{ provide: SignpostingDataService, useValue: signpostingDataService },
148+
{ provide: ObjectCacheService, useValue: objectCacheService },
149+
{ provide: PLATFORM_ID, useValue: 'server' },
150+
{ provide: UUIDService, useValue: uuidService },
151+
{ provide: Store, useValue: store },
152+
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
153+
{ provide: HALEndpointService, useValue: halEndpointService },
154+
{ provide: DSOChangeAnalyzer, useValue: {} },
155+
{ provide: BitstreamFormatDataService, useValue: {} },
156+
{ provide: NotificationsService, useValue: {} },
157+
{ provide: BitstreamDataService, useValue: bitstreamDataService },
158+
],
159+
});
160+
}
161+
162+
describe('bitstream retrieval', () => {
163+
describe('when the user is authorized and not logged in', () => {
164+
beforeEach(() => {
165+
init();
166+
(authService.isAuthenticated as jasmine.Spy).and.returnValue(of(false));
167+
initTestbed();
168+
});
169+
it('should redirect to the content link', waitForAsync(() => {
170+
TestBed.runInInjectionContext(() => {
171+
resolver(route, state).subscribe(() => {
172+
expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link', null, true);
173+
},
174+
);
175+
});
176+
}));
177+
});
178+
describe('when the user is authorized and logged in', () => {
179+
beforeEach(() => {
180+
init();
181+
initTestbed();
182+
});
183+
it('should redirect to an updated content link', waitForAsync(() => {
184+
TestBed.runInInjectionContext(() => {
185+
resolver(route, state).subscribe(() => {
186+
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers', null, true);
187+
});
188+
});
189+
}));
190+
});
191+
describe('when the user is not authorized and logged in', () => {
192+
beforeEach(() => {
193+
init();
194+
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false));
195+
initTestbed();
196+
});
197+
it('should navigate to the forbidden route', waitForAsync(() => {
198+
TestBed.runInInjectionContext(() => {
199+
resolver(route, state).subscribe(() => {
200+
expect(router.createUrlTree).toHaveBeenCalledWith([getForbiddenRoute()]);
201+
});
202+
});
203+
}));
204+
});
205+
describe('when the user is not authorized and not logged in', () => {
206+
beforeEach(() => {
207+
init();
208+
(authService.isAuthenticated as jasmine.Spy).and.returnValue(of(false));
209+
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false));
210+
initTestbed();
211+
});
212+
it('should navigate to the login page', waitForAsync(() => {
213+
214+
TestBed.runInInjectionContext(() => {
215+
resolver(route, state).subscribe(() => {
216+
expect(authService.setRedirectUrl).toHaveBeenCalled();
217+
expect(router.createUrlTree).toHaveBeenCalledWith(['login']);
218+
});
219+
});
220+
}));
221+
});
222+
});
223+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { inject } from '@angular/core';
2+
import {
3+
ActivatedRouteSnapshot,
4+
CanActivateFn,
5+
Router,
6+
RouterStateSnapshot,
7+
UrlTree,
8+
} from '@angular/router';
9+
import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths';
10+
import {
11+
combineLatest,
12+
Observable,
13+
of,
14+
} from 'rxjs';
15+
import {
16+
filter,
17+
map,
18+
switchMap,
19+
take,
20+
} from 'rxjs/operators';
21+
22+
import { AuthService } from '../core/auth/auth.service';
23+
import { BitstreamDataService } from '../core/data/bitstream-data.service';
24+
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
25+
import { FeatureID } from '../core/data/feature-authorization/feature-id';
26+
import { RemoteData } from '../core/data/remote-data';
27+
import { HardRedirectService } from '../core/services/hard-redirect.service';
28+
import { redirectOn4xx } from '../core/shared/authorized.operators';
29+
import {
30+
Bitstream,
31+
BITSTREAM_PAGE_LINKS_TO_FOLLOW,
32+
} from '../core/shared/bitstream.model';
33+
import { FileService } from '../core/shared/file.service';
34+
import { getFirstCompletedRemoteData } from '../core/shared/operators';
35+
import {
36+
hasValue,
37+
isNotEmpty,
38+
} from '../utils/empty.util';
39+
40+
/**
41+
* Guard that handles bitstream download authorization and redirection logic.
42+
* This guard intercepts bitstream download requests and performs the following checks and actions:
43+
*
44+
* 1. **Retrieves the bitstream** by ID from the route parameters
45+
* 2. **Checks authorization** using the CanDownload feature permission
46+
* 3. **Determines authentication status** of the current user
47+
* 4. **Handles different scenarios**:
48+
* - **Authorized + Logged in**: Retrieves a secure download link and redirects to it
49+
* - **Authorized + Not logged in + No access token**: Direct redirect to bitstream content URL
50+
* - **Not authorized + Has access token**: Redirect to content URL with access token appended
51+
* - **Not authorized + Logged in**: Redirect to forbidden page
52+
* - **Not authorized + Not logged in**: Store current URL and redirect to login page
53+
*
54+
* @param route - The activated route snapshot containing the bitstream ID and optional access token
55+
* @param state - The router state snapshot
56+
* @param bitstreamDataService - Service for retrieving bitstream data
57+
* @param authorizationService - Service for checking download authorization
58+
* @param auth - Service for authentication operations
59+
* @param fileService - Service for retrieving secure file download links
60+
* @param hardRedirectService - Service for performing hard redirects to download URLs
61+
* @param router - Angular router for navigation
62+
* @returns Observable that emits a UrlTree for navigation or boolean to allow/prevent route activation
63+
*/
64+
export const bitstreamDownloadRedirectGuard: CanActivateFn = (
65+
route: ActivatedRouteSnapshot,
66+
state: RouterStateSnapshot,
67+
bitstreamDataService: BitstreamDataService = inject(BitstreamDataService),
68+
authorizationService: AuthorizationDataService = inject(AuthorizationDataService),
69+
auth: AuthService = inject(AuthService),
70+
fileService: FileService = inject(FileService),
71+
hardRedirectService: HardRedirectService = inject(HardRedirectService),
72+
router: Router = inject(Router),
73+
): Observable<UrlTree | boolean> => {
74+
75+
const bitstreamId = route.params.id;
76+
const accessToken: string = route.queryParams.accessToken;
77+
78+
return bitstreamDataService.findById(bitstreamId, true, false, ...BITSTREAM_PAGE_LINKS_TO_FOLLOW).pipe(
79+
getFirstCompletedRemoteData(),
80+
redirectOn4xx(router, auth),
81+
switchMap((rd: RemoteData<Bitstream>) => {
82+
if (rd.hasSucceeded && !rd.hasNoContent) {
83+
const bitstream = rd.payload;
84+
const isAuthorized$ = authorizationService.isAuthorized(FeatureID.CanDownload, bitstream.self);
85+
const isLoggedIn$ = auth.isAuthenticated();
86+
return combineLatest([isAuthorized$, isLoggedIn$, of(bitstream)]);
87+
} else {
88+
return of([false, false, null]);
89+
}
90+
}),
91+
filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)),
92+
take(1),
93+
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => {
94+
if (isAuthorized && isLoggedIn) {
95+
return fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
96+
filter((fileLink) => hasValue(fileLink)),
97+
take(1),
98+
map((fileLink) => {
99+
return [isAuthorized, isLoggedIn, bitstream, fileLink];
100+
}));
101+
} else {
102+
return of([isAuthorized, isLoggedIn, bitstream, '']);
103+
}
104+
}),
105+
map(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => {
106+
if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
107+
hardRedirectService.redirect(fileLink, null, true);
108+
return false;
109+
} else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) {
110+
hardRedirectService.redirect(bitstream._links.content.href, null, true);
111+
return false;
112+
} else if (!isAuthorized) {
113+
if (hasValue(accessToken)) {
114+
hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken, null, true);
115+
return false;
116+
} else if (isLoggedIn) {
117+
return router.createUrlTree([getForbiddenRoute()]);
118+
} else if (!isLoggedIn) {
119+
auth.setRedirectUrl(router.url);
120+
return router.createUrlTree(['login']);
121+
}
122+
}
123+
}),
124+
);
125+
};

src/app/bitstream-page/bitstream-page-routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { resourcePolicyResolver } from '../shared/resource-policies/resolvers/re
99
import { resourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver';
1010
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
1111
import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component';
12+
import { bitstreamDownloadRedirectGuard } from './bitstream-download-redirect.guard';
1213
import { bitstreamPageResolver } from './bitstream-page.resolver';
1314
import { bitstreamPageAuthorizationsGuard } from './bitstream-page-authorizations.guard';
1415
import { ThemedEditBitstreamPageComponent } from './edit-bitstream-page/themed-edit-bitstream-page.component';
@@ -40,6 +41,7 @@ export const ROUTES: Route[] = [
4041
resolve: {
4142
bitstream: bitstreamPageResolver,
4243
},
44+
canActivate: [bitstreamDownloadRedirectGuard],
4345
},
4446
{
4547
path: EDIT_BITSTREAM_PATH,

0 commit comments

Comments
 (0)