From 04aefdc0519434f2d103dd2da6b5b225a77d0f3b Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 31 Jul 2026 16:10:18 +0200 Subject: [PATCH] MENDELU/Add "Allow external URLs" toggle to collection Content Source Collection > Edit > Content Source now shows an "Allow external URLs" checkbox when the harvest type is "Harvest metadata and bitstreams", together with a short description of what it does and what the risk is when it is ticked (dataquest-dev/dspace-customers#860). The checkbox is a plain template control rather than a dynamic form control, so it reverts correctly on Discard/Reinstate - the form's patchValue only names the three existing containers. The warning renders whenever the harvest type matches, not only when the box is ticked, because the administrator needs to read it before ticking; the alert styling appears only in the risky state. Requires the matching backend change; the REST field is allow_external_urls. Co-Authored-By: Claude Opus 4.8 --- .../collection-source.component.html | 20 ++++++ .../collection-source.component.spec.ts | 66 +++++++++++++++++++ .../collection-source.component.ts | 17 +++++ src/app/core/shared/content-source.model.ts | 7 ++ src/assets/i18n/cs.json5 | 6 ++ src/assets/i18n/en.json5 | 4 ++ 6 files changed, 120 insertions(+) diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index 7f392b2f027..55f8fd18ad9 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -52,6 +52,26 @@

{{ 'collection.edit.tabs.source.form.head' | translate }}

(cancel)="onCancel()"> } +@if (contentSource && (contentSource?.harvestType === harvestTypeMetadataAndBitstreams)) { +
+
+
+
+ + +
+ +
+ {{ 'collection.edit.tabs.source.allow-external-urls.warning' | translate }} +
+
+
+
+} @if ((contentSource?.harvestType !== harvestTypeNone)) {
diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts index 3e61103ff60..82a43934f45 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts @@ -214,6 +214,72 @@ describe('CollectionSourceComponent', () => { }); }); + describe('when selecting the allow external URLs checkbox', () => { + let input; + + beforeEach(() => { + comp.contentSource.harvestType = ContentSourceHarvestType.MetadataAndBitstreams; + fixture.detectChanges(); + input = fixture.debugElement.query(By.css('#allowExternalUrlsCheck')).nativeElement; + input.click(); + fixture.detectChanges(); + }); + + it('should enable allowExternalUrls', () => { + expect(comp.contentSource.allowExternalUrls).toBeTrue(); + }); + + it('should send a field update', () => { + expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(router.url, comp.contentSource); + }); + }); + + describe('the allow external URLs checkbox and warning', () => { + it('should be hidden when only metadata is harvested', () => { + comp.contentSource.harvestType = ContentSourceHarvestType.Metadata; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#allowExternalUrlsCheck'))).toBeNull(); + expect(fixture.debugElement.query(By.css('#allowExternalUrlsWarning'))).toBeNull(); + }); + + it('should be hidden when only references to bitstreams are harvested', () => { + comp.contentSource.harvestType = ContentSourceHarvestType.MetadataAndRef; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#allowExternalUrlsCheck'))).toBeNull(); + expect(fixture.debugElement.query(By.css('#allowExternalUrlsWarning'))).toBeNull(); + }); + + it('should be shown when bitstreams are harvested', () => { + comp.contentSource.harvestType = ContentSourceHarvestType.MetadataAndBitstreams; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#allowExternalUrlsCheck'))).not.toBeNull(); + expect(fixture.debugElement.query(By.css('#allowExternalUrlsWarning'))).not.toBeNull(); + }); + + it('should describe the checkbox with the warning, so screen readers announce the risk', () => { + comp.contentSource.harvestType = ContentSourceHarvestType.MetadataAndBitstreams; + fixture.detectChanges(); + const checkbox = fixture.debugElement.query(By.css('#allowExternalUrlsCheck')).nativeElement; + expect(checkbox.getAttribute('aria-describedby')).toEqual('allowExternalUrlsWarning'); + }); + + it('should show the text unstyled before the checkbox is ticked', () => { + comp.contentSource.harvestType = ContentSourceHarvestType.MetadataAndBitstreams; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#allowExternalUrlsCheck')).nativeElement.checked).toBeFalse(); + const warning = fixture.debugElement.query(By.css('#allowExternalUrlsWarning')).nativeElement; + expect(warning.classList.contains('alert-warning')).toBeFalse(); + }); + + it('should style the text as a warning once the checkbox is ticked', () => { + comp.contentSource.harvestType = ContentSourceHarvestType.MetadataAndBitstreams; + comp.contentSource.allowExternalUrls = true; + fixture.detectChanges(); + const warning = fixture.debugElement.query(By.css('#allowExternalUrlsWarning')).nativeElement; + expect(warning.classList.contains('alert-warning')).toBeTrue(); + }); + }); + describe('isValid', () => { it('should return true when ContentSource is disabled but the form invalid', () => { spyOnProperty(comp.formGroup, 'valid').and.returnValue(false); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts index fa20b0e5e12..25103fd8c47 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -1,6 +1,7 @@ import { AsyncPipe, Location, + NgClass, } from '@angular/common'; import { Component, @@ -79,6 +80,7 @@ import { CollectionSourceControlsComponent } from './collection-source-controls/ BtnDisabledDirective, CollectionSourceControlsComponent, FormComponent, + NgClass, ThemedLoadingComponent, TranslateModule, ], @@ -254,6 +256,11 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ harvestTypeNone = ContentSourceHarvestType.None; + /** + * The content harvesting type that downloads bitstreams, the only one the external URL flag applies to + */ + harvestTypeMetadataAndBitstreams = ContentSourceHarvestType.MetadataAndBitstreams; + /** * The previously selected harvesting type * Used for switching between ContentSourceHarvestType.None and the previously selected value when enabling / disabling harvesting @@ -465,6 +472,16 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.updateContentSource(false); } + /** + * Switch the allowExternalUrls flag on or off and fire a field update + * Deliberately not a dynamic form control: the patchValue() in initializeOriginalContentSource() only names the + * three form containers, so a control here would not revert on Discard/Reinstate + */ + changeAllowExternalUrls() { + this.contentSource.allowExternalUrls = !this.contentSource.allowExternalUrls; + this.saveFieldUpdate(); + } + /** * Loop over all inputs and update the Content Source with their value * @param updateHarvestType When set to false, the harvestType of the contentSource will be ignored in the update diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts index cfc78f223fc..3434858c53d 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -73,6 +73,13 @@ export class ContentSource extends CacheableObject { @autoserializeAs('harvest_type') harvestType = ContentSourceHarvestType.None; + /** + * Whether bitstreams may be fetched from hosts other than the OAI provider + * Initialised to false on purpose: cerialize omits undefined values, so without it the key would never reach the backend + */ + @autoserializeAs('allow_external_urls') + allowExternalUrls = false; + /** * The available metadata configurations */ diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 0e8b47fda3e..4d70fc4fc83 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -1916,6 +1916,12 @@ // "collection.edit.tabs.roles.title": "Collection Edit - Roles", "collection.edit.tabs.roles.title": "Upravit kolekci - Role", + // "collection.edit.tabs.source.allow-external-urls": "Allow external URLs", + "collection.edit.tabs.source.allow-external-urls": "Povolit externí URL", + + // "collection.edit.tabs.source.allow-external-urls.warning": "When off, files are fetched only from the same scheme and host as the OAI provider above, and a record pointing elsewhere is skipped in full. When on, your server fetches from any address the provider names — that provider, not you, decides what is downloaded and republished here, so enable it only for providers you trust. Private and internal addresses stay blocked either way.", + "collection.edit.tabs.source.allow-external-urls.warning": "Když je vypnuto, stahují se soubory jen ze stejného schématu a hostitele, jaké má adresa poskytovatele OAI výše; záznam odkazující jinam se přeskočí celý. Když je zapnuto, stahuje váš server z libovolné adresy, kterou poskytovatel uvede — o tom, co se zde stáhne a znovu zveřejní, tak rozhoduje on, a ne vy. Zapínejte jen u poskytovatelů, kterým důvěřujete. Privátní a interní adresy zůstávají blokované tak či tak.", + // "collection.edit.tabs.source.external": "This collection harvests its content from an external source", "collection.edit.tabs.source.external": "Tato kolekce získává svůj obsah z externího zdroje", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 2cb600f2731..b3c8c5c1b88 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1277,6 +1277,10 @@ "collection.edit.tabs.roles.title": "Collection Edit - Roles", + "collection.edit.tabs.source.allow-external-urls": "Allow external URLs", + + "collection.edit.tabs.source.allow-external-urls.warning": "When off, files are fetched only from the same scheme and host as the OAI provider above, and a record pointing elsewhere is skipped in full. When on, your server fetches from any address the provider names — that provider, not you, decides what is downloaded and republished here, so enable it only for providers you trust. Private and internal addresses stay blocked either way.", + "collection.edit.tabs.source.external": "This collection harvests its content from an external source", "collection.edit.tabs.source.form.errors.oaiSource.required": "You must provide a set id of the target collection.",