diff --git a/config/config.example.yml b/config/config.example.yml index 8b56711c7d2..fc3ec3ef2bb 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -467,3 +467,18 @@ accessibility: matomo: hostUrl: http://localhost:8135/ siteId: 1 + +# Values substituted for `${key}` placeholders in static pages served from `static-files` +# (e.g. `${dspace.name}` in terms-of-service.html). Values are plain text; HTML is escaped on insert. +# A missing or empty value leaves the `${key}` placeholder as is and logs a warning. +# Placeholders are substituted in text content only. Do not use them inside +# href, src, or other attribute values. +# You can override a single key; the other defaults are kept. +# Keys contain dots, so set them in this YAML file rather than through environment variables. +#staticPage: +# variables: +# dspace.name: LINDAT/CLARIAH-CZ Repository +# lr.description.institution: Institute of Formal and Applied Linguistics +# lr.description.location: Malostranské náměstí 25, 118 00 Praha, Czech Republic +# lr.description.city: Prague +# lr.description.country: Czech Republic diff --git a/src/app/static-page/static-page.component.spec.ts b/src/app/static-page/static-page.component.spec.ts index f62bf4a1756..b5ba6e8d443 100644 --- a/src/app/static-page/static-page.component.spec.ts +++ b/src/app/static-page/static-page.component.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { StaticPageComponent } from './static-page.component'; import { HtmlContentService } from '../shared/html-content.service'; @@ -10,6 +10,7 @@ import { APP_CONFIG } from '../../config/app-config.interface'; import { environment } from '../../environments/environment'; import { ClarinSafeHtmlPipe } from '../shared/utils/clarin-safehtml.pipe'; import { ServerResponseService } from '../core/services/server-response.service'; +import { StaticPageConfig } from '../../config/static-page-config.interface'; describe('StaticPageComponent', () => { function createDeferred() { @@ -26,7 +27,8 @@ describe('StaticPageComponent', () => { html: string | undefined, restBase?: string, contentPromise?: Promise, - route: string = '/static/test-file.html' + route: string = '/static/test-file.html', + staticPage?: StaticPageConfig ) { const htmlContentService = jasmine.createSpyObj('htmlContentService', { fetchHtmlContent: of(html), @@ -49,7 +51,8 @@ describe('StaticPageComponent', () => { rest: { ...(environment as any).rest, baseUrl: restBase - } + }, + staticPage }; await TestBed.configureTestingModule({ @@ -167,6 +170,147 @@ describe('StaticPageComponent', () => { expect(component.htmlContent.value).toBe(otherHtml); }); + describe('variable interpolation', () => { + const allPlaceholdersHtml = '

Welcome to ${dspace.name}, provided by ${lr.description.institution}, ' + + 'located at ${lr.description.location}.

Laws of ${lr.description.country}, court of ${lr.description.city}.

'; + const allVariables = { + 'dspace.name': 'Test Repository', + 'lr.description.institution': 'Test Institute', + 'lr.description.location': 'Test Street 1, 100 00 Test City', + 'lr.description.city': 'Test City', + 'lr.description.country': 'Test Country', + }; + + function setupInterpolationTest(html: string, variables?: { [key: string]: string }, restBase?: string) { + const staticPage = variables === undefined ? undefined : { variables }; + return setupTest(html, restBase, undefined, '/static/test-file.html', staticPage); + } + + async function render(fixture: ComponentFixture) { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + let warnSpy: jasmine.Spy; + + beforeEach(() => { + warnSpy = spyOn(console, 'warn'); + }); + + it('should replace all configured placeholders', async () => { + const { fixture, component } = await setupInterpolationTest(allPlaceholdersHtml, allVariables); + await render(fixture); + + const content = component.htmlContent.value; + Object.values(allVariables).forEach((value) => expect(content).toContain(value)); + expect(content).not.toContain('${'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should leave an unknown placeholder literal and warn once', async () => { + const { fixture, component } = await setupInterpolationTest('

${dspace.name} ${foo.bar}

', allVariables); + await render(fixture); + + expect(component.htmlContent.value).toBe('

Test Repository ${foo.bar}

'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith('Static page: unresolved variables', ['foo.bar']); + }); + + it('should treat an empty string value as missing', async () => { + const { fixture, component } = await setupInterpolationTest('

${dspace.name}

', { ...allVariables, 'dspace.name': '' }); + await render(fixture); + + expect(component.htmlContent.value).toBe('

${dspace.name}

'); + expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['dspace.name']); + }); + + it('should treat a null value as missing', async () => { + // A YAML key without a value (`dspace.name:`) is loaded as null + const { fixture, component } = await setupInterpolationTest('

${dspace.name}

', { ...allVariables, 'dspace.name': null }); + await render(fixture); + + expect(component.htmlContent.value).toBe('

${dspace.name}

'); + expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['dspace.name']); + }); + + it('should treat a non-string value as missing', async () => { + // YAML can load a number, e.g. `dspace.name: 123` + const { fixture, component } = await setupInterpolationTest('

${dspace.name}

', { ...allVariables, 'dspace.name': 123 } as any); + await render(fixture); + + expect(component.htmlContent.value).toBe('

${dspace.name}

'); + expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['dspace.name']); + }); + + it('should HTML-escape values', async () => { + const { fixture, component } = await setupInterpolationTest('

${dspace.name}

', { 'dspace.name': 'A & B' }); + await render(fixture); + + expect(component.htmlContent.value).toBe('

A & <b>B</b>

'); + const target = fixture.nativeElement.querySelector('#target'); + expect(target.querySelector('b')).toBeNull(); + expect(target.textContent).toBe('A & B'); + }); + + it('should insert values containing replacement patterns literally', async () => { + const { fixture, component } = await setupInterpolationTest( + '

${dspace.name}|${lr.description.city}

', + { 'dspace.name': 'a$&b', 'lr.description.city': 'c$1$`$\'d' } + ); + await render(fixture); + + expect(fixture.nativeElement.querySelector('#target').textContent).toBe('a$&b|c$1$`$\'d'); + expect(component.htmlContent.value).not.toContain('${'); + }); + + it('should leave content unchanged when staticPage config is missing', async () => { + const { fixture, component } = await setupInterpolationTest(allPlaceholdersHtml, undefined); + await render(fixture); + + expect(component.contentState).toBe('found'); + expect(component.htmlContent.value).toBe(allPlaceholdersHtml); + }); + + it('should leave all placeholders literal and warn once when the variables map is empty', async () => { + const { fixture, component } = await setupInterpolationTest(allPlaceholdersHtml, {}); + await render(fixture); + + expect(component.htmlContent.value).toBe(allPlaceholdersHtml); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.calls.mostRecent().args[1]).toEqual(jasmine.arrayWithExactContents(Object.keys(allVariables))); + }); + + it('should not touch other placeholder syntaxes', async () => { + const html = '

$dspace.name {{\'x\' | translate}} $ {dspace.name}

'; + const { fixture, component } = await setupInterpolationTest(html, allVariables); + await render(fixture); + + expect(component.htmlContent.value).toBe(html); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should apply both the OAI link rewrite and variable interpolation', async () => { + const { fixture, component } = await setupInterpolationTest( + '${dspace.name}', + allVariables, + 'https://api.example.org/repository/server' + ); + await render(fixture); + + expect(component.htmlContent.value) + .toBe('Test Repository'); + }); + + it('should not resolve inherited object properties', async () => { + const { fixture, component } = await setupInterpolationTest('

${toString} ${constructor}

', allVariables); + await render(fixture); + + expect(component.htmlContent.value).toBe('

${toString} ${constructor}

'); + expect(warnSpy).toHaveBeenCalledOnceWith('Static page: unresolved variables', ['toString', 'constructor']); + }); + }); + describe('contentState behavior', () => { it('should initialize contentState to "loading"', async () => { const { component } = await setupTest('
test
'); diff --git a/src/app/static-page/static-page.component.ts b/src/app/static-page/static-page.component.ts index 6ca77bf955c..bff081043b3 100644 --- a/src/app/static-page/static-page.component.ts +++ b/src/app/static-page/static-page.component.ts @@ -6,6 +6,7 @@ import { isEmpty } from '../shared/empty.util'; import { STATIC_PAGE_PATH } from './static-page-routing-paths'; import { APP_CONFIG, AppConfig } from '../../config/app-config.interface'; import { ServerResponseService } from '../core/services/server-response.service'; +import escapeHtml from 'lodash/escape'; /** * Component which load and show static files from the `static-files` folder. @@ -40,6 +41,7 @@ export class StaticPageComponent implements OnInit { const restBase = this.appConfig?.rest?.baseUrl; const oaiUrl = restBase ? restBase.replace(/\/+$/, '') + '/oai' : '/server/oai'; htmlContent = htmlContent.replace(/href="\/server\/oai/gi, 'href="' + oaiUrl); + htmlContent = this.interpolateVariables(htmlContent); this.htmlContent.next(htmlContent); this.contentState = 'found'; @@ -85,6 +87,31 @@ export class StaticPageComponent implements OnInit { this.redirectToRelativeLink(staticPageBaseUrl, href); } + /** + * Replace `${key}` placeholders with values from `staticPage.variables` in the app config. + * Values are HTML-escaped. Missing, null, non-string, or empty values leave the placeholder unchanged. + * @param html static page content + * @private + */ + private interpolateVariables(html: string): string { + const vars = this.appConfig?.staticPage?.variables ?? {}; + const missing = new Set(); + // Function replacer: `$&`, `$'` etc. in values are inserted literally, not as replacement patterns. + const out = html.replace(/\$\{([\w.-]+)\}/g, (match: string, key: string) => { + // Own-property check, so `${toString}` does not resolve to an inherited member. + const value = Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : undefined; + if (typeof value !== 'string' || value.trim() === '') { + missing.add(key); + return match; + } + return escapeHtml(value); + }); + if (missing.size > 0) { + console.warn('Static page: unresolved variables', [...missing]); + } + return out; + } + private getNamespacePrefix(): string { const nameSpace = this.appConfig?.ui?.nameSpace ?? '/'; return nameSpace === '/' ? '' : nameSpace.replace(/\/$/, ''); diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 96a6f91f8ec..9c104898be1 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -27,6 +27,7 @@ import { SearchConfig } from './search-page-config.interface'; import { AccessibilitySettingsConfig } from '../app/accessibility/accessibility-settings.config'; import { MatomoConfig } from './matomo-config'; import { StatisticsConfig } from './statistics-config'; +import { StaticPageConfig } from './static-page-config.interface'; interface AppConfig extends Config { ui: UIServerConfig; @@ -59,6 +60,7 @@ interface AppConfig extends Config { signpostingEnabled: boolean; matomo: MatomoConfig; statistics?: StatisticsConfig; + staticPage?: StaticPageConfig; } /** diff --git a/src/config/config.server.ts b/src/config/config.server.ts index 848f5fa4622..aff8ff108dd 100644 --- a/src/config/config.server.ts +++ b/src/config/config.server.ts @@ -287,6 +287,8 @@ export const buildAppConfig = (destConfigPath?: string): AppConfig => { search: appConfig.search, accessibility: appConfig.accessibility, signpostingEnabled: appConfig.signpostingEnabled, + // Static page placeholder values (e.g. terms-of-service.html), needed for client-side rendering + staticPage: appConfig.staticPage, // Matomo analytics - only expose client-side tracking properties ...(appConfig.matomo && { matomo: { diff --git a/src/config/config.util.spec.ts b/src/config/config.util.spec.ts index 4dc2b67260e..d3fc149e274 100644 --- a/src/config/config.util.spec.ts +++ b/src/config/config.util.spec.ts @@ -1,5 +1,5 @@ import { environment } from '../environments/environment.production'; -import { extendEnvironmentWithAppConfig } from './config.util'; +import { extendEnvironmentWithAppConfig, mergeConfig } from './config.util'; import { DefaultAppConfig } from './default-app-config'; import { HandleThemeConfig } from './theme.config'; @@ -57,4 +57,19 @@ describe('Config Util', () => { expect((environment.themes[1] as HandleThemeConfig).handle).toEqual(customTheme.handle); }); }); + + describe('mergeConfig', () => { + it('should override a single static page variable and keep the other defaults', () => { + const appConfig = new DefaultAppConfig(); + const defaults = { ...appConfig.staticPage.variables }; + + mergeConfig(appConfig, { staticPage: { variables: { 'dspace.name': 'X' } } } as any); + + expect(appConfig.staticPage.variables['dspace.name']).toEqual('X'); + Object.keys(defaults) + .filter((key) => key !== 'dspace.name') + .forEach((key) => expect(appConfig.staticPage.variables[key]).toEqual(defaults[key])); + expect(Object.keys(appConfig.staticPage.variables).length).toEqual(5); + }); + }); }); diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 38cb942e457..87f36cffb55 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -26,6 +26,7 @@ import { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; import { SearchConfig } from './search-page-config.interface'; import { AccessibilitySettingsConfig } from '../app/accessibility/accessibility-settings.config'; import { MatomoConfig } from './matomo-config'; +import { StaticPageConfig } from './static-page-config.interface'; export class DefaultAppConfig implements AppConfig { production = false; @@ -456,4 +457,16 @@ export class DefaultAppConfig implements AppConfig { siteId: '1', dimensionId: 1 }; + + // Values substituted for `${key}` placeholders in static pages (e.g. terms-of-service.html). + // Plain text; HTML is escaped on insert. A missing or empty value leaves the placeholder as is. + staticPage: StaticPageConfig = { + variables: { + 'dspace.name': 'LINDAT/CLARIAH-CZ Repository', + 'lr.description.institution': 'Institute of Formal and Applied Linguistics', + 'lr.description.location': 'Malostranské náměstí 25, 118 00 Praha, Czech Republic', + 'lr.description.city': 'Prague', + 'lr.description.country': 'Czech Republic', + } + }; } diff --git a/src/config/static-page-config.interface.ts b/src/config/static-page-config.interface.ts new file mode 100644 index 00000000000..3cca4c6179d --- /dev/null +++ b/src/config/static-page-config.interface.ts @@ -0,0 +1,13 @@ +import { Config } from './config.interface'; + +/** + * Configuration for static pages served from the `static-files` folder. + */ +export class StaticPageConfig implements Config { + /** + * Values substituted for `${key}` placeholders in static page HTML, e.g. `${dspace.name}`. + * Values are plain text; they are HTML-escaped on insert. + * Placeholders are for text content only, not for URL or attribute values. + */ + public variables: { [key: string]: string } = {}; +}