support base path

This commit is contained in:
William Welling
2022-05-05 16:13:25 -05:00
parent 37ebe259f3
commit 9a433b50ff
18 changed files with 111 additions and 38 deletions

View File

@@ -9,10 +9,11 @@
"start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"", "start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"",
"start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr", "start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr",
"start:mirador:prod": "yarn run build:mirador && yarn run start:prod", "start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
"serve": "ng serve -c development", "preserve": "yarn base-href",
"serve": "ng serve --configuration development",
"serve:ssr": "node dist/server/main", "serve:ssr": "node dist/server/main",
"analyze": "webpack-bundle-analyzer dist/browser/stats.json", "analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build -c development", "build": "ng build --configuration development",
"build:stats": "ng build --stats-json", "build:stats": "ng build --stats-json",
"build:prod": "yarn run build:ssr", "build:prod": "yarn run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
@@ -37,6 +38,7 @@
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:run": "cypress run", "cypress:run": "cypress run",
"env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts", "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts",
"base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts",
"check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./" "check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./"
}, },
"browser": { "browser": {

36
scripts/base-href.ts Normal file
View File

@@ -0,0 +1,36 @@
import * as fs from 'fs';
import { join } from 'path';
import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server';
/**
* Script to set baseHref as `ui.nameSpace` for development mode. Adds `baseHref` to angular.json build options.
*
* Usage (see package.json):
*
* yarn base-href
*/
const appConfig: AppConfig = buildAppConfig();
const angularJsonPath = join(process.cwd(), 'angular.json');
if (!fs.existsSync(angularJsonPath)) {
console.error(`Error:\n${angularJsonPath} does not exist\n`);
process.exit(1);
}
try {
const angularJson = require(angularJsonPath);
const baseHref = `${appConfig.ui.nameSpace}${appConfig.ui.nameSpace.endsWith('/') ? '' : '/'}`;
console.log(`Setting baseHref to ${baseHref} in angular.json`);
angularJson.projects['dspace-angular'].architect.build.options.baseHref = baseHref;
fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n');
} catch (e) {
console.error(e);
}

View File

@@ -66,6 +66,8 @@ extendEnvironmentWithAppConfig(environment, appConfig);
// The Express app is exported so that it can be used by serverless Functions. // The Express app is exported so that it can be used by serverless Functions.
export function app() { export function app() {
const router = express.Router();
/* /*
* Create a new express application * Create a new express application
*/ */
@@ -133,7 +135,11 @@ export function app() {
/** /**
* Proxy the sitemaps * Proxy the sitemaps
*/ */
server.use('/sitemap**', createProxyMiddleware({ target: `${environment.rest.baseUrl}/sitemaps`, changeOrigin: true })); router.use('/sitemap**', createProxyMiddleware({
target: `${environment.rest.baseUrl}/sitemaps`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true
}));
/** /**
* Checks if the rateLimiter property is present * Checks if the rateLimiter property is present
@@ -151,14 +157,16 @@ export function app() {
/* /*
* Serve static resources (images, i18n messages, …) * Serve static resources (images, i18n messages, …)
*/ */
server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); router.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
/* /*
* Fallthrough to the IIIF viewer (must be included in the build). * Fallthrough to the IIIF viewer (must be included in the build).
*/ */
server.use('/iiif', express.static(IIIF_VIEWER, {index:false})); router.use('/iiif', express.static(IIIF_VIEWER, { index: false }));
// Register the ngApp callback function to handle incoming requests // Register the ngApp callback function to handle incoming requests
server.get('*', ngApp); router.get('*', ngApp);
server.use(environment.ui.nameSpace, router);
return server; return server;
} }
@@ -191,13 +199,25 @@ function ngApp(req, res) {
if (hasValue(err)) { if (hasValue(err)) {
console.warn('Error details : ', err); console.warn('Error details : ', err);
} }
res.sendFile(DIST_FOLDER + '/index.html'); res.render(indexHtml, {
req,
providers: [{
provide: APP_BASE_HREF,
useValue: req.baseUrl
}]
});
} }
}); });
} else { } else {
// If preboot is disabled, just serve the client // If preboot is disabled, just serve the client
console.log('Universal off, serving for direct CSR'); console.log('Universal off, serving for direct CSR');
res.sendFile(DIST_FOLDER + '/index.html'); res.render(indexHtml, {
req,
providers: [{
provide: APP_BASE_HREF,
useValue: req.baseUrl
}]
});
} }
} }

