diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 924a5eaf2e3..a7bc970c595 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -43,6 +43,12 @@ jobs: - name: Checkout codebase uses: actions/checkout@v3 + # Generate deployed-version info (git hash, commit date, build run) into a static + # file that the UI serves at `/static/VERSION_D` (issue #813). Must run before the + # Docker build so the file is included in the build context. + - name: Add version + run: python scripts/sourceversion.py ${{ github.server_url }}/${{ github.repository }}/actions/runs/ ${{ github.run_id }} > src/static-files/VERSION_D.html + # https://github.com/docker/setup-buildx-action - name: Setup Docker Buildx uses: docker/setup-buildx-action@v2 diff --git a/angular.json b/angular.json index 386644ac725..9adf078e539 100644 --- a/angular.json +++ b/angular.json @@ -41,6 +41,11 @@ "aot": true, "assets": [ "src/assets", + { + "glob": "VERSION_D.html", + "input": "src/static-files", + "output": "static-files" + }, "src/robots.txt" ], "styles": [ diff --git a/scripts/sourceversion.py b/scripts/sourceversion.py new file mode 100644 index 00000000000..c6d50504613 --- /dev/null +++ b/scripts/sourceversion.py @@ -0,0 +1,38 @@ +import subprocess +import sys +from datetime import datetime, timezone + +# when next editing this script, please introduce argparse. +# do not forget, it is called in BE by .github\workflows\reusable-docker-build.yml +# argparse must be introduced there. +# that action also calls BE version of this script, which is different (BE: scripts/sourceversion.py). +# It must also cooperate with argparse + +# the idea is, that this will be different on each branch, but could be possibly passed by argv/argparse +RELEASE_TAG_BASE='none' + +def get_time_in_timezone(zone: str = "Europe/Bratislava"): + try: + from zoneinfo import ZoneInfo + my_tz = ZoneInfo(zone) + except Exception as e: + my_tz = timezone.utc + return datetime.now(my_tz) + + +if __name__ == '__main__': + ts = get_time_in_timezone() + # we have html tags, since this script ends up creating VERSION_D.html + print(f"

This info was generated on:
{ts.strftime('%Y-%m-%d %H:%M:%S %Z%z')}

") + + cmd = 'git log -1 --pretty=format:"

Git hash:
%H
Date of commit:
%ai

"' + subprocess.check_call(cmd, shell=True) + + # when adding argparse, this should be a bit more obvious + link = sys.argv[1] + sys.argv[2] + print('

Build run:

' + link + ' ') + + link = "https://github.com/dataquest-dev/dspace-angular/releases/tag/" \ + + RELEASE_TAG_BASE + "-" + datetime.now().strftime('%Y.%m.') + sys.argv[2] + + print('

Release link:

