diff --git a/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.html b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.html new file mode 100644 index 00000000000..ae7bad867a7 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.html @@ -0,0 +1,12 @@ +@if ((citaceProStatus$ | async) && (citaceProURL$ | async); as citaceProURL) { +
+ +
+} diff --git a/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.spec.ts new file mode 100644 index 00000000000..cbc22bddc15 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.spec.ts @@ -0,0 +1,108 @@ +import { + ChangeDetectionStrategy, + NO_ERRORS_SCHEMA, + SecurityContext, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + By, + DomSanitizer, +} from '@angular/platform-browser'; + +import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; +import { ItemPageCitationFieldComponent } from './item-page-citation.component'; + +const CITACE_PRO_URL = 'https://www.citacepro.com/api/dspace/citace/oai'; +const CITACE_PRO_UNIVERSITY = 'dspace.jcu.cz'; + +describe('ItemPageCitationFieldComponent', () => { + let component: ItemPageCitationFieldComponent; + let fixture: ComponentFixture; + const mockHandle = '123456789/3'; + + function mockConfigurationDataService(allowed: string) { + const valuesByName: { [name: string]: string[] } = { + 'citace.pro.url': [CITACE_PRO_URL], + 'citace.pro.university': [CITACE_PRO_UNIVERSITY], + 'citace.pro.allowed': [allowed], + }; + return { + findByPropertyName: (name: string) => createSuccessfulRemoteDataObject$( + Object.assign(new ConfigurationProperty(), { name, values: valuesByName[name] ?? [] }), + ), + }; + } + + async function init(allowed: string): Promise { + await TestBed.configureTestingModule({ + imports: [ItemPageCitationFieldComponent], + providers: [ + { provide: ConfigurationDataService, useValue: mockConfigurationDataService(allowed) }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(ItemPageCitationFieldComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(ItemPageCitationFieldComponent); + component = fixture.componentInstance; + component.handle = mockHandle; + fixture.detectChanges(); + } + + describe('when citace.pro.allowed is true', () => { + beforeEach(async () => { + await init('true'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should enable the widget and compose the CitacePRO URL', () => { + expect(component.citaceProStatus$.getValue()).toBeTrue(); + const sanitizer = TestBed.inject(DomSanitizer); + expect(sanitizer.sanitize(SecurityContext.RESOURCE_URL, component.citaceProURL$.getValue())) + .toBe(`${CITACE_PRO_URL}:${CITACE_PRO_UNIVERSITY}:${mockHandle}`); + }); + + it('should render the iframe with a title', () => { + const iframe = fixture.debugElement.query(By.css('iframe')); + expect(iframe).not.toBeNull(); + expect(iframe.nativeElement.getAttribute('title')).toBe('Citace PRO'); + }); + }); + + describe('when citace.pro.allowed is false', () => { + beforeEach(async () => { + await init('false'); + }); + + it('should keep the widget hidden', () => { + expect(component.citaceProStatus$.getValue()).toBeFalse(); + expect(fixture.debugElement.query(By.css('iframe'))).toBeNull(); + }); + }); + + describe('makeCitaceProURL', () => { + beforeEach(async () => { + await init('true'); + }); + + it('should reject a non-http(s) base URL', () => { + expect(component.makeCitaceProURL('javascript:alert(1)//', CITACE_PRO_UNIVERSITY)).toBeNull(); + }); + + it('should reject missing config values', () => { + expect(component.makeCitaceProURL(undefined, CITACE_PRO_UNIVERSITY)).toBeNull(); + expect(component.makeCitaceProURL(CITACE_PRO_URL, undefined)).toBeNull(); + }); + }); +}); diff --git a/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.ts b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.ts new file mode 100644 index 00000000000..8dcb7c2cdf7 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.ts @@ -0,0 +1,85 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { + DomSanitizer, + SafeResourceUrl, +} from '@angular/platform-browser'; +import { + BehaviorSubject, + combineLatest, + of, +} from 'rxjs'; +import { + catchError, + take, +} from 'rxjs/operators'; + +import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; +import { getFirstCompletedRemoteData } from '../../../../../core/shared/operators'; + +@Component({ + selector: 'ds-item-page-citation-field', + templateUrl: './item-page-citation.component.html', + imports: [ + CommonModule, + ], +}) +export class ItemPageCitationFieldComponent implements OnInit { + @Input() handle: string; + + citaceProStatus$ = new BehaviorSubject(false); + citaceProURL$ = new BehaviorSubject(null); + + constructor( + private sanitizer: DomSanitizer, + private configService: ConfigurationDataService, + ) {} + + + ngOnInit() { + const citaceProUrl$ = this.configService.findByPropertyName('citace.pro.url').pipe( + getFirstCompletedRemoteData(), + catchError(() => of(null)), + ); + const universityUsingDspace$ = this.configService.findByPropertyName('citace.pro.university').pipe( + getFirstCompletedRemoteData(), + catchError(() => of(null)), + ); + const citaceProAllowed$ = this.configService.findByPropertyName('citace.pro.allowed').pipe( + getFirstCompletedRemoteData(), + catchError(() => of(null)), + ); + + combineLatest([citaceProUrl$, universityUsingDspace$, citaceProAllowed$]).pipe( + take(1), + ).subscribe(([citaceProUrlData, universityData, citaceProAllowedData]) => { + const citaceProBaseUrl = citaceProUrlData?.payload?.values?.[0]; + const universityUsingDspace = universityData?.payload?.values?.[0]; + this.citaceProURL$.next(this.makeCitaceProURL(citaceProBaseUrl, universityUsingDspace)); + + const citaceProAllowed = citaceProAllowedData?.payload?.values?.[0]; + this.citaceProStatus$.next(citaceProAllowed === 'true'); + }); + } + + + makeCitaceProURL( + citaceProBaseUrl: string, + universityUsingDspace: string, + ): SafeResourceUrl | null { + if (!citaceProBaseUrl || !universityUsingDspace || !this.handle) { + return null; + } + // Only http(s) bases may bypass Angular's resource-URL sanitization; + // anything else (javascript:, data:, ...) must not be trusted as iframe src. + if (!/^https?:\/\//i.test(citaceProBaseUrl)) { + return null; + } + const url = `${citaceProBaseUrl}:${universityUsingDspace}:${this.handle}`; + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + } +} diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index a294cc025bc..4ad2eb72584 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -70,6 +70,7 @@ [relationType]="'isJournalIssueOfPublication'" [label]="'item.page.journal-issue' | translate"> + { { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: mockRouteService }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: ConfigurationDataService, useValue: new ConfigurationDataServiceStub() }, { provide: APP_CONFIG, useValue: environment }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ], diff --git a/src/app/item-page/simple/item-types/publication/publication.component.ts b/src/app/item-page/simple/item-types/publication/publication.component.ts index 282198fe7de..85a82936190 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.ts @@ -17,6 +17,7 @@ import { ThemedMediaViewerComponent } from '../../../media-viewer/themed-media-v import { MiradorViewerComponent } from '../../../mirador-viewer/mirador-viewer.component'; import { ThemedFileSectionComponent } from '../../field-components/file-section/themed-file-section.component'; import { ItemPageAbstractFieldComponent } from '../../field-components/specific-field/abstract/item-page-abstract-field.component'; +import { ItemPageCitationFieldComponent } from '../../field-components/specific-field/citation/item-page-citation.component'; import { ItemPageDateFieldComponent } from '../../field-components/specific-field/date/item-page-date-field.component'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; import { GeospatialItemPageFieldComponent } from '../../field-components/specific-field/geospatial/geospatial-item-page-field.component'; @@ -43,6 +44,7 @@ import { ItemComponent } from '../shared/item.component'; GenericItemPageFieldComponent, GeospatialItemPageFieldComponent, ItemPageAbstractFieldComponent, + ItemPageCitationFieldComponent, ItemPageDateFieldComponent, ItemPageUriFieldComponent, MetadataFieldWrapperComponent, diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 52ae5556dce..4d5ba91b89a 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -56,6 +56,7 @@
+