Skip to content

Commit 5772d97

Browse files
KasinhouMatus Kasakclaude
authored
TUL/Add deployed-version info: VERSION_D at /static/VERSION_D (#813) (#1477)
* Add deployed-version info feature: serve VERSION_D at /static/VERSION_D (#813) Port the deployed-commit info feature from dtq-dev so this instance can show which commit is currently deployed: - scripts/sourceversion.py generates src/static-files/VERSION_D.html (git hash, commit date, build-run link) at Docker build time. - .github/workflows/docker.yml runs the version script before the image build. - Static-page module + HtmlContentService serve /static/<name> from static-files/. - ClarinSafeHtmlPipe (dsSafeHtml) ported and registered in SharedModule (needed by the static-page template). - angular.json registers src/static-files as a build asset. - app-routing.module.ts registers the /static route. - en/cs i18n: static-page.404.* strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add src/static-files placeholder so VERSION_D is generated & served (#813) TUL/jcu had no src/static-files directory. The CI 'Add version' step redirects into src/static-files/VERSION_D.html and angular.json ships src/static-files as a build asset, both of which require the directory to exist. Commit a placeholder (overwritten at build time) to guarantee it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Scope static-files asset to VERSION_D.html only (#813) Only the deployed-version info page should be served, not a full LINDAT-style static-page set. Restrict the angular.json asset to VERSION_D.html so nothing else under src/static-files becomes web-accessible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Trim issue refs / verbose comments in TUL frontend (align with dtq-dev) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ce4405e commit 5772d97

18 files changed

Lines changed: 831 additions & 1 deletion

.github/workflows/docker.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ jobs:
4343
- name: Checkout codebase
4444
uses: actions/checkout@v3
4545

46+
# Generate the version file before the Docker build so it is included in the image.
47+
- name: Add version
48+
run: python scripts/sourceversion.py ${{ github.server_url }}/${{ github.repository }}/actions/runs/ ${{ github.run_id }} > src/static-files/VERSION_D.html
49+
4650
# https://github.com/docker/setup-buildx-action
4751
- name: Setup Docker Buildx
4852
uses: docker/setup-buildx-action@v2

angular.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
"aot": true,
4242
"assets": [
4343
"src/assets",
44+
{
45+
"glob": "VERSION_D.html",
46+
"input": "src/static-files",
47+
"output": "static-files"
48+
},
4449
"src/robots.txt"
4550
],
4651
"styles": [

scripts/sourceversion.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import subprocess
2+
import sys
3+
from datetime import datetime, timezone
4+
5+
# when next editing this script, please introduce argparse.
6+
# do not forget, it is called in BE by .github\workflows\reusable-docker-build.yml
7+
# argparse must be introduced there.
8+
# that action also calls BE version of this script, which is different (BE: scripts/sourceversion.py).
9+
# It must also cooperate with argparse
10+
11+
# the idea is, that this will be different on each branch, but could be possibly passed by argv/argparse
12+
RELEASE_TAG_BASE='none'
13+
14+
def get_time_in_timezone(zone: str = "Europe/Bratislava"):
15+
try:
16+
from zoneinfo import ZoneInfo
17+
my_tz = ZoneInfo(zone)
18+
except Exception as e:
19+
my_tz = timezone.utc
20+
return datetime.now(my_tz)
21+
22+
23+
if __name__ == '__main__':
24+
ts = get_time_in_timezone()
25+
# we have html tags, since this script ends up creating VERSION_D.html
26+
print(f"<h4>This info was generated on: <br> <strong> {ts.strftime('%Y-%m-%d %H:%M:%S %Z%z')} </strong> </h4>")
27+
28+
cmd = 'git log -1 --pretty=format:"<h4>Git hash: <br><strong> %H </strong> <br> Date of commit: <br> <strong> %ai </strong></h4>"'
29+
subprocess.check_call(cmd, shell=True)
30+
31+
# when adding argparse, this should be a bit more obvious
32+
link = sys.argv[1] + sys.argv[2]
33+
print('<br> <h4>Build run: </h4> <a href="' + link + '"> ' + link + '</a> ')
34+
35+
link = "https://github.com/dataquest-dev/dspace-angular/releases/tag/" \
36+
+ RELEASE_TAG_BASE + "-" + datetime.now().strftime('%Y.%m.') + sys.argv[2]
37+
38+
print('<br> <br> <h4>Release link: </h4><a href="' + link + '"> ' + link + '</a> (if it does not work, then this is not an official release instance) ')

src/app/app-routing.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard';
4343
import { MenuResolver } from './menu.resolver';
4444
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
4545
import { HANDLE_TABLE_MODULE_PATH } from './handle-page/handle-page-routing-paths';
46+
import { STATIC_PAGE_PATH } from './static-page/static-page-routing-paths';
4647

4748
@NgModule({
4849
imports: [
@@ -260,6 +261,10 @@ import { HANDLE_TABLE_MODULE_PATH } from './handle-page/handle-page-routing-path
260261
loadChildren: () => import('./handle-page/handle-page.module').then((m) => m.HandlePageModule),
261262
canActivate: [SiteAdministratorGuard],
262263
},
264+
{
265+
path: STATIC_PAGE_PATH,
266+
loadChildren: () => import('./static-page/static-page.module').then((m) => m.StaticPageModule),
267+
},
263268
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }
264269
]
265270
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
2+
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
3+
import { firstValueFrom } from 'rxjs';
4+
5+
import { HtmlContentService } from './html-content.service';
6+
import { LocaleService } from '../core/locale/locale.service';
7+
import { APP_CONFIG } from '../../config/app-config.interface';
8+
9+
class LocaleServiceStub {
10+
languageCode = 'en';
11+
12+
getCurrentLanguageCode(): string {
13+
return this.languageCode;
14+
}
15+
}
16+
17+
describe('HtmlContentService', () => {
18+
let service: HtmlContentService;
19+
let httpMock: HttpTestingController;
20+
let localeService: LocaleServiceStub;
21+
22+
function setup(nameSpace: string): void {
23+
TestBed.configureTestingModule({
24+
imports: [HttpClientTestingModule],
25+
providers: [
26+
HtmlContentService,
27+
{ provide: LocaleService, useClass: LocaleServiceStub },
28+
{
29+
provide: APP_CONFIG,
30+
useValue: {
31+
ui: { nameSpace },
32+
},
33+
},
34+
],
35+
});
36+
37+
service = TestBed.inject(HtmlContentService);
38+
httpMock = TestBed.inject(HttpTestingController);
39+
localeService = TestBed.inject(LocaleService) as any;
40+
}
41+
42+
afterEach(() => {
43+
if (httpMock) {
44+
httpMock.verify();
45+
}
46+
});
47+
48+
it('should request root namespaced URL for default locale', async () => {
49+
setup('/');
50+
localeService.languageCode = 'en';
51+
52+
const promise = service.getHmtlContentByPathAndLocale('license-ud-1.0');
53+
54+
const request = httpMock.expectOne('/static-files/license-ud-1.0.html');
55+
expect(request.request.method).toBe('GET');
56+
request.flush('Universal Dependencies 1.0 License Set');
57+
58+
const content = await promise;
59+
expect(content).toBe('Universal Dependencies 1.0 License Set');
60+
});
61+
62+
it('should request locale-specific namespaced URL for non-default locale', async () => {
63+
setup('/repository');
64+
localeService.languageCode = 'cs';
65+
66+
const promise = service.getHmtlContentByPathAndLocale('license-ud-1.0');
67+
68+
const request = httpMock.expectOne('/repository/static-files/cs/license-ud-1.0.html');
69+
expect(request.request.method).toBe('GET');
70+
request.flush('Localized content');
71+
72+
const content = await promise;
73+
expect(content).toBe('Localized content');
74+
});
75+
76+
it('should fallback from locale-specific to default namespaced URL when localized content is missing', fakeAsync(() => {
77+
setup('/repository/');
78+
localeService.languageCode = 'cs';
79+
80+
let content: string | undefined;
81+
service.getHmtlContentByPathAndLocale('license-ud-1.0').then((result) => {
82+
content = result;
83+
});
84+
85+
const localizedRequest = httpMock.expectOne('/repository/static-files/cs/license-ud-1.0.html');
86+
localizedRequest.flush('Not Found', { status: 404, statusText: 'Not Found' });
87+
tick();
88+
89+
const fallbackRequest = httpMock.expectOne('/repository/static-files/license-ud-1.0.html');
90+
fallbackRequest.flush('Fallback content');
91+
tick();
92+
93+
expect(content).toBe('Fallback content');
94+
}));
95+
96+
it('should fallback from locale-specific to default URL when locale returns 404', fakeAsync(() => {
97+
setup('/');
98+
localeService.languageCode = 'cs';
99+
100+
let content: string | undefined;
101+
service.getHmtlContentByPathAndLocale('license').then((result) => {
102+
content = result;
103+
});
104+
105+
httpMock.expectOne('/static-files/cs/license.html')
106+
.flush('Not Found', { status: 404, statusText: 'Not Found' });
107+
tick();
108+
109+
httpMock.expectOne('/static-files/license.html').flush('<div>English Content</div>');
110+
tick();
111+
112+
expect(content).toBe('<div>English Content</div>');
113+
}));
114+
115+
it('should return empty string from getHtmlContent when request fails', async () => {
116+
setup('/repository');
117+
118+
const contentPromise = firstValueFrom(service.getHtmlContent('static-files/missing-page.html'));
119+
120+
const request = httpMock.expectOne('/repository/static-files/missing-page.html');
121+
request.flush('Not Found', { status: 404, statusText: 'Not Found' });
122+
123+
const content = await contentPromise;
124+
expect(content).toBe('');
125+
});
126+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { isPlatformServer } from '@angular/common';
2+
import { Inject, Injectable, Optional, PLATFORM_ID } from '@angular/core';
3+
import { HttpClient, HttpResponse } from '@angular/common/http';
4+
import { catchError } from 'rxjs/operators';
5+
import { firstValueFrom, of as observableOf } from 'rxjs';
6+
import { HTML_SUFFIX, STATIC_FILES_PROJECT_PATH } from '../static-page/static-page-routing-paths';
7+
import { isEmpty } from './empty.util';
8+
import { LocaleService } from '../core/locale/locale.service';
9+
import { APP_CONFIG, AppConfig } from '../../config/app-config.interface';
10+
import { REQUEST } from '@nguniversal/express-engine/tokens';
11+
12+
/**
13+
* Service for loading static `.html` files stored in the `/static-files` folder.
14+
*/
15+
@Injectable()
16+
export class HtmlContentService {
17+
constructor(private http: HttpClient,
18+
private localeService: LocaleService,
19+
@Inject(APP_CONFIG) protected appConfig?: AppConfig,
20+
@Inject(PLATFORM_ID) private platformId?: object,
21+
@Optional() @Inject(REQUEST) private request?: any,
22+
) {}
23+
24+
private getNamespacePrefix(): string {
25+
const nameSpace = this.appConfig?.ui?.nameSpace ?? '/';
26+
if (nameSpace === '/') {
27+
return '';
28+
}
29+
return nameSpace.endsWith('/') ? nameSpace.slice(0, -1) : nameSpace;
30+
}
31+
32+
private composeNamespacedUrl(url: string): string {
33+
if (/^https?:\/\//i.test(url)) {
34+
return url;
35+
}
36+
37+
const normalizedPath = url.startsWith('/') ? url : `/${url}`;
38+
const namespacePrefix = this.getNamespacePrefix();
39+
40+
if (namespacePrefix && normalizedPath.startsWith(`${namespacePrefix}/`)) {
41+
return normalizedPath;
42+
}
43+
44+
return `${namespacePrefix}${normalizedPath}`;
45+
}
46+
47+
private buildRuntimeUrl(path: string): string {
48+
if (!isPlatformServer(this.platformId) || !this.request) {
49+
return path;
50+
}
51+
52+
const protocol = this.request.protocol;
53+
const host = this.request.get?.('host');
54+
if (!protocol || !host) {
55+
return path;
56+
}
57+
58+
return `${protocol}://${host}${path}`;
59+
}
60+
61+
getHtmlContent(url: string) {
62+
const namespacedUrl = this.composeNamespacedUrl(url);
63+
const runtimeUrl = this.buildRuntimeUrl(namespacedUrl);
64+
return this.http.get(runtimeUrl, { responseType: 'text' }).pipe(
65+
catchError(() => observableOf('')));
66+
}
67+
68+
/**
69+
* Load `.html` file content and return the full response.
70+
* @param url file location
71+
*/
72+
fetchHtmlContent(url: string) {
73+
const namespacedUrl = this.composeNamespacedUrl(url);
74+
const runtimeUrl = this.buildRuntimeUrl(namespacedUrl);
75+
return this.http.get(runtimeUrl, { responseType: 'text', observe: 'response' }).pipe(
76+
catchError((error) => observableOf(new HttpResponse({ status: error.status || 0, body: '' }))));
77+
}
78+
79+
/**
80+
* Load HTML content for a single URL attempt and handle cached 304 responses.
81+
* @param url file location
82+
*/
83+
private async loadHtmlContent(url: string): Promise<string | undefined> {
84+
const response = await firstValueFrom(this.fetchHtmlContent(url));
85+
if (response.status === 200) {
86+
return response.body ?? '';
87+
}
88+
if (response.status === 304) {
89+
return response.body ?? '';
90+
}
91+
return undefined;
92+
}
93+
94+
/**
95+
* Get the html file content as a string by the file name and the current locale.
96+
*/
97+
async getHmtlContentByPathAndLocale(fileName: string) {
98+
let url = '';
99+
// Get current language
100+
let language = this.localeService.getCurrentLanguageCode();
101+
// If language is default = `en` do not load static files from translated package e.g. `cs`.
102+
language = language === 'en' ? '' : language;
103+
104+
// Try to find the html file in the translated package. `static-files/language_code/some_file.html`
105+
// Compose url
106+
url = STATIC_FILES_PROJECT_PATH;
107+
url += isEmpty(language) ? '/' + fileName : '/' + language + '/' + fileName;
108+
// Add `.html` suffix to get the current html file
109+
url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX;
110+
let potentialContent = await this.loadHtmlContent(url);
111+
if (potentialContent !== undefined) {
112+
return potentialContent;
113+
}
114+
115+
// If the file wasn't find, get the non-translated file from the default package.
116+
url = STATIC_FILES_PROJECT_PATH + '/' + fileName;
117+
// Add `.html` suffix to match localized request behavior
118+
url = url.endsWith(HTML_SUFFIX) ? url : url + HTML_SUFFIX;
119+
potentialContent = await this.loadHtmlContent(url);
120+
if (potentialContent !== undefined) {
121+
return potentialContent;
122+
}
123+
}
124+
}

src/app/shared/shared.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ import {
250250
ItemPageTitleFieldComponent
251251
} from '../item-page/simple/field-components/specific-field/title/item-page-title-field.component';
252252
import { MarkdownPipe } from './utils/markdown.pipe';
253+
import { ClarinSafeHtmlPipe } from './utils/clarin-safehtml.pipe';
253254
import { GoogleRecaptchaModule } from '../core/google-recaptcha/google-recaptcha.module';
254255
import { MenuModule } from './menu/menu.module';
255256
import {
@@ -318,7 +319,8 @@ const PIPES = [
318319
ClarinLicenseCheckedPipe,
319320
ClarinLicenseLabelRadioValuePipe,
320321
ClarinLicenseRequiredInfoPipe,
321-
CharToEndPipe
322+
CharToEndPipe,
323+
ClarinSafeHtmlPipe
322324
];
323325

324326
const COMPONENTS = [
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Pipe, PipeTransform } from '@angular/core';
2+
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
3+
4+
/**
5+
* Pipe to keep html tags e.g., `id` in the `innerHTML` attribute.
6+
*/
7+
@Pipe({
8+
name: 'dsSafeHtml'
9+
})
10+
export class ClarinSafeHtmlPipe implements PipeTransform {
11+
constructor(private sanitized: DomSanitizer) {}
12+
transform(htmlString: string): SafeHtml {
13+
return this.sanitized.bypassSecurityTrustHtml(htmlString);
14+
}
15+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Constants for `/static` route.
3+
*/
4+
export const STATIC_PAGE_PATH = 'static';
5+
export const STATIC_FILES_PROJECT_PATH = 'static-files';
6+
export const HTML_SUFFIX = '.html';
7+
export const STATIC_FILES_DEFAULT_ERROR_PAGE_PATH = STATIC_FILES_PROJECT_PATH + '/' + 'error.html';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { NgModule } from '@angular/core';
2+
import { RouterModule, Routes } from '@angular/router';
3+
import { StaticPageComponent } from './static-page.component';
4+
5+
const routes: Routes = [
6+
{
7+
path: '',
8+
children: [
9+
{ path: '', component: StaticPageComponent },
10+
{ path: ':htmlFileName', component: StaticPageComponent },
11+
],
12+
},
13+
];
14+
15+
@NgModule({
16+
imports: [RouterModule.forChild(routes)],
17+
exports: [RouterModule]
18+
})
19+
export class StaticPageRoutingModule { }

0 commit comments

Comments
 (0)