Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
"aot": true,
"assets": [
"src/assets",
{
"glob": "VERSION_D.html",
"input": "src/static-files",
"output": "static-files"
},
"src/robots.txt"
],
"styles": [
Expand Down
38 changes: 38 additions & 0 deletions scripts/sourceversion.py
Original file line number Diff line number Diff line change
@@ -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"<h4>This info was generated on: <br> <strong> {ts.strftime('%Y-%m-%d %H:%M:%S %Z%z')} </strong> </h4>")

cmd = 'git log -1 --pretty=format:"<h4>Git hash: <br><strong> %H </strong> <br> Date of commit: <br> <strong> %ai </strong></h4>"'
subprocess.check_call(cmd, shell=True)

# when adding argparse, this should be a bit more obvious
link = sys.argv[1] + sys.argv[2]
print('<br> <h4>Build run: </h4> <a href="' + link + '"> ' + link + '</a> ')

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

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) ')
5 changes: 5 additions & 0 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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 }
]
}
Expand Down
126 changes: 126 additions & 0 deletions src/app/shared/html-content.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<div>English Content</div>');
tick();

expect(content).toBe('<div>English Content</div>');
}));

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('');
});
});
124 changes: 124 additions & 0 deletions src/app/shared/html-content.service.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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;
}
}
}
4 changes: 3 additions & 1 deletion src/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -318,7 +319,8 @@ const PIPES = [
ClarinLicenseCheckedPipe,
ClarinLicenseLabelRadioValuePipe,
ClarinLicenseRequiredInfoPipe,
CharToEndPipe
CharToEndPipe,
ClarinSafeHtmlPipe
];

const COMPONENTS = [
Expand Down
15 changes: 15 additions & 0 deletions src/app/shared/utils/clarin-safehtml.pipe.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
7 changes: 7 additions & 0 deletions src/app/static-page/static-page-routing-paths.ts
Original file line number Diff line number Diff line change
@@ -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';
19 changes: 19 additions & 0 deletions src/app/static-page/static-page-routing.module.ts
Original file line number Diff line number Diff line change
@@ -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 { }
Loading
Loading