mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge pull request #3358 from 4Science/task/main/DURACOM-288
Provide a setting to use a different REST url during SSR execution
This commit is contained in:
@@ -19,6 +19,9 @@ ui:
|
|||||||
|
|
||||||
# Angular Server Side Rendering (SSR) settings
|
# Angular Server Side Rendering (SSR) settings
|
||||||
ssr:
|
ssr:
|
||||||
|
# Enable request performance profiling data collection and printing the results in the server console.
|
||||||
|
# Defaults to false. Enabling in production is NOT recommended
|
||||||
|
enablePerformanceProfiler: false
|
||||||
# Whether to tell Angular to inline "critical" styles into the server-side rendered HTML.
|
# Whether to tell Angular to inline "critical" styles into the server-side rendered HTML.
|
||||||
# Determining which styles are critical is a relatively expensive operation; this option is
|
# Determining which styles are critical is a relatively expensive operation; this option is
|
||||||
# disabled (false) by default to boost server performance at the expense of loading smoothness.
|
# disabled (false) by default to boost server performance at the expense of loading smoothness.
|
||||||
@@ -35,6 +38,16 @@ ssr:
|
|||||||
# If set to true the component will be included in the HTML returned from the server side rendering.
|
# If set to true the component will be included in the HTML returned from the server side rendering.
|
||||||
# If set to false the component will not be included in the HTML returned from the server side rendering.
|
# If set to false the component will not be included in the HTML returned from the server side rendering.
|
||||||
enableBrowseComponent: false
|
enableBrowseComponent: false
|
||||||
|
# Enable state transfer from the server-side application to the client-side application.
|
||||||
|
# Defaults to true.
|
||||||
|
# Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it.
|
||||||
|
# Disabling it ensures that dynamic state information is not inadvertently cached, which can improve security and
|
||||||
|
# ensure that users always use the most up-to-date state.
|
||||||
|
transferState: true
|
||||||
|
# When a different REST base URL is used for the server-side application, the generated state contains references to
|
||||||
|
# REST resources with the internal URL configured. By default, these internal URLs are replaced with public URLs.
|
||||||
|
# Disable this setting to avoid URL replacement during SSR. In this the state is not transferred to avoid security issues.
|
||||||
|
replaceRestUrl: true
|
||||||
|
|
||||||
# The REST API server settings
|
# The REST API server settings
|
||||||
# NOTE: these settings define which (publicly available) REST API to use. They are usually
|
# NOTE: these settings define which (publicly available) REST API to use. They are usually
|
||||||
@@ -45,6 +58,9 @@ rest:
|
|||||||
port: 443
|
port: 443
|
||||||
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
|
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
|
||||||
nameSpace: /server
|
nameSpace: /server
|
||||||
|
# Provide a different REST url to be used during SSR execution. It must contain the whole url including protocol, server port and
|
||||||
|
# server namespace (uncomment to use it).
|
||||||
|
#ssrBaseUrl: http://localhost:8080/server
|
||||||
|
|
||||||
# Caching settings
|
# Caching settings
|
||||||
cache:
|
cache:
|
||||||
|
14
server.ts
14
server.ts
@@ -81,6 +81,9 @@ let anonymousCache: LRU<string, any>;
|
|||||||
// extend environment with app config for server
|
// extend environment with app config for server
|
||||||
extendEnvironmentWithAppConfig(environment, appConfig);
|
extendEnvironmentWithAppConfig(environment, appConfig);
|
||||||
|
|
||||||
|
// The REST server base URL
|
||||||
|
const REST_BASE_URL = environment.rest.ssrBaseUrl || environment.rest.baseUrl;
|
||||||
|
|
||||||
// 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() {
|
||||||
|
|
||||||
@@ -156,7 +159,7 @@ export function app() {
|
|||||||
* Proxy the sitemaps
|
* Proxy the sitemaps
|
||||||
*/
|
*/
|
||||||
router.use('/sitemap**', createProxyMiddleware({
|
router.use('/sitemap**', createProxyMiddleware({
|
||||||
target: `${environment.rest.baseUrl}/sitemaps`,
|
target: `${REST_BASE_URL}/sitemaps`,
|
||||||
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
|
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}));
|
}));
|
||||||
@@ -165,7 +168,7 @@ export function app() {
|
|||||||
* Proxy the linksets
|
* Proxy the linksets
|
||||||
*/
|
*/
|
||||||
router.use('/signposting**', createProxyMiddleware({
|
router.use('/signposting**', createProxyMiddleware({
|
||||||
target: `${environment.rest.baseUrl}`,
|
target: `${REST_BASE_URL}`,
|
||||||
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
|
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}));
|
}));
|
||||||
@@ -266,6 +269,11 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) {
|
|||||||
})
|
})
|
||||||
.then((html) => {
|
.then((html) => {
|
||||||
if (hasValue(html)) {
|
if (hasValue(html)) {
|
||||||
|
// Replace REST URL with UI URL
|
||||||
|
if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) {
|
||||||
|
html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// save server side rendered page to cache (if any are enabled)
|
// save server side rendered page to cache (if any are enabled)
|
||||||
saveToCache(req, html);
|
saveToCache(req, html);
|
||||||
if (sendToUser) {
|
if (sendToUser) {
|
||||||
@@ -623,7 +631,7 @@ function start() {
|
|||||||
* The callback function to serve health check requests
|
* The callback function to serve health check requests
|
||||||
*/
|
*/
|
||||||
function healthCheck(req, res) {
|
function healthCheck(req, res) {
|
||||||
const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`;
|
const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`;
|
||||||
axios.get(baseUrl)
|
axios.get(baseUrl)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
res.status(response.status).send(response.data);
|
res.status(response.status).send(response.data);
|
||||||
|
@@ -54,6 +54,7 @@ import {
|
|||||||
} from './app-routes';
|
} from './app-routes';
|
||||||
import { BROWSE_BY_DECORATOR_MAP } from './browse-by/browse-by-switcher/browse-by-decorator';
|
import { BROWSE_BY_DECORATOR_MAP } from './browse-by/browse-by-switcher/browse-by-decorator';
|
||||||
import { AuthInterceptor } from './core/auth/auth.interceptor';
|
import { AuthInterceptor } from './core/auth/auth.interceptor';
|
||||||
|
import { DspaceRestInterceptor } from './core/dspace-rest/dspace-rest.interceptor';
|
||||||
import { LocaleInterceptor } from './core/locale/locale.interceptor';
|
import { LocaleInterceptor } from './core/locale/locale.interceptor';
|
||||||
import { LogInterceptor } from './core/log/log.interceptor';
|
import { LogInterceptor } from './core/log/log.interceptor';
|
||||||
import {
|
import {
|
||||||
@@ -148,6 +149,11 @@ export const commonAppConfig: ApplicationConfig = {
|
|||||||
useClass: LogInterceptor,
|
useClass: LogInterceptor,
|
||||||
multi: true,
|
multi: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: DspaceRestInterceptor,
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
// register the dynamic matcher used by form. MUST be provided by the app module
|
// register the dynamic matcher used by form. MUST be provided by the app module
|
||||||
...DYNAMIC_MATCHER_PROVIDERS,
|
...DYNAMIC_MATCHER_PROVIDERS,
|
||||||
provideCore(),
|
provideCore(),
|
||||||
|
194
src/app/core/dspace-rest/dspace-rest.interceptor.spec.ts
Normal file
194
src/app/core/dspace-rest/dspace-rest.interceptor.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
HTTP_INTERCEPTORS,
|
||||||
|
HttpClient,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
HttpClientTestingModule,
|
||||||
|
HttpTestingController,
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import {
|
||||||
|
APP_CONFIG,
|
||||||
|
AppConfig,
|
||||||
|
} from '../../../config/app-config.interface';
|
||||||
|
import { DspaceRestInterceptor } from './dspace-rest.interceptor';
|
||||||
|
import { DspaceRestService } from './dspace-rest.service';
|
||||||
|
|
||||||
|
describe('DspaceRestInterceptor', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let httpClient: HttpClient;
|
||||||
|
const appConfig: Partial<AppConfig> = {
|
||||||
|
rest: {
|
||||||
|
ssl: false,
|
||||||
|
host: 'localhost',
|
||||||
|
port: 8080,
|
||||||
|
nameSpace: '/server',
|
||||||
|
baseUrl: 'http://api.example.com/server',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const appConfigWithSSR: Partial<AppConfig> = {
|
||||||
|
rest: {
|
||||||
|
ssl: false,
|
||||||
|
host: 'localhost',
|
||||||
|
port: 8080,
|
||||||
|
nameSpace: '/server',
|
||||||
|
baseUrl: 'http://api.example.com/server',
|
||||||
|
ssrBaseUrl: 'http://ssr.example.com/server',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('When SSR base URL is not set ', () => {
|
||||||
|
describe('and it\'s in the browser', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
DspaceRestService,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: DspaceRestInterceptor,
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{ provide: APP_CONFIG, useValue: appConfig },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'browser' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
httpClient = TestBed.inject(HttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the request', () => {
|
||||||
|
const url = 'http://api.example.com/server/items';
|
||||||
|
httpClient.get(url).subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(url);
|
||||||
|
expect(req.request.url).toBe(url);
|
||||||
|
req.flush({});
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and it\'s in SSR mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
DspaceRestService,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: DspaceRestInterceptor,
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{ provide: APP_CONFIG, useValue: appConfig },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
httpClient = TestBed.inject(HttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not replace the base URL', () => {
|
||||||
|
const url = 'http://api.example.com/server/items';
|
||||||
|
|
||||||
|
httpClient.get(url).subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(url);
|
||||||
|
expect(req.request.url).toBe(url);
|
||||||
|
req.flush({});
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('When SSR base URL is set ', () => {
|
||||||
|
describe('and it\'s in the browser', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
DspaceRestService,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: DspaceRestInterceptor,
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{ provide: APP_CONFIG, useValue: appConfigWithSSR },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'browser' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
httpClient = TestBed.inject(HttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the request', () => {
|
||||||
|
const url = 'http://api.example.com/server/items';
|
||||||
|
httpClient.get(url).subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(url);
|
||||||
|
expect(req.request.url).toBe(url);
|
||||||
|
req.flush({});
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and it\'s in SSR mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
DspaceRestService,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: DspaceRestInterceptor,
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{ provide: APP_CONFIG, useValue: appConfigWithSSR },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
httpClient = TestBed.inject(HttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace the base URL', () => {
|
||||||
|
const url = 'http://api.example.com/server/items';
|
||||||
|
const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl;
|
||||||
|
|
||||||
|
httpClient.get(url).subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(ssrBaseUrl + '/items');
|
||||||
|
expect(req.request.url).toBe(ssrBaseUrl + '/items');
|
||||||
|
req.flush({});
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not replace any query param containing the base URL', () => {
|
||||||
|
const url = 'http://api.example.com/server/items?url=http://api.example.com/server/item/1';
|
||||||
|
const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl;
|
||||||
|
|
||||||
|
httpClient.get(url).subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1');
|
||||||
|
expect(req.request.url).toBe(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1');
|
||||||
|
req.flush({});
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
52
src/app/core/dspace-rest/dspace-rest.interceptor.ts
Normal file
52
src/app/core/dspace-rest/dspace-rest.interceptor.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import {
|
||||||
|
HttpEvent,
|
||||||
|
HttpHandler,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpRequest,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
APP_CONFIG,
|
||||||
|
AppConfig,
|
||||||
|
} from '../../../config/app-config.interface';
|
||||||
|
import { isEmpty } from '../../shared/empty.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
/**
|
||||||
|
* This Interceptor is used to use the configured base URL for the request made during SSR execution
|
||||||
|
*/
|
||||||
|
export class DspaceRestInterceptor implements HttpInterceptor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains the configured application base URL
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected baseUrl: string;
|
||||||
|
protected ssrBaseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
||||||
|
@Inject(PLATFORM_ID) private platformId: string,
|
||||||
|
) {
|
||||||
|
this.baseUrl = this.appConfig.rest.baseUrl;
|
||||||
|
this.ssrBaseUrl = this.appConfig.rest.ssrBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
if (isPlatformBrowser(this.platformId) || isEmpty(this.ssrBaseUrl) || this.baseUrl === this.ssrBaseUrl) {
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different SSR Base URL specified so replace it in the current request url
|
||||||
|
const url = request.url.replace(this.baseUrl, this.ssrBaseUrl);
|
||||||
|
const newRequest: HttpRequest<any> = request.clone({ url });
|
||||||
|
return next.handle(newRequest);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment.test';
|
||||||
import { ServerHardRedirectService } from './server-hard-redirect.service';
|
import { ServerHardRedirectService } from './server-hard-redirect.service';
|
||||||
|
|
||||||
describe('ServerHardRedirectService', () => {
|
describe('ServerHardRedirectService', () => {
|
||||||
@@ -7,7 +8,7 @@ describe('ServerHardRedirectService', () => {
|
|||||||
const mockRequest = jasmine.createSpyObj(['get']);
|
const mockRequest = jasmine.createSpyObj(['get']);
|
||||||
const mockResponse = jasmine.createSpyObj(['redirect', 'end']);
|
const mockResponse = jasmine.createSpyObj(['redirect', 'end']);
|
||||||
|
|
||||||
const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse);
|
let service: ServerHardRedirectService = new ServerHardRedirectService(environment, mockRequest, mockResponse);
|
||||||
const origin = 'https://test-host.com:4000';
|
const origin = 'https://test-host.com:4000';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -68,4 +69,23 @@ describe('ServerHardRedirectService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when SSR base url is set', () => {
|
||||||
|
const redirect = 'https://private-url:4000/server/api/bitstreams/uuid';
|
||||||
|
const replacedUrl = 'https://public-url/server/api/bitstreams/uuid';
|
||||||
|
const environmentWithSSRUrl: any = { ...environment, ...{ ...environment.rest, rest: {
|
||||||
|
ssrBaseUrl: 'https://private-url:4000/server',
|
||||||
|
baseUrl: 'https://public-url/server',
|
||||||
|
} } };
|
||||||
|
service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.redirect(redirect);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should perform a 302 redirect', () => {
|
||||||
|
expect(mockResponse.redirect).toHaveBeenCalledWith(302, replacedUrl);
|
||||||
|
expect(mockResponse.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -7,10 +7,15 @@ import {
|
|||||||
Response,
|
Response,
|
||||||
} from 'express';
|
} from 'express';
|
||||||
|
|
||||||
|
import {
|
||||||
|
APP_CONFIG,
|
||||||
|
AppConfig,
|
||||||
|
} from '../../../config/app-config.interface';
|
||||||
import {
|
import {
|
||||||
REQUEST,
|
REQUEST,
|
||||||
RESPONSE,
|
RESPONSE,
|
||||||
} from '../../../express.tokens';
|
} from '../../../express.tokens';
|
||||||
|
import { isNotEmpty } from '../../shared/empty.util';
|
||||||
import { HardRedirectService } from './hard-redirect.service';
|
import { HardRedirectService } from './hard-redirect.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +25,7 @@ import { HardRedirectService } from './hard-redirect.service';
|
|||||||
export class ServerHardRedirectService extends HardRedirectService {
|
export class ServerHardRedirectService extends HardRedirectService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
||||||
@Inject(REQUEST) protected req: Request,
|
@Inject(REQUEST) protected req: Request,
|
||||||
@Inject(RESPONSE) protected res: Response,
|
@Inject(RESPONSE) protected res: Response,
|
||||||
) {
|
) {
|
||||||
@@ -35,17 +41,22 @@ export class ServerHardRedirectService extends HardRedirectService {
|
|||||||
* optional HTTP status code to use for redirect (default = 302, which is a temporary redirect)
|
* optional HTTP status code to use for redirect (default = 302, which is a temporary redirect)
|
||||||
*/
|
*/
|
||||||
redirect(url: string, statusCode?: number) {
|
redirect(url: string, statusCode?: number) {
|
||||||
|
|
||||||
if (url === this.req.url) {
|
if (url === this.req.url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let redirectUrl = url;
|
||||||
|
// If redirect url contains SSR base url then replace with public base url
|
||||||
|
if (isNotEmpty(this.appConfig.rest.ssrBaseUrl) && this.appConfig.rest.baseUrl !== this.appConfig.rest.ssrBaseUrl) {
|
||||||
|
redirectUrl = url.replace(this.appConfig.rest.ssrBaseUrl, this.appConfig.rest.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.res.finished) {
|
if (this.res.finished) {
|
||||||
const req: any = this.req;
|
const req: any = this.req;
|
||||||
req._r_count = (req._r_count || 0) + 1;
|
req._r_count = (req._r_count || 0) + 1;
|
||||||
|
|
||||||
console.warn('Attempted to redirect on a finished response. From',
|
console.warn('Attempted to redirect on a finished response. From',
|
||||||
this.req.url, 'to', url);
|
this.req.url, 'to', redirectUrl);
|
||||||
|
|
||||||
if (req._r_count > 10) {
|
if (req._r_count > 10) {
|
||||||
console.error('Detected a redirection loop. killing the nodejs process');
|
console.error('Detected a redirection loop. killing the nodejs process');
|
||||||
@@ -59,9 +70,9 @@ export class ServerHardRedirectService extends HardRedirectService {
|
|||||||
status = 302;
|
status = 302;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`);
|
console.info(`Redirecting from ${this.req.url} to ${redirectUrl} with ${status}`);
|
||||||
|
|
||||||
this.res.redirect(status, url);
|
this.res.redirect(status, redirectUrl);
|
||||||
this.res.end();
|
this.res.end();
|
||||||
// I haven't found a way to correctly stop Angular rendering.
|
// I haven't found a way to correctly stop Angular rendering.
|
||||||
// So we just let it end its work, though we have already closed
|
// So we just let it end its work, though we have already closed
|
||||||
|
@@ -16,6 +16,6 @@ import { DomSanitizer } from '@angular/platform-browser';
|
|||||||
export class SafeUrlPipe implements PipeTransform {
|
export class SafeUrlPipe implements PipeTransform {
|
||||||
constructor(private domSanitizer: DomSanitizer) { }
|
constructor(private domSanitizer: DomSanitizer) { }
|
||||||
transform(url) {
|
transform(url) {
|
||||||
return this.domSanitizer.bypassSecurityTrustResourceUrl(url);
|
return url == null ? null : this.domSanitizer.bypassSecurityTrustResourceUrl(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@ import {
|
|||||||
DebugElement,
|
DebugElement,
|
||||||
Pipe,
|
Pipe,
|
||||||
PipeTransform,
|
PipeTransform,
|
||||||
|
PLATFORM_ID,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
@@ -48,7 +49,9 @@ describe('ThumbnailComponent', () => {
|
|||||||
let authService;
|
let authService;
|
||||||
let authorizationService;
|
let authorizationService;
|
||||||
let fileService;
|
let fileService;
|
||||||
|
let spy;
|
||||||
|
|
||||||
|
describe('when platform is browser', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
authService = jasmine.createSpyObj('AuthService', {
|
authService = jasmine.createSpyObj('AuthService', {
|
||||||
isAuthenticated: observableOf(true),
|
isAuthenticated: observableOf(true),
|
||||||
@@ -74,6 +77,7 @@ describe('ThumbnailComponent', () => {
|
|||||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||||
{ provide: FileService, useValue: fileService },
|
{ provide: FileService, useValue: fileService },
|
||||||
{ provide: ThemeService, useValue: getMockThemeService() },
|
{ provide: ThemeService, useValue: getMockThemeService() },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'browser' },
|
||||||
],
|
],
|
||||||
}).overrideComponent(ThumbnailComponent, {
|
}).overrideComponent(ThumbnailComponent, {
|
||||||
add: {
|
add: {
|
||||||
@@ -361,4 +365,65 @@ describe('ThumbnailComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when platform is server', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
|
||||||
|
authService = jasmine.createSpyObj('AuthService', {
|
||||||
|
isAuthenticated: observableOf(true),
|
||||||
|
});
|
||||||
|
authorizationService = jasmine.createSpyObj('AuthorizationService', {
|
||||||
|
isAuthorized: observableOf(true),
|
||||||
|
});
|
||||||
|
fileService = jasmine.createSpyObj('FileService', {
|
||||||
|
retrieveFileDownloadLink: null,
|
||||||
|
});
|
||||||
|
fileService.retrieveFileDownloadLink.and.callFake((url) => observableOf(`${url}?authentication-token=fake`));
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
ThumbnailComponent,
|
||||||
|
SafeUrlPipe,
|
||||||
|
MockTranslatePipe,
|
||||||
|
VarDirective,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: AuthService, useValue: authService },
|
||||||
|
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||||
|
{ provide: FileService, useValue: fileService },
|
||||||
|
{ provide: ThemeService, useValue: getMockThemeService() },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
],
|
||||||
|
}).overrideComponent(ThumbnailComponent, {
|
||||||
|
add: {
|
||||||
|
imports: [MockTranslatePipe],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ThumbnailComponent);
|
||||||
|
spyOn(fixture.componentInstance, 'setSrc').and.callThrough();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
authService = TestBed.inject(AuthService);
|
||||||
|
|
||||||
|
comp = fixture.componentInstance; // ThumbnailComponent test instance
|
||||||
|
de = fixture.debugElement.query(By.css('div.thumbnail'));
|
||||||
|
el = de.nativeElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start out with isLoading$ true', () => {
|
||||||
|
expect(comp.isLoading).toBeTrue();
|
||||||
|
expect(de.query(By.css('ds-loading'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call setSrc', () => {
|
||||||
|
expect(comp.setSrc).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,8 +1,13 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import {
|
||||||
|
CommonModule,
|
||||||
|
isPlatformBrowser,
|
||||||
|
} from '@angular/common';
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
Inject,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
|
PLATFORM_ID,
|
||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
@@ -76,6 +81,7 @@ export class ThumbnailComponent implements OnChanges {
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(PLATFORM_ID) private platformID: any,
|
||||||
protected auth: AuthService,
|
protected auth: AuthService,
|
||||||
protected authorizationService: AuthorizationDataService,
|
protected authorizationService: AuthorizationDataService,
|
||||||
protected fileService: FileService,
|
protected fileService: FileService,
|
||||||
@@ -87,6 +93,7 @@ export class ThumbnailComponent implements OnChanges {
|
|||||||
* Use a default image if no actual image is available.
|
* Use a default image if no actual image is available.
|
||||||
*/
|
*/
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (isPlatformBrowser(this.platformID)) {
|
||||||
if (hasNoValue(this.thumbnail)) {
|
if (hasNoValue(this.thumbnail)) {
|
||||||
this.setSrc(this.defaultImage);
|
this.setSrc(this.defaultImage);
|
||||||
return;
|
return;
|
||||||
@@ -99,6 +106,7 @@ export class ThumbnailComponent implements OnChanges {
|
|||||||
this.setSrc(this.defaultImage);
|
this.setSrc(this.defaultImage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current thumbnail Bitstream
|
* The current thumbnail Bitstream
|
||||||
|
@@ -236,6 +236,7 @@ export const buildAppConfig = (destConfigPath?: string): AppConfig => {
|
|||||||
appConfig.rest.port = isNotEmpty(ENV('REST_PORT', true)) ? getNumberFromString(ENV('REST_PORT', true)) : appConfig.rest.port;
|
appConfig.rest.port = isNotEmpty(ENV('REST_PORT', true)) ? getNumberFromString(ENV('REST_PORT', true)) : appConfig.rest.port;
|
||||||
appConfig.rest.nameSpace = isNotEmpty(ENV('REST_NAMESPACE', true)) ? ENV('REST_NAMESPACE', true) : appConfig.rest.nameSpace;
|
appConfig.rest.nameSpace = isNotEmpty(ENV('REST_NAMESPACE', true)) ? ENV('REST_NAMESPACE', true) : appConfig.rest.nameSpace;
|
||||||
appConfig.rest.ssl = isNotEmpty(ENV('REST_SSL', true)) ? getBooleanFromString(ENV('REST_SSL', true)) : appConfig.rest.ssl;
|
appConfig.rest.ssl = isNotEmpty(ENV('REST_SSL', true)) ? getBooleanFromString(ENV('REST_SSL', true)) : appConfig.rest.ssl;
|
||||||
|
appConfig.rest.ssrBaseUrl = isNotEmpty(ENV('REST_SSRBASEURL', true)) ? ENV('REST_SSRBASEURL', true) : appConfig.rest.ssrBaseUrl;
|
||||||
|
|
||||||
// apply build defined production
|
// apply build defined production
|
||||||
appConfig.production = env === 'production';
|
appConfig.production = env === 'production';
|
||||||
|
@@ -6,4 +6,8 @@ export class ServerConfig implements Config {
|
|||||||
public port: number;
|
public port: number;
|
||||||
public nameSpace: string;
|
public nameSpace: string;
|
||||||
public baseUrl?: string;
|
public baseUrl?: string;
|
||||||
|
public ssrBaseUrl?: string;
|
||||||
|
// This boolean will be automatically set on server startup based on whether "baseUrl" and "ssrBaseUrl"
|
||||||
|
// have different values.
|
||||||
|
public hasSsrBaseUrl?: boolean;
|
||||||
}
|
}
|
||||||
|
@@ -21,6 +21,23 @@ export interface SSRConfig extends Config {
|
|||||||
*/
|
*/
|
||||||
inlineCriticalCss: boolean;
|
inlineCriticalCss: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable state transfer from the server-side application to the client-side application.
|
||||||
|
* Defaults to true.
|
||||||
|
*
|
||||||
|
* Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it.
|
||||||
|
* Disabling it ensures that dynamic state information is not inadvertently cached, which can improve security and
|
||||||
|
* ensure that users always use the most up-to-date state.
|
||||||
|
*/
|
||||||
|
transferState: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a different REST base URL is used for the server-side application, the generated state contains references to
|
||||||
|
* REST resources with the internal URL configured. By default, these internal URLs are replaced with public URLs.
|
||||||
|
* Disable this setting to avoid URL replacement during SSR. In this the state is not transferred to avoid security issues.
|
||||||
|
*/
|
||||||
|
replaceRestUrl: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paths to enable SSR for. Defaults to the home page and paths in the sitemap.
|
* Paths to enable SSR for. Defaults to the home page and paths in the sitemap.
|
||||||
*/
|
*/
|
||||||
|
@@ -8,6 +8,8 @@ export const environment: Partial<BuildConfig> = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
enablePerformanceProfiler: false,
|
enablePerformanceProfiler: false,
|
||||||
inlineCriticalCss: false,
|
inlineCriticalCss: false,
|
||||||
|
transferState: true,
|
||||||
|
replaceRestUrl: true,
|
||||||
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ],
|
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ],
|
||||||
enableSearchComponent: false,
|
enableSearchComponent: false,
|
||||||
enableBrowseComponent: false,
|
enableBrowseComponent: false,
|
||||||
|
@@ -12,6 +12,8 @@ export const environment: BuildConfig = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
enablePerformanceProfiler: false,
|
enablePerformanceProfiler: false,
|
||||||
inlineCriticalCss: false,
|
inlineCriticalCss: false,
|
||||||
|
transferState: true,
|
||||||
|
replaceRestUrl: false,
|
||||||
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ],
|
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ],
|
||||||
enableSearchComponent: false,
|
enableSearchComponent: false,
|
||||||
enableBrowseComponent: false,
|
enableBrowseComponent: false,
|
||||||
|
@@ -13,6 +13,8 @@ export const environment: Partial<BuildConfig> = {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
enablePerformanceProfiler: false,
|
enablePerformanceProfiler: false,
|
||||||
inlineCriticalCss: false,
|
inlineCriticalCss: false,
|
||||||
|
transferState: true,
|
||||||
|
replaceRestUrl: false,
|
||||||
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ],
|
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ],
|
||||||
enableSearchComponent: false,
|
enableSearchComponent: false,
|
||||||
enableBrowseComponent: false,
|
enableBrowseComponent: false,
|
||||||
|
@@ -54,6 +54,7 @@ import {
|
|||||||
APP_CONFIG_STATE,
|
APP_CONFIG_STATE,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
} from '../../config/app-config.interface';
|
} from '../../config/app-config.interface';
|
||||||
|
import { BuildConfig } from '../../config/build-config.interface';
|
||||||
import { extendEnvironmentWithAppConfig } from '../../config/config.util';
|
import { extendEnvironmentWithAppConfig } from '../../config/config.util';
|
||||||
import { DefaultAppConfig } from '../../config/default-app-config';
|
import { DefaultAppConfig } from '../../config/default-app-config';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
@@ -70,7 +71,7 @@ export class BrowserInitService extends InitService {
|
|||||||
protected store: Store<AppState>,
|
protected store: Store<AppState>,
|
||||||
protected correlationIdService: CorrelationIdService,
|
protected correlationIdService: CorrelationIdService,
|
||||||
protected transferState: TransferState,
|
protected transferState: TransferState,
|
||||||
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
@Inject(APP_CONFIG) protected appConfig: BuildConfig,
|
||||||
protected translate: TranslateService,
|
protected translate: TranslateService,
|
||||||
protected localeService: LocaleService,
|
protected localeService: LocaleService,
|
||||||
protected angulartics2DSpace: Angulartics2DSpace,
|
protected angulartics2DSpace: Angulartics2DSpace,
|
||||||
@@ -144,6 +145,8 @@ export class BrowserInitService extends InitService {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private async loadAppState(): Promise<boolean> {
|
private async loadAppState(): Promise<boolean> {
|
||||||
|
// The app state can be transferred only when SSR and CSR are using the same base url for the REST API
|
||||||
|
if (this.appConfig.ssr.transferState) {
|
||||||
const state = this.transferState.get<any>(InitService.NGRX_STATE, null);
|
const state = this.transferState.get<any>(InitService.NGRX_STATE, null);
|
||||||
this.transferState.remove(InitService.NGRX_STATE);
|
this.transferState.remove(InitService.NGRX_STATE);
|
||||||
this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state));
|
this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state));
|
||||||
@@ -153,6 +156,9 @@ export class BrowserInitService extends InitService {
|
|||||||
map(() => true),
|
map(() => true),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private trackAuthTokenExpiration(): void {
|
private trackAuthTokenExpiration(): void {
|
||||||
|
@@ -21,6 +21,10 @@ import { LocaleService } from '../../app/core/locale/locale.service';
|
|||||||
import { HeadTagService } from '../../app/core/metadata/head-tag.service';
|
import { HeadTagService } from '../../app/core/metadata/head-tag.service';
|
||||||
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
|
import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service';
|
||||||
import { InitService } from '../../app/init.service';
|
import { InitService } from '../../app/init.service';
|
||||||
|
import {
|
||||||
|
isEmpty,
|
||||||
|
isNotEmpty,
|
||||||
|
} from '../../app/shared/empty.util';
|
||||||
import { MenuService } from '../../app/shared/menu/menu.service';
|
import { MenuService } from '../../app/shared/menu/menu.service';
|
||||||
import { ThemeService } from '../../app/shared/theme-support/theme.service';
|
import { ThemeService } from '../../app/shared/theme-support/theme.service';
|
||||||
import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider';
|
import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider';
|
||||||
@@ -29,6 +33,7 @@ import {
|
|||||||
APP_CONFIG_STATE,
|
APP_CONFIG_STATE,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
} from '../../config/app-config.interface';
|
} from '../../config/app-config.interface';
|
||||||
|
import { BuildConfig } from '../../config/build-config.interface';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +45,7 @@ export class ServerInitService extends InitService {
|
|||||||
protected store: Store<AppState>,
|
protected store: Store<AppState>,
|
||||||
protected correlationIdService: CorrelationIdService,
|
protected correlationIdService: CorrelationIdService,
|
||||||
protected transferState: TransferState,
|
protected transferState: TransferState,
|
||||||
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
@Inject(APP_CONFIG) protected appConfig: BuildConfig,
|
||||||
protected translate: TranslateService,
|
protected translate: TranslateService,
|
||||||
protected localeService: LocaleService,
|
protected localeService: LocaleService,
|
||||||
protected angulartics2DSpace: Angulartics2DSpace,
|
protected angulartics2DSpace: Angulartics2DSpace,
|
||||||
@@ -89,6 +94,7 @@ export class ServerInitService extends InitService {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private saveAppState() {
|
private saveAppState() {
|
||||||
|
if (this.appConfig.ssr.transferState && (isEmpty(this.appConfig.rest.ssrBaseUrl) || this.appConfig.ssr.replaceRestUrl)) {
|
||||||
this.transferState.onSerialize(InitService.NGRX_STATE, () => {
|
this.transferState.onSerialize(InitService.NGRX_STATE, () => {
|
||||||
let state;
|
let state;
|
||||||
this.store.pipe(take(1)).subscribe((saveState: any) => {
|
this.store.pipe(take(1)).subscribe((saveState: any) => {
|
||||||
@@ -98,8 +104,17 @@ export class ServerInitService extends InitService {
|
|||||||
return state;
|
return state;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private saveAppConfigForCSR(): void {
|
private saveAppConfigForCSR(): void {
|
||||||
|
if (isNotEmpty(environment.rest.ssrBaseUrl) && environment.rest.baseUrl !== environment.rest.ssrBaseUrl) {
|
||||||
|
// Avoid to transfer ssrBaseUrl in order to prevent security issues
|
||||||
|
const config: AppConfig = Object.assign({}, environment as AppConfig, {
|
||||||
|
rest: Object.assign({}, environment.rest, { ssrBaseUrl: '', hasSsrBaseUrl: true }),
|
||||||
|
});
|
||||||
|
this.transferState.set<AppConfig>(APP_CONFIG_STATE, config);
|
||||||
|
} else {
|
||||||
this.transferState.set<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
|
this.transferState.set<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user