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
22 changes: 22 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

@tdonohue tdonohue Mar 26, 2026

Choose a reason for hiding this comment

The 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:

layout:
  showDownloadLinkAsAttachment: true
  metadata:
    - name: dc.title
      type: metadata
      truncatable: false
    - name: format
      type: attribute

I'd expect to then no longer see the checksum and size in the display. However, I still see the same display as before.

Image

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:
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']);
});
});
}));
});
});
});
125 changes: 125 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,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 = (
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
Loading
Loading