View File

@@ -187,7 +187,7 @@ describe('App component', () => {
link.setAttribute('rel', 'stylesheet'); link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css'); link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css'); link.setAttribute('class', 'theme-css');
link.setAttribute('href', '/custom-theme.css'); link.setAttribute('href', 'custom-theme.css');
expect(headSpy.appendChild).toHaveBeenCalledWith(link); expect(headSpy.appendChild).toHaveBeenCalledWith(link);
}); });

View File

@@ -268,7 +268,7 @@ export class AppComponent implements OnInit, AfterViewInit {
link.setAttribute('rel', 'stylesheet'); link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css'); link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css'); link.setAttribute('class', 'theme-css');
link.setAttribute('href', `/${encodeURIComponent(themeName)}-theme.css`); link.setAttribute('href', `${encodeURIComponent(themeName)}-theme.css`);
// wait for the new css to download before removing the old one to prevent a // wait for the new css to download before removing the old one to prevent a
// flash of unstyled content // flash of unstyled content
link.onload = () => { link.onload = () => {

View File

@@ -1,4 +1,4 @@
import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { APP_INITIALIZER, NgModule } from '@angular/core';
import { AbstractControl } from '@angular/forms'; import { AbstractControl } from '@angular/forms';
@@ -66,9 +66,11 @@ export function getConfig() {
return environment; return environment;
} }
export function getBase(appConfig: AppConfig) { const getBaseHref = (document: Document, appConfig: AppConfig): string => {
return appConfig.ui.nameSpace; const baseTag = document.querySelector('head > base');
} baseTag.setAttribute('href', `${appConfig.ui.nameSpace}${appConfig.ui.nameSpace.endsWith('/') ? '' : '/'}`);
return baseTag.getAttribute('href');
};
export function getMetaReducers(appConfig: AppConfig): MetaReducer<AppState>[] { export function getMetaReducers(appConfig: AppConfig): MetaReducer<AppState>[] {
return appConfig.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; return appConfig.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
@@ -107,8 +109,8 @@ const PROVIDERS = [
}, },
{ {
provide: APP_BASE_HREF, provide: APP_BASE_HREF,
useFactory: getBase, useFactory: getBaseHref,
deps: [APP_CONFIG] deps: [DOCUMENT, APP_CONFIG]
}, },
{ {
provide: USER_PROVIDED_META_REDUCERS, provide: USER_PROVIDED_META_REDUCERS,

View File

@@ -368,25 +368,25 @@ describe('AuthService test', () => {
it('should redirect to reload with redirect url', () => { it('should redirect to reload with redirect url', () => {
authService.navigateToRedirectUrl('/collection/123'); authService.navigateToRedirectUrl('/collection/123');
// Reload with redirect URL set to /collection/123 // Reload with redirect URL set to /collection/123
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123'))));
}); });
it('should redirect to reload with /home', () => { it('should redirect to reload with /home', () => {
authService.navigateToRedirectUrl('/home'); authService.navigateToRedirectUrl('/home');
// Reload with redirect URL set to /home // Reload with redirect URL set to /home
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/home'))));
}); });
it('should redirect to regular reload and not to /login', () => { it('should redirect to regular reload and not to /login', () => {
authService.navigateToRedirectUrl('/login'); authService.navigateToRedirectUrl('/login');
// Reload without a redirect URL // Reload without a redirect URL
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$')));
}); });
it('should redirect to regular reload when no redirect url is found', () => { it('should redirect to regular reload when no redirect url is found', () => {
authService.navigateToRedirectUrl(undefined); authService.navigateToRedirectUrl(undefined);
// Reload without a redirect URL // Reload without a redirect URL
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$')));
}); });
describe('impersonate', () => { describe('impersonate', () => {

View File

@@ -447,8 +447,8 @@ export class AuthService {
*/ */
public navigateToRedirectUrl(redirectUrl: string) { public navigateToRedirectUrl(redirectUrl: string) {
// Don't do redirect if already on reload url // Don't do redirect if already on reload url
if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) { if (!hasValue(redirectUrl) || !redirectUrl.includes('reload/')) {
let url = `/reload/${new Date().getTime()}`; let url = `reload/${new Date().getTime()}`;
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
url += `?redirect=${encodeURIComponent(redirectUrl)}`; url += `?redirect=${encodeURIComponent(redirectUrl)}`;
} }

View File

@@ -192,7 +192,7 @@ export class LocaleService {
this.routeService.getCurrentUrl().pipe(take(1)).subscribe((currentURL) => { this.routeService.getCurrentUrl().pipe(take(1)).subscribe((currentURL) => {
// Hard redirect to the reload page with a unique number behind it // Hard redirect to the reload page with a unique number behind it
// so that all state is definitely lost // so that all state is definitely lost
this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL); this._window.nativeWindow.location.href = `reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL);
}); });
} }

View File

@@ -1,13 +1,17 @@
import { ReloadGuard } from './reload.guard';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AppConfig } from '../../../config/app-config.interface';
import { DefaultAppConfig } from '../../../config/default-app-config';
import { ReloadGuard } from './reload.guard';
describe('ReloadGuard', () => { describe('ReloadGuard', () => {
let guard: ReloadGuard; let guard: ReloadGuard;
let router: Router; let router: Router;
let appConfig: AppConfig;
beforeEach(() => { beforeEach(() => {
router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']); router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']);
guard = new ReloadGuard(router); appConfig = new DefaultAppConfig();
guard = new ReloadGuard(router, appConfig);
}); });
describe('canActivate', () => { describe('canActivate', () => {
@@ -27,7 +31,7 @@ describe('ReloadGuard', () => {
it('should create a UrlTree with the redirect URL', () => { it('should create a UrlTree with the redirect URL', () => {
guard.canActivate(route, undefined); guard.canActivate(route, undefined);
expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl); expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl.substring(1));
}); });
}); });

View File

@@ -1,5 +1,6 @@
import { Inject, Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Injectable } from '@angular/core'; import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
/** /**
@@ -8,7 +9,10 @@ import { isNotEmpty } from '../../shared/empty.util';
*/ */
@Injectable() @Injectable()
export class ReloadGuard implements CanActivate { export class ReloadGuard implements CanActivate {
constructor(private router: Router) { constructor(
private router: Router,
@Inject(APP_CONFIG) private appConfig: AppConfig,
) {
} }
/** /**
@@ -18,7 +22,10 @@ export class ReloadGuard implements CanActivate {
*/ */
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree {
if (isNotEmpty(route.queryParams.redirect)) { if (isNotEmpty(route.queryParams.redirect)) {
return this.router.parseUrl(route.queryParams.redirect); const url = route.queryParams.redirect.startsWith(this.appConfig.ui.nameSpace)
? route.queryParams.redirect.substring(this.appConfig.ui.nameSpace.length)
: route.queryParams.redirect;
return this.router.parseUrl(url);
} else { } else {
return this.router.createUrlTree(['home']); return this.router.createUrlTree(['home']);
} }

View File

@@ -5,6 +5,6 @@
<p>{{"500.help" | translate}}</p> <p>{{"500.help" | translate}}</p>
<br/> <br/>
<p class="text-center"> <p class="text-center">
<a href="/home" class="btn btn-primary">{{"500.link.home-page" | translate}}</a> <a href="home" class="btn btn-primary">{{"500.link.home-page" | translate}}</a>
</p> </p>
</div> </div>

View File

@@ -4,7 +4,6 @@ import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { LinkMenuItemComponent } from './link-menu-item.component'; import { LinkMenuItemComponent } from './link-menu-item.component';
import { RouterLinkDirectiveStub } from '../../testing/router-link-directive.stub'; import { RouterLinkDirectiveStub } from '../../testing/router-link-directive.stub';
import { environment } from '../../../../environments/environment';
import { QueryParamsDirectiveStub } from '../../testing/query-params-directive.stub'; import { QueryParamsDirectiveStub } from '../../testing/query-params-directive.stub';
import { RouterStub } from '../../testing/router.stub'; import { RouterStub } from '../../testing/router.stub';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -58,7 +57,7 @@ describe('LinkMenuItemComponent', () => {
const routerLinkQuery = linkDes.map((de) => de.injector.get(RouterLinkDirectiveStub)); const routerLinkQuery = linkDes.map((de) => de.injector.get(RouterLinkDirectiveStub));
expect(routerLinkQuery.length).toBe(1); expect(routerLinkQuery.length).toBe(1);
expect(routerLinkQuery[0].routerLink).toBe(environment.ui.nameSpace + link); expect(routerLinkQuery[0].routerLink).toBe(link);
}); });
it('should have the right queryParams attribute', () => { it('should have the right queryParams attribute', () => {

View File

@@ -2,7 +2,6 @@ import { Component, Inject, Input, OnInit } from '@angular/core';
import { LinkMenuItemModel } from './models/link.model'; import { LinkMenuItemModel } from './models/link.model';
import { rendersMenuItemForType } from '../menu-item.decorator'; import { rendersMenuItemForType } from '../menu-item.decorator';
import { isNotEmpty } from '../../empty.util'; import { isNotEmpty } from '../../empty.util';
import { environment } from '../../../../environments/environment';
import { MenuItemType } from '../menu-item-type.model'; import { MenuItemType } from '../menu-item-type.model';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -30,7 +29,7 @@ export class LinkMenuItemComponent implements OnInit {
getRouterLink() { getRouterLink() {
if (this.hasLink) { if (this.hasLink) {
return environment.ui.nameSpace + this.item.link; return this.item.link;
} }
return undefined; return undefined;
} }

View File

@@ -98,7 +98,7 @@ describe('NotificationComponent', () => {
it('should have html content', () => { it('should have html content', () => {
fixture = TestBed.createComponent(NotificationComponent); fixture = TestBed.createComponent(NotificationComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
const htmlContent = '<a class="btn btn-link p-0 m-0 pb-1" href="/test"><strong>test</strong></a>'; const htmlContent = '<a class="btn btn-link p-0 m-0 pb-1" href="test"><strong>test</strong></a>';
comp.notification = { comp.notification = {
id: '1', id: '1',
type: NotificationType.Info, type: NotificationType.Info,

View File

@@ -13,6 +13,6 @@
</body> </body>
<!-- this is needed for CSR fallback --> <!-- this is needed for CSR fallback -->
<script async src="/client.js"></script> <script async src="client.js"></script>
</html> </html>

View File

@@ -6,7 +6,9 @@ $sidebar-items-width: 250px !default;
$total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width !default; $total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width !default;
/* Fonts */ /* Fonts */
$fa-font-path: "/assets/fonts" !default; // Starting this url with a caret (^) allows it to be a relative path based on UI's deployment path
// See https://github.com/angular/angular-cli/issues/12797#issuecomment-598534241
$fa-font-path: "^assets/fonts" !default;
/* Images */ /* Images */
$image-path: "../assets/images" !default; $image-path: "../assets/images" !default;

View File

@@ -6,7 +6,9 @@
color: white; color: white;
background-color: var(--bs-info); background-color: var(--bs-info);
position: relative; position: relative;
background-image: url('/assets/dspace/images/banner.jpg'); // Starting this url with a caret (^) allows it to be a relative path based on UI's deployment path
// See https://github.com/angular/angular-cli/issues/12797#issuecomment-598534241
background-image: url('^assets/dspace/images/banner.jpg');
background-size: cover; background-size: cover;
.container { .container {