' + link + ' (if it does not work, then this is not an official release instance) ') diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 34c6bc0efcd..d80b6374431 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -43,6 +43,7 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard'; import { MenuResolver } from './menu.resolver'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; import { HANDLE_TABLE_MODULE_PATH } from './handle-page/handle-page-routing-paths'; +import { STATIC_PAGE_PATH } from './static-page/static-page-routing-paths'; @NgModule({ imports: [ @@ -260,6 +261,10 @@ import { HANDLE_TABLE_MODULE_PATH } from './handle-page/handle-page-routing-path loadChildren: () => import('./handle-page/handle-page.module').then((m) => m.HandlePageModule), canActivate: [SiteAdministratorGuard], }, + { + path: STATIC_PAGE_PATH, + loadChildren: () => import('./static-page/static-page.module').then((m) => m.StaticPageModule), + }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent } ] } diff --git a/src/app/shared/html-content.service.spec.ts b/src/app/shared/html-content.service.spec.ts new file mode 100644 index 00000000000..dbc04dcf4e4 --- /dev/null +++ b/src/app/shared/html-content.service.spec.ts @@ -0,0 +1,126 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { firstValueFrom } from 'rxjs'; + +import { HtmlContentService } from './html-content.service'; +import { LocaleService } from '../core/locale/locale.service'; +import { APP_CONFIG } from '../../config/app-config.interface'; + +class LocaleServiceStub { + languageCode = 'en'; + + getCurrentLanguageCode(): string { + return this.languageCode; + } +} + +describe('HtmlContentService', () => { + let service: HtmlContentService; + let httpMock: HttpTestingController; + let localeService: LocaleServiceStub; + + function setup(nameSpace: string): void { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + HtmlContentService, + { provide: LocaleService, useClass: LocaleServiceStub }, + { + provide: APP_CONFIG, + useValue: { + ui: { nameSpace }, + }, + }, + ], + }); + + service = TestBed.inject(HtmlContentService); + httpMock = TestBed.inject(HttpTestingController); + localeService = TestBed.inject(LocaleService) as any; + } + + afterEach(() => { + if (httpMock) { + httpMock.verify(); + } + }); + + it('should request root namespaced URL for default locale', async () => { + setup('/'); + localeService.languageCode = 'en'; + + const promise = service.getHmtlContentByPathAndLocale('license-ud-1.0'); + + const request = httpMock.expectOne('/static-files/license-ud-1.0.html'); + expect(request.request.method).toBe('GET'); + request.flush('Universal Dependencies 1.0 License Set'); + + const content = await promise; + expect(content).toBe('Universal Dependencies 1.0 License Set'); + }); + + it('should request locale-specific namespaced URL for non-default locale', async () => { + setup('/repository'); + localeService.languageCode = 'cs'; + + const promise = service.getHmtlContentByPathAndLocale('license-ud-1.0'); + + const request = httpMock.expectOne('/repository/static-files/cs/license-ud-1.0.html'); + expect(request.request.method).toBe('GET'); + request.flush('Localized content'); + + const content = await promise; + expect(content).toBe('Localized content'); + }); + + it('should fallback from locale-specific to default namespaced URL when localized content is missing', fakeAsync(() => { + setup('/repository/'); + localeService.languageCode = 'cs'; + + let content: string | undefined; + service.getHmtlContentByPathAndLocale('license-ud-1.0').then((result) => { + content = result; + }); + + const localizedRequest = httpMock.expectOne('/repository/static-files/cs/license-ud-1.0.html'); + localizedRequest.flush('Not Found', { status: 404, statusText: 'Not Found' }); + tick(); + + const fallbackRequest = httpMock.expectOne('/repository/static-files/license-ud-1.0.html'); + fallbackRequest.flush('Fallback content'); + tick(); + + expect(content).toBe('Fallback content'); + })); + + it('should fallback from locale-specific to default URL when locale returns 404', fakeAsync(() => { + setup('/'); + localeService.languageCode = 'cs'; + + let content: string | undefined; + service.getHmtlContentByPathAndLocale('license').then((result) => { + content = result; + }); + + httpMock.expectOne('/static-files/cs/license.html') + .flush('Not Found', { status: 404, statusText: 'Not Found' }); + tick(); + + httpMock.expectOne('/static-files/license.html').flush('
English Content
'); + tick(); + + expect(content).toBe('
English Content
'); + })); + + it('should return empty string from getHtmlContent when request fails', async () => { + setup('/repository'); + + const contentPromise = firstValueFrom(service.getHtmlContent('static-files/missing-page.html')); + + const request = httpMock.expectOne('/repository/static-files/missing-page.html'); + request.flush('Not Found', { status: 404, statusText: 'Not Found' }); + + const content = await contentPromise; + expect(content).toBe(''); + }); +}); diff --git a/src/app/shared/html-content.service.ts b/src/app/shared/html-content.service.ts new file mode 100644 index 00000000000..664eaf71e22 --- /dev/null +++ b/src/app/shared/html-content.service.ts @@ -0,0 +1,124 @@ +import { isPlatformServer } from '@angular/common'; +import { Inject, Injectable, Optional, PLATFORM_ID } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { catchError } from 'rxjs/operators'; +import { firstValueFrom, of as observableOf } from 'rxjs'; +import { HTML_SUFFIX, STATIC_FILES_PROJECT_PATH } from '../static-page/static-page-routing-paths'; +import { isEmpty } from './empty.util'; +import { LocaleService } from '../core/locale/locale.service'; +import { APP_CONFIG, AppConfig } from '../../config/app-config.interface'; +import { REQUEST } from '@nguniversal/express-engine/tokens'; + +/** + * Service for loading static `.html` files stored in the `/static-files` folder. + */ +@Injectable() +export class HtmlContentService { + constructor(private http: HttpClient, + private localeService: LocaleService, + @Inject(APP_CONFIG) protected appConfig?: AppConfig, + @Inject(PLATFORM_ID) private platformId?: object, + @Optional() @Inject(REQUEST) private request?: any, + ) {} + + private getNamespacePrefix(): string { + const nameSpace = this.appConfig?.ui?.nameSpace ?? '/'; + if (nameSpace === '/') { + return ''; + } + return nameSpace.endsWith('/') ? nameSpace.slice(0, -1) : nameSpace; + } + + private composeNamespacedUrl(url: string): string { + if (/^https?:\/\//i.test(url)) { + return url; + } + + const normalizedPath = url.startsWith('/') ? url : `/${url}`; + const namespacePrefix = this.getNamespacePrefix(); + + if (namespacePrefix && normalizedPath.startsWith(`${namespacePrefix}/`)) { + return normalizedPath; + } + + return `${namespacePrefix}${normalizedPath}`; + } + + private buildRuntimeUrl(path: string): string { + if (!isPlatformServer(this.platformId) || !this.request) { + return path; + } + + const protocol = this.request.protocol; + const host = this.request.get?.('host'); + if (!protocol || !host) { + return path; + } + + return `${protocol}://${host}${path}`; + } + + getHtmlContent(url: string) { + const namespacedUrl = this.composeNamespacedUrl(url); + const runtimeUrl = this.buildRuntimeUrl(namespacedUrl); + return this.http.get(runtimeUrl, { responseType: 'text' }).pipe( + catchError(() => observableOf(''))); + } + + /** + * Load `.html` file content and return the full response. + * @param url file location + */ + fetchHtmlContent(url: string) { + const namespacedUrl = this.composeNamespacedUrl(url); + const runtimeUrl = this.buildRuntimeUrl(namespacedUrl); + return this.http.get(runtimeUrl, { responseType: 'text', observe: 'response' }).pipe( + catchError((error) => observableOf(new HttpResponse({ status: error.status || 0, body: '' })))); + } + + /** + * Load HTML content for a single URL attempt and handle cached 304 responses. + * @param url file location + */ + private async loadHtmlContent(url: string): Promise { + const response = await firstValueFrom(this.fetchHtmlContent(url)); + if (response.status === 200) { + return response.body ?? ''; + } + if (response.status === 304) { + return response.body ?? ''; + } + return undefined; + } + + /** + * Get the html file content as a string by the file name and the current locale. + */ + async getHmtlContentByPathAndLocale(fileName: string) { + let url = ''; + // Get current language + let language = this.localeService.getCurrentLanguageCode(); + // If language is default = `en` do not load static files from translated package e.g. `cs`. + language = language === 'en' ? '' : language; + + // Try to find the html file in the translated package. `static-files/language_code/some_file.html` + // Compose url + url = STATIC_FILES_PROJECT_PATH; + url += isEmpty(language) ? '/' + fileName : '/' + language + '/' + fileName; + // Add `.html` suffix to get the current html file + url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX; + let potentialContent = await this.loadHtmlContent(url); + if (potentialContent !== undefined) { + return potentialContent; + } + + // If the file wasn't find, get the non-translated file from the default package. + url = STATIC_FILES_PROJECT_PATH + '/' + fileName; + // Add `.html` suffix to match localized request behavior + url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX; + potentialContent = await this.loadHtmlContent(url); + if (potentialContent !== undefined) { + return potentialContent; + } + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index cecf0cec284..10d40d47da3 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -250,6 +250,7 @@ import { ItemPageTitleFieldComponent } from '../item-page/simple/field-components/specific-field/title/item-page-title-field.component'; import { MarkdownPipe } from './utils/markdown.pipe'; +import { ClarinSafeHtmlPipe } from './utils/clarin-safehtml.pipe'; import { GoogleRecaptchaModule } from '../core/google-recaptcha/google-recaptcha.module'; import { MenuModule } from './menu/menu.module'; import { @@ -318,7 +319,8 @@ const PIPES = [ ClarinLicenseCheckedPipe, ClarinLicenseLabelRadioValuePipe, ClarinLicenseRequiredInfoPipe, - CharToEndPipe + CharToEndPipe, + ClarinSafeHtmlPipe ]; const COMPONENTS = [ diff --git a/src/app/shared/utils/clarin-safehtml.pipe.ts b/src/app/shared/utils/clarin-safehtml.pipe.ts new file mode 100644 index 00000000000..ebf2e21c8da --- /dev/null +++ b/src/app/shared/utils/clarin-safehtml.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +/** + * Pipe to keep html tags e.g., `id` in the `innerHTML` attribute. + */ +@Pipe({ + name: 'dsSafeHtml' +}) +export class ClarinSafeHtmlPipe implements PipeTransform { + constructor(private sanitized: DomSanitizer) {} + transform(htmlString: string): SafeHtml { + return this.sanitized.bypassSecurityTrustHtml(htmlString); + } +} diff --git a/src/app/static-page/static-page-routing-paths.ts b/src/app/static-page/static-page-routing-paths.ts new file mode 100644 index 00000000000..e6fd63cf4d2 --- /dev/null +++ b/src/app/static-page/static-page-routing-paths.ts @@ -0,0 +1,7 @@ +/** + * Constants for `/static` route. + */ +export const STATIC_PAGE_PATH = 'static'; +export const STATIC_FILES_PROJECT_PATH = 'static-files'; +export const HTML_SUFFIX = '.html'; +export const STATIC_FILES_DEFAULT_ERROR_PAGE_PATH = STATIC_FILES_PROJECT_PATH + '/' + 'error.html'; diff --git a/src/app/static-page/static-page-routing.module.ts b/src/app/static-page/static-page-routing.module.ts new file mode 100644 index 00000000000..93990bfa078 --- /dev/null +++ b/src/app/static-page/static-page-routing.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { StaticPageComponent } from './static-page.component'; + +const routes: Routes = [ + { + path: '', + children: [ + { path: '', component: StaticPageComponent }, + { path: ':htmlFileName', component: StaticPageComponent }, + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class StaticPageRoutingModule { } diff --git a/src/app/static-page/static-page.component.html b/src/app/static-page/static-page.component.html new file mode 100644 index 00000000000..372c6496f41 --- /dev/null +++ b/src/app/static-page/static-page.component.html @@ -0,0 +1,20 @@ +
+ +
+ + +
+
+
+ + +
+

404

+

{{"static-page.404.page-not-found" | translate}}

+
+

{{"static-page.404.help" | translate}}

+
+

+ {{"static-page.404.link.home-page" | translate}} +

+
diff --git a/src/app/static-page/static-page.component.scss b/src/app/static-page/static-page.component.scss new file mode 100644 index 00000000000..fe7baec6e4c --- /dev/null +++ b/src/app/static-page/static-page.component.scss @@ -0,0 +1,3 @@ +/** +File for styling the `static-page` component. + */ diff --git a/src/app/static-page/static-page.component.spec.ts b/src/app/static-page/static-page.component.spec.ts new file mode 100644 index 00000000000..f62bf4a1756 --- /dev/null +++ b/src/app/static-page/static-page.component.spec.ts @@ -0,0 +1,295 @@ +import { TestBed } from '@angular/core/testing'; + +import { StaticPageComponent } from './static-page.component'; +import { HtmlContentService } from '../shared/html-content.service'; +import { Router } from '@angular/router'; +import { RouterMock } from '../shared/mocks/router.mock'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +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'; + +describe('StaticPageComponent', () => { + function createDeferred() { + let resolve: (value: T) => void; + let reject: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve: resolve!, reject: reject! }; + } + + async function setupTest( + html: string | undefined, + restBase?: string, + contentPromise?: Promise, + route: string = '/static/test-file.html' + ) { + const htmlContentService = jasmine.createSpyObj('htmlContentService', { + fetchHtmlContent: of(html), + getHmtlContentByPathAndLocale: contentPromise ?? Promise.resolve(html) + }); + + const responseService = jasmine.createSpyObj('responseService', { + setNotFound: null + }); + + const router = new RouterMock(); + router.setRoute(route); + + const appConfig = { + ...environment, + ui: { + ...(environment as any).ui, + nameSpace: '/testNamespace' + }, + rest: { + ...(environment as any).rest, + baseUrl: restBase + } + }; + + await TestBed.configureTestingModule({ + declarations: [ StaticPageComponent, ClarinSafeHtmlPipe ], + imports: [ + TranslateModule.forRoot() + ], + providers: [ + { provide: HtmlContentService, useValue: htmlContentService }, + { provide: Router, useValue: router }, + { provide: ServerResponseService, useValue: responseService }, + { provide: APP_CONFIG, useValue: appConfig } + ] + }).compileComponents(); + + const fixture = TestBed.createComponent(StaticPageComponent); + const component = fixture.componentInstance; + return { fixture, component, htmlContentService, responseService }; + } + + function createLinkEvent(href: string, useNestedTarget = false): Event { + const anchor = document.createElement('a'); + anchor.setAttribute('href', href); + + let target: EventTarget = anchor; + if (useNestedTarget) { + const nestedElement = document.createElement('span'); + anchor.appendChild(nestedElement); + target = nestedElement; + } + + return { + target, + preventDefault: jasmine.createSpy('preventDefault') + } as unknown as Event; + } + + it('should create', async () => { + const { component } = await setupTest('
test
'); + expect(component).toBeTruthy(); + }); + + it('should load html file content', async () => { + const { component } = await setupTest('
TEST MESSAGE
'); + await component.ngOnInit(); + expect(component.htmlContent.value).toBe('
TEST MESSAGE
'); + }); + + it('should call HtmlContentService with the route html file name', async () => { + const { component, htmlContentService } = await setupTest('
TEST MESSAGE
', undefined, undefined, '/static/license-ud-1.0.html'); + await component.ngOnInit(); + + expect(htmlContentService.getHmtlContentByPathAndLocale).toHaveBeenCalledWith('license-ud-1.0.html'); + }); + + it('should rewrite OAI link with rest.baseUrl', async () => { + const oaiHtml = 'OAI'; + const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/server'); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const rewritten = 'https://api.example.org/server/oai/request?verb=ListSets'; + expect(component.htmlContent.value).toContain(rewritten); + const anchor = fixture.nativeElement.querySelector('a'); + expect(anchor.getAttribute('href')).toBe(rewritten); + }); + + it('should leave OAI link unchanged when rest.baseUrl is missing', async () => { + const oaiHtml = 'OAI'; + const { fixture, component } = await setupTest(oaiHtml, undefined); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.htmlContent.value).toContain('/server/oai/request?verb=Identify'); + }); + + it('should avoid double slashes when rest.baseUrl ends with slash', async () => { + const oaiHtml = 'OAI'; + const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/server/'); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.htmlContent.value).toContain('https://api.example.org/server/oai/request?verb=ListRecords'); + expect(component.htmlContent.value).not.toContain('//oai'); + }); + + it('should include namespace in OAI link when rest.baseUrl has namespace prefix', async () => { + const oaiHtml = 'full list'; + const { fixture, component } = await setupTest(oaiHtml, 'https://api.example.org/repository/server'); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const rewritten = 'https://api.example.org/repository/server/oai/request?verb=ListMetadataFormats'; + expect(component.htmlContent.value).toContain(rewritten); + const anchor = fixture.nativeElement.querySelector('a'); + expect(anchor.getAttribute('href')).toBe(rewritten); + }); + + it('should leave content unchanged when no OAI link is present', async () => { + const otherHtml = 'Other'; + const { fixture, component } = await setupTest(otherHtml, 'https://api.example.org/server'); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.htmlContent.value).toBe(otherHtml); + }); + + describe('contentState behavior', () => { + it('should initialize contentState to "loading"', async () => { + const { component } = await setupTest('
test
'); + expect(component.contentState).toBe('loading'); + }); + + it('should set contentState to "found" when content loads successfully', async () => { + const { component } = await setupTest('
Test Content
'); + await component.ngOnInit(); + expect(component.contentState).toBe('found'); + }); + + it('should set contentState to "not-found" when content is undefined', async () => { + const { component, responseService } = await setupTest(undefined); + + await component.ngOnInit(); + + expect(component.contentState).toBe('not-found'); + expect(responseService.setNotFound).toHaveBeenCalled(); + }); + + it('should keep loading state and not render 404 before content promise resolves', async () => { + const deferred = createDeferred(); + const { fixture, component } = await setupTest(undefined, undefined, deferred.promise); + + const initPromise = component.ngOnInit(); + fixture.detectChanges(); + + expect(component.contentState).toBe('loading'); + expect(component.htmlContent.value).toBe(''); + expect(fixture.nativeElement.querySelector('.page-not-found')).toBeNull(); + + deferred.resolve('
Loaded later
'); + await initPromise; + fixture.detectChanges(); + + expect(component.contentState).toBe('found'); + expect(fixture.nativeElement.querySelector('.page-not-found')).toBeNull(); + }); + + it('should reset stale not-found state to loading on init', async () => { + const deferred = createDeferred(); + const { component } = await setupTest(undefined, undefined, deferred.promise); + + component.contentState = 'not-found'; + component.htmlContent.next('
stale
'); + + const initPromise = component.ngOnInit(); + + expect(component.contentState).toBe('loading'); + expect(component.htmlContent.value).toBe(''); + + deferred.resolve('
fresh
'); + await initPromise; + + expect(component.contentState).toBe('found'); + }); + }); + + describe('change detection', () => { + it('should call changeDetector.detectChanges() after successful content load', async () => { + const { component } = await setupTest('
test
'); + spyOn((component as any).changeDetector, 'detectChanges'); + + await component.ngOnInit(); + + expect((component as any).changeDetector.detectChanges).toHaveBeenCalled(); + }); + + it('should call changeDetector.detectChanges() when content not found', async () => { + const { component } = await setupTest(undefined); + + spyOn((component as any).changeDetector, 'detectChanges'); + + await component.ngOnInit(); + + expect((component as any).changeDetector.detectChanges).toHaveBeenCalled(); + }); + }); + + describe('link handling', () => { + it('should intercept and navigate dot-relative links under the static route', async () => { + const { component } = await setupTest('
test
'); + const navigateTo = spyOn(component, 'navigateTo'); + const event = createLinkEvent('./cite'); + + component.processLinks(event); + + expect((event.preventDefault as jasmine.Spy)).toHaveBeenCalled(); + expect(navigateTo).toHaveBeenCalledWith(`${window.location.origin}/testNamespace/static/cite`); + }); + + it('should resolve nested relative-link clicks inside anchors', async () => { + const { component } = await setupTest('
test
'); + const navigateTo = spyOn(component, 'navigateTo'); + const event = createLinkEvent('../discover?query=test', true); + + component.processLinks(event); + + expect((event.preventDefault as jasmine.Spy)).toHaveBeenCalled(); + expect(navigateTo).toHaveBeenCalledWith(`${window.location.origin}/testNamespace/discover?query=test`); + }); + + it('should not intercept explicit app-route links', async () => { + const { component } = await setupTest('
test
'); + const navigateTo = spyOn(component, 'navigateTo'); + const event = createLinkEvent('contract'); + + component.processLinks(event); + + expect((event.preventDefault as jasmine.Spy)).not.toHaveBeenCalled(); + expect(navigateTo).not.toHaveBeenCalled(); + }); + + it('should not intercept fragment links', async () => { + const { component } = await setupTest('
test
'); + const navigateTo = spyOn(component, 'navigateTo'); + const event = createLinkEvent('#about-contracts'); + + component.processLinks(event); + + expect((event.preventDefault as jasmine.Spy)).not.toHaveBeenCalled(); + expect(navigateTo).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/static-page/static-page.component.ts b/src/app/static-page/static-page.component.ts new file mode 100644 index 00000000000..6ca77bf955c --- /dev/null +++ b/src/app/static-page/static-page.component.ts @@ -0,0 +1,131 @@ +import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core'; +import { HtmlContentService } from '../shared/html-content.service'; +import { BehaviorSubject } from 'rxjs'; +import { Router } from '@angular/router'; +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'; + +/** + * Component which load and show static files from the `static-files` folder. + * E.g., `/static/test_file.html will load the file content from the `static-files/test_file.html`/ + */ +@Component({ + selector: 'ds-static-page', + templateUrl: './static-page.component.html', + styleUrls: ['./static-page.component.scss'] +}) +export class StaticPageComponent implements OnInit { + htmlContent: BehaviorSubject = new BehaviorSubject(''); + htmlFileName: string; + contentState: 'loading' | 'found' | 'not-found' = 'loading'; + + constructor(private htmlContentService: HtmlContentService, + private router: Router, + private responseService: ServerResponseService, + private changeDetector: ChangeDetectorRef, + @Inject(APP_CONFIG) protected appConfig?: AppConfig) { } + + async ngOnInit(): Promise { + try { + this.contentState = 'loading'; + this.htmlContent.next(''); + + // Fetch html file name from the url path. `static/some_file.html` + this.htmlFileName = this.getHtmlFileName(); + + let htmlContent = await this.htmlContentService.getHmtlContentByPathAndLocale(this.htmlFileName); + if (htmlContent !== undefined) { + const restBase = this.appConfig?.rest?.baseUrl; + const oaiUrl = restBase ? restBase.replace(/\/+$/, '') + '/oai' : '/server/oai'; + htmlContent = htmlContent.replace(/href="\/server\/oai/gi, 'href="' + oaiUrl); + + this.htmlContent.next(htmlContent); + this.contentState = 'found'; + this.changeDetector.detectChanges(); + return; + } + + // Content not found - set 404 status for SSR and show inline error + this.responseService.setNotFound(); + this.contentState = 'not-found'; + this.changeDetector.detectChanges(); + } catch (error) { + console.error('Static page load error:', { + fileName: this.htmlFileName, + url: this.router.url, + error: error + }); + this.responseService.setNotFound(); + this.contentState = 'not-found'; + this.changeDetector.detectChanges(); + } + } + + /** + * Handle click on links in the static page. + * @param event + */ + processLinks(event: Event): void { + const targetElement = event.target as HTMLElement | null; + const anchorElement = targetElement?.closest?.('a'); + if (!anchorElement) { + return; + } + + const href = anchorElement.getAttribute('href'); + if (!href || !this.isRelativeLink(href)) { + return; + } + + event.preventDefault(); + const namespacePrefix = this.getNamespacePrefix(); + const staticPageBaseUrl = this.composeStaticPageBaseUrl(namespacePrefix); + this.redirectToRelativeLink(staticPageBaseUrl, href); + } + + private getNamespacePrefix(): string { + const nameSpace = this.appConfig?.ui?.nameSpace ?? '/'; + return nameSpace === '/' ? '' : nameSpace.replace(/\/$/, ''); + } + + private composeUrl(pathname: string): string { + const baseUrl = new URL(window.location.origin); + baseUrl.pathname = pathname; + return baseUrl.href; + } + + private composeStaticPageBaseUrl(namespacePrefix: string): string { + return this.composeUrl(`${namespacePrefix}/${STATIC_PAGE_PATH}/`); + } + + private isRelativeLink(href: string | null): boolean { + return href?.startsWith('.') ?? false; + } + + private redirectToRelativeLink(redirectUrl: string, href: string | null): void { + this.navigateTo(new URL(href, redirectUrl).href); + } + + private navigateTo(url: string): void { + window.location.href = url; + } + + /** + * Load file name from the URL - `static/FILE_NAME.html` + * @private + */ + private getHtmlFileName() { + let urlInList = this.router.url?.split('/'); + // Filter empty elements + urlInList = urlInList.filter(n => n); + // if length is 1 - html file name wasn't defined. + if (isEmpty(urlInList) || urlInList.length === 1) { + return null; + } + + // If the url is too long take just the first string after `/static` prefix. + return urlInList[1]?.split('#')?.[0]; + } +} diff --git a/src/app/static-page/static-page.module.ts b/src/app/static-page/static-page.module.ts new file mode 100644 index 00000000000..7ed011ead4a --- /dev/null +++ b/src/app/static-page/static-page.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { StaticPageRoutingModule } from './static-page-routing.module'; +import { StaticPageComponent } from './static-page.component'; +import { SharedModule } from '../shared/shared.module'; + + +@NgModule({ + declarations: [ + StaticPageComponent + ], + imports: [ + CommonModule, + StaticPageRoutingModule, + SharedModule, + ] +}) +export class StaticPageModule { } diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 32d958e88df..af60b1907a1 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -7204,6 +7204,15 @@ // "sorting.lastModified.DESC" : "Last modified Descending" "sorting.lastModified.DESC" : "Poslední změna sestupně", + // "static-page.404.help" : "The static page you requested does not exist. It may have been moved or deleted. You can use the button below to get back to the home page." + "static-page.404.help" : "Požadovaná statická stránka neexistuje. Mohla být přesunuta nebo smazána. Pomocí níže uvedeného tlačítka se můžete vrátit na domovskou stránku.", + + // "static-page.404.link.home-page" : "Take me to the home page" + "static-page.404.link.home-page" : "Návrat na domovskou stránku", + + // "static-page.404.page-not-found" : "page not found" + "static-page.404.page-not-found" : "stránka nebyla nalezena", + // "statistics.title" : "Statistics" "statistics.title" : "Statistiky", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index c95e0576de5..0751b2f91f4 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -4443,6 +4443,13 @@ "sorting.lastModified.DESC": "Last modified Descending", + "static-page.404.help": "The static page you requested does not exist. It may have been moved or deleted. You can use the button below to get back to the home page.", + + "static-page.404.link.home-page": "Take me to the home page", + + "static-page.404.page-not-found": "page not found", + + "statistics.title": "Statistics", "statistics.header": "Statistics for {{ scope }}", diff --git a/src/static-files/VERSION_D.html b/src/static-files/VERSION_D.html new file mode 100644 index 00000000000..dd4019e2622 --- /dev/null +++ b/src/static-files/VERSION_D.html @@ -0,0 +1,7 @@ +