Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,18 @@ 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: true
# Configuration for advanced attachment rendering in item pages. This controls how files are displayed when showDownloadLinkAsAttachment is enabled.
# Each configuration maps a bundle name to specific metadata fields that should be displayed alongside the file.
advancedAttachmentRendering:
- bundle: ORIGINAL
metadata:
- dc.description
- dc.title
- bundle: LICENSE
metadata:
- dc.rights

# Configuration for customization of search results
searchResults:
Expand Down
223 changes: 223 additions & 0 deletions src/app/bitstream-page/bitstream-download-redirect.guard.spec.ts
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']);
});
});
}));
});
});
});
102 changes: 102 additions & 0 deletions src/app/bitstream-page/bitstream-download-redirect.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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';


export const bitstreamDownloadRedirectGuard: CanActivateFn = (
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']);
}
}
}),
);
};
2 changes: 2 additions & 0 deletions src/app/bitstream-page/bitstream-page-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { resourcePolicyResolver } from '../shared/resource-policies/resolvers/re
import { resourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component';
import { bitstreamDownloadRedirectGuard } from './bitstream-download-redirect.guard';
import { bitstreamPageResolver } from './bitstream-page.resolver';
import { bitstreamPageAuthorizationsGuard } from './bitstream-page-authorizations.guard';
import { ThemedEditBitstreamPageComponent } from './edit-bitstream-page/themed-edit-bitstream-page.component';
Expand Down Expand Up @@ -40,6 +41,7 @@ export const ROUTES: Route[] = [
resolve: {
bitstream: bitstreamPageResolver,
},
canActivate: [bitstreamDownloadRedirectGuard],
},
{
path: EDIT_BITSTREAM_PATH,
Expand Down
4 changes: 3 additions & 1 deletion src/app/core/services/hard-redirect.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export abstract class HardRedirectService {
* the page to redirect to
* @param statusCode
* optional HTTP status code to use for redirect (default = 302, which is a temporary redirect)
* @param shouldSetCorsHeader
* optional to prevent CORS error on redirect
*/
abstract redirect(url: string, statusCode?: number);
abstract redirect(url: string, statusCode?: number, shouldSetCorsHeader?: boolean);

/**
* Get the current route, with query params included
Expand Down
Loading
Loading