diff --git a/config/config.example.yml b/config/config.example.yml index af04859201..500c2c476a 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -32,12 +32,60 @@ cache: # NOTE: how long should objects be cached for by default msToLive: default: 900000 # 15 minutes - control: max-age=60 # revalidate browser + # Default 'Cache-Control' HTTP Header to set for all static content (including compiled *.js files) + # Defaults to max-age=604,800 seconds (one week). This lets a user's browser know that it can cache these + # files for one week, after which they will be "stale" and need to be redownloaded. + # NOTE: When updates are made to compiled *.js files, it will automatically bypass this browser cache, because + # all compiled *.js files include a unique hash in their name which updates when content is modified. + control: max-age=604800 # revalidate browser autoSync: defaultTime: 0 maxBufferSize: 100 timePerMethod: PATCH: 3 # time in seconds + # In-memory cache(s) of server-side rendered pages. These caches will store the most recently accessed public pages. + # Pages are automatically added/dropped from these caches based on how recently they have been used. + # Restarting the app clears all page caches. + # NOTE: To control the cache size, use the "max" setting. Keep in mind, individual cached pages are usually small (<100KB). + # Enabling *both* caches will mean that a page may be cached twice, once in each cache (but may expire at different times via timeToLive). + serverSide: + # Set to true to see all cache hits/misses/refreshes in your console logs. Useful for debugging SSR caching issues. + debug: false + # When enabled (i.e. max > 0), known bots will be sent pages from a server side cache specific for bots. + # (Keep in mind, bot detection cannot be guarranteed. It is possible some bots will bypass this cache.) + botCache: + # Maximum number of pages to cache for known bots. Set to zero (0) to disable server side caching for bots. + # Default is 1000, which means the 1000 most recently accessed public pages will be cached. + # As all pages are cached in server memory, increasing this value will increase memory needs. + # Individual cached pages are usually small (<100KB), so max=1000 should only require ~100MB of memory. + max: 1000 + # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached + # copy is automatically refreshed on the next request. + # NOTE: For the bot cache, this setting may impact how quickly search engine bots will index new content on your site. + # For example, setting this to one week may mean that search engine bots may not find all new content for one week. + timeToLive: 86400000 # 1 day + # When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page + # behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive). + # When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache). + # This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR. + allowStale: true + # When enabled (i.e. max > 0), all anonymous users will be sent pages from a server side cache. + # This allows anonymous users to interact more quickly with the site, but also means they may see slightly + # outdated content (based on timeToLive) + anonymousCache: + # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. + # As all pages are cached in server memory, increasing this value will increase memory needs. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + max: 0 + # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached + # copy is automatically refreshed on the next request. + # NOTE: For the anonymous cache, it is recommended to keep this value low to avoid anonymous users seeing outdated content. + timeToLive: 10000 # 10 seconds + # When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page + # behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive). + # When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache). + # This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR. + allowStale: true # Authentication settings auth: diff --git a/package.json b/package.json index dcb629a331..184f1a5647 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "fast-json-patch": "^3.0.0-1", "filesize": "^6.1.0", "http-proxy-middleware": "^1.0.5", + "isbot": "^3.6.5", "js-cookie": "2.2.1", "js-yaml": "^4.1.0", "json5": "^2.2.2", @@ -106,6 +107,7 @@ "jwt-decode": "^3.1.2", "klaro": "^0.7.18", "lodash": "^4.17.21", + "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", "markdown-it-mathjax3": "^4.3.1", "mirador": "^3.3.0", diff --git a/server.ts b/server.ts index ecbbb982d4..ba0c8fd7b2 100644 --- a/server.ts +++ b/server.ts @@ -28,6 +28,8 @@ import * as expressStaticGzip from 'express-static-gzip'; /* eslint-enable import/no-namespace */ import axios from 'axios'; +import LRU from 'lru-cache'; +import isbot from 'isbot'; import { createCertificate } from 'pem'; import { createServer } from 'https'; import { json } from 'body-parser'; @@ -53,6 +55,8 @@ import { buildAppConfig } from './src/config/config.server'; import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; import { logStartupMessage } from './startup-message'; +import { TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; + /* * Set path for the browser application's dist folder @@ -67,6 +71,12 @@ const cookieParser = require('cookie-parser'); const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); +// cache of SSR pages for known bots, only enabled in production mode +let botCache: LRU; + +// cache of SSR pages for anonymous users. Disabled by default, and only available in production mode +let anonymousCache: LRU; + // extend environment with app config for server extendEnvironmentWithAppConfig(environment, appConfig); @@ -87,10 +97,12 @@ export function app() { /* * If production mode is enabled in the environment file: * - Enable Angular's production mode + * - Initialize caching of SSR rendered pages (if enabled in config.yml) * - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression) */ if (environment.production) { enableProdMode(); + initCache(); server.use(compression({ // only compress responses we've marked as SSR // otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin @@ -106,13 +118,13 @@ export function app() { /* * Add cookie parser middleware - * See [morgan](https://github.com/expressjs/cookie-parser) + * See [cookie-parser](https://github.com/expressjs/cookie-parser) */ server.use(cookieParser()); /* - * Add parser for request bodies - * See [morgan](https://github.com/expressjs/body-parser) + * Add JSON parser for request bodies + * See [body-parser](https://github.com/expressjs/body-parser) */ server.use(json()); @@ -186,7 +198,7 @@ export function app() { * Serve static resources (images, i18n messages, …) * Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip) */ - router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, { + router.get('*.*', addCacheControl, expressStaticGzip(DIST_FOLDER, { index: false, enableBrotli: true, orderPreference: ['br', 'gzip'], @@ -202,8 +214,11 @@ export function app() { */ server.get('/app/health', healthCheck); - // Register the ngApp callback function to handle incoming requests - router.get('*', ngApp); + /** + * Default sending all incoming requests to ngApp() function, after first checking for a cached + * copy of the page (see cacheCheck()) + */ + router.get('*', cacheCheck, ngApp); server.use(environment.ui.nameSpace, router); @@ -215,60 +230,249 @@ export function app() { */ function ngApp(req, res) { if (environment.universal.preboot) { - res.render(indexHtml, { - req, - res, - preboot: environment.universal.preboot, - async: environment.universal.async, - time: environment.universal.time, - baseUrl: environment.ui.nameSpace, - originUrl: environment.ui.baseUrl, - requestUrl: req.originalUrl, - providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] - }, (err, data) => { - if (hasNoValue(err) && hasValue(data)) { - res.locals.ssr = true; // mark response as SSR - res.send(data); - } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { - // When this error occurs we can't fall back to CSR because the response has already been - // sent. These errors occur for various reasons in universal, not all of which are in our - // control to solve. - console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client'); - } else { - console.warn('Error in SSR, serving for direct CSR.'); - if (hasValue(err)) { - console.warn('Error details : ', err); - } - res.render(indexHtml, { - req, - providers: [{ - provide: APP_BASE_HREF, - useValue: req.baseUrl - }] - }); - } - }); + // Render the page to user via SSR (server side rendering) + serverSideRender(req, res); } else { // If preboot is disabled, just serve the client - console.log('Universal off, serving for direct CSR'); - res.render(indexHtml, { - req, - providers: [{ - provide: APP_BASE_HREF, - useValue: req.baseUrl - }] + console.log('Universal off, serving for direct client-side rendering (CSR)'); + clientSideRender(req, res); + } +} + +/** + * Render page content on server side using Angular SSR. By default this page content is + * returned to the user. + * @param req current request + * @param res current response + * @param sendToUser if true (default), send the rendered content to the user. + * If false, then only save this rendered content to the in-memory cache (to refresh cache). + */ +function serverSideRender(req, res, sendToUser: boolean = true) { + // Render the page via SSR (server side rendering) + res.render(indexHtml, { + req, + res, + preboot: environment.universal.preboot, + async: environment.universal.async, + time: environment.universal.time, + baseUrl: environment.ui.nameSpace, + originUrl: environment.ui.baseUrl, + requestUrl: req.originalUrl, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] + }, (err, data) => { + if (hasNoValue(err) && hasValue(data)) { + // save server side rendered page to cache (if any are enabled) + saveToCache(req, data); + if (sendToUser) { + res.locals.ssr = true; // mark response as SSR (enables text compression) + // send rendered page to user + res.send(data); + } + } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { + // When this error occurs we can't fall back to CSR because the response has already been + // sent. These errors occur for various reasons in universal, not all of which are in our + // control to solve. + console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client'); + } else { + console.warn('Error in server-side rendering (SSR)'); + if (hasValue(err)) { + console.warn('Error details : ', err); + } + if (sendToUser) { + console.warn('Falling back to serving direct client-side rendering (CSR).'); + clientSideRender(req, res); + } + } + }); +} + +/** + * Send back response to user to trigger direct client-side rendering (CSR) + * @param req current request + * @param res current response + */ +function clientSideRender(req, res) { + res.render(indexHtml, { + req, + providers: [{ + provide: APP_BASE_HREF, + useValue: req.baseUrl + }] + }); +} + + +/* + * Adds a Cache-Control HTTP header to the response. + * The cache control value can be configured in the config.*.yml file + * Defaults to max-age=604,800 seconds (1 week) + */ +function addCacheControl(req, res, next) { + // instruct browser to revalidate + res.header('Cache-Control', environment.cache.control || 'max-age=604800'); + next(); +} + +/* + * Initialize server-side caching of pages rendered via SSR. + */ +function initCache() { + if (botCacheEnabled()) { + // Initialize a new "least-recently-used" item cache (where least recently used pages are removed first) + // See https://www.npmjs.com/package/lru-cache + // When enabled, each page defaults to expiring after 1 day + botCache = new LRU( { + max: environment.cache.serverSide.botCache.max, + ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day + allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting + }); + } + + if (anonymousCacheEnabled()) { + // NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive + // may expire pages more frequently. + // When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content) + anonymousCache = new LRU( { + max: environment.cache.serverSide.anonymousCache.max, + ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds + allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting }); } } -/* - * Adds a cache control header to the response - * The cache control value can be configured in the environments file and defaults to max-age=60 +/** + * Return whether bot-specific server side caching is enabled in configuration. */ -function cacheControl(req, res, next) { - // instruct browser to revalidate - res.header('Cache-Control', environment.cache.control || 'max-age=60'); - next(); +function botCacheEnabled(): boolean { + // Caching is only enabled if SSR is enabled AND + // "max" pages to cache is greater than zero + return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0); +} + +/** + * Return whether anonymous user server side caching is enabled in configuration. + */ +function anonymousCacheEnabled(): boolean { + // Caching is only enabled if SSR is enabled AND + // "max" pages to cache is greater than zero + return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0); +} + +/** + * Check if the currently requested page is in our server-side, in-memory cache. + * Caching is ONLY done for SSR requests. Pages are cached base on their path (e.g. /home or /search?query=test) + */ +function cacheCheck(req, res, next) { + // Cached copy of page (if found) + let cachedCopy; + + // If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page. + if (botCacheEnabled() && isbot(req.get('user-agent'))) { + cachedCopy = checkCacheForRequest('bot', botCache, req, res); + } else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) { + cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res); + } + + // If cached copy exists, return it to the user. + if (cachedCopy) { + res.locals.ssr = true; // mark response as SSR-generated (enables text compression) + res.send(cachedCopy); + + // Tell Express to skip all other handlers for this path + // This ensures we don't try to re-render the page since we've already returned the cached copy + next('router'); + } else { + // If nothing found in cache, just continue with next handler + // (This should send the request on to the handler that rerenders the page via SSR + next(); + } +} + +/** + * Checks if the current request (i.e. page) is found in the given cache. If it is found, + * the cached copy is returned. When found, this method also triggers a re-render via + * SSR if the cached copy is now expired (i.e. timeToLive has passed for this cached copy). + * @param cacheName name of cache (just useful for debug logging) + * @param cache LRU cache to check + * @param req current request to look for in the cache + * @param res current response + * @returns cached copy (if found) or undefined (if not found) + */ +function checkCacheForRequest(cacheName: string, cache: LRU, req, res): any { + // Get the cache key for this request + const key = getCacheKey(req); + + // Check if this page is in our cache + let cachedCopy = cache.get(key); + if (cachedCopy) { + if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); } + + // Check if cached copy is expired (If expired, the key will now be gone from cache) + // NOTE: This will only occur when "allowStale=true", as it means the "get(key)" above returned a stale value. + if (!cache.has(key)) { + if (environment.cache.serverSide.debug) { console.log(`CACHE EXPIRED FOR ${key} in ${cacheName} cache. Re-rendering...`); } + // Update cached copy by rerendering server-side + // NOTE: In this scenario the currently cached copy will be returned to the current user. + // This re-render is peformed behind the scenes to update cached copy for next user. + serverSideRender(req, res, false); + } + } else { + if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); } + } + + // return page from cache + return cachedCopy; +} + +/** + * Create a cache key from the current request. + * The cache key is the URL path (NOTE: this key will also include any querystring params). + * E.g. "/home" or "/search?query=test" + * @param req current request + * @returns cache key to use for this page + */ +function getCacheKey(req): string { + // NOTE: this will return the URL path *without* any baseUrl + return req.url; +} + +/** + * Save page to server side cache(s), if enabled. If caching is not enabled or a user is authenticated, this is a noop + * If multiple caches are enabled, the page will be saved to any caches where it does not yet exist (or is expired). + * (This minimizes the number of times we need to run SSR on the same page.) + * @param req current page request + * @param page page data to save to cache + */ +function saveToCache(req, page: any) { + // Only cache if no one is currently authenticated. This means ONLY public pages can be cached. + // NOTE: It's not safe to save page data to the cache when a user is authenticated. In that situation, + // the page may include sensitive or user-specific materials. As the cache is shared across all users, it can only contain public info. + if (!isUserAuthenticated(req)) { + const key = getCacheKey(req); + // Avoid caching "/reload/[random]" paths (these are hard refreshes after logout) + if (key.startsWith('/reload')) { return; } + + // If bot cache is enabled, save it to that cache if it doesn't exist or is expired + // (NOTE: has() will return false if page is expired in cache) + if (botCacheEnabled() && !botCache.has(key)) { + botCache.set(key, page); + if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in bot cache.`); } + } + + // If anonymous cache is enabled, save it to that cache if it doesn't exist or is expired + if (anonymousCacheEnabled() && !anonymousCache.has(key)) { + anonymousCache.set(key, page); + if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in anonymous cache.`); } + } + } +} + +/** + * Whether a user is authenticated or not + */ +function isUserAuthenticated(req): boolean { + // Check whether our DSpace authentication Cookie exists or not + return req.cookies[TOKENITEM]; } /* diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts index 704922c5b5..063aad612f 100644 --- a/src/app/core/auth/auth-request.service.spec.ts +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -11,6 +11,7 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import objectContaining = jasmine.objectContaining; import { AuthStatus } from './models/auth-status.model'; import { RestRequestMethod } from '../data/rest-request-method'; +import { Observable, of as observableOf } from 'rxjs'; describe(`AuthRequestService`, () => { let halService: HALEndpointService; @@ -34,8 +35,8 @@ describe(`AuthRequestService`, () => { super(hes, rs, rdbs); } - protected createShortLivedTokenRequest(href: string): PostRequest { - return new PostRequest(this.requestService.generateRequestId(), href); + protected createShortLivedTokenRequest(href: string): Observable { + return observableOf(new PostRequest(this.requestService.generateRequestId(), href)); } } diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 5c0c3340c7..7c1f17dec2 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -100,14 +100,12 @@ export abstract class AuthRequestService { ); } /** - * Factory function to create the request object to send. This needs to be a POST client side and - * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow - * only the server IP to send a GET to this endpoint. + * Factory function to create the request object to send. * * @param href The href to send the request to * @protected */ - protected abstract createShortLivedTokenRequest(href: string): GetRequest | PostRequest; + protected abstract createShortLivedTokenRequest(href: string): Observable; /** * Send a request to retrieve a short-lived token which provides download access of restricted files @@ -117,7 +115,7 @@ export abstract class AuthRequestService { filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()), - map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)), + switchMap((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)), tap((request: RestRequest) => this.requestService.send(request)), switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), getFirstCompletedRemoteData(), diff --git a/src/app/core/auth/browser-auth-request.service.spec.ts b/src/app/core/auth/browser-auth-request.service.spec.ts index 18d27340af..b41d981bcf 100644 --- a/src/app/core/auth/browser-auth-request.service.spec.ts +++ b/src/app/core/auth/browser-auth-request.service.spec.ts @@ -1,6 +1,8 @@ import { AuthRequestService } from './auth-request.service'; import { RequestService } from '../data/request.service'; import { BrowserAuthRequestService } from './browser-auth-request.service'; +import { Observable } from 'rxjs'; +import { PostRequest } from '../data/request.models'; describe(`BrowserAuthRequestService`, () => { let href: string; @@ -16,14 +18,20 @@ describe(`BrowserAuthRequestService`, () => { }); describe(`createShortLivedTokenRequest`, () => { - it(`should return a PostRequest`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.constructor.name).toBe('PostRequest'); + it(`should return a PostRequest`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.constructor.name).toBe('PostRequest'); + done(); + }); }); - it(`should return a request with the given href`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.href).toBe(href) ; + it(`should return a request with the given href`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.href).toBe(href); + done(); + }); }); }); }); diff --git a/src/app/core/auth/browser-auth-request.service.ts b/src/app/core/auth/browser-auth-request.service.ts index 85d5f54340..485e2ef9c4 100644 --- a/src/app/core/auth/browser-auth-request.service.ts +++ b/src/app/core/auth/browser-auth-request.service.ts @@ -4,6 +4,7 @@ import { PostRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Observable, of as observableOf } from 'rxjs'; /** * Client side version of the service to send authentication requests @@ -20,15 +21,13 @@ export class BrowserAuthRequestService extends AuthRequestService { } /** - * Factory function to create the request object to send. This needs to be a POST client side and - * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow - * only the server IP to send a GET to this endpoint. + * Factory function to create the request object to send. * * @param href The href to send the request to * @protected */ - protected createShortLivedTokenRequest(href: string): PostRequest { - return new PostRequest(this.requestService.generateRequestId(), href); + protected createShortLivedTokenRequest(href: string): Observable { + return observableOf(new PostRequest(this.requestService.generateRequestId(), href)); } } diff --git a/src/app/core/auth/server-auth-request.service.spec.ts b/src/app/core/auth/server-auth-request.service.spec.ts index 69053fbb3a..df6d78256b 100644 --- a/src/app/core/auth/server-auth-request.service.spec.ts +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -1,34 +1,68 @@ import { AuthRequestService } from './auth-request.service'; import { RequestService } from '../data/request.service'; import { ServerAuthRequestService } from './server-auth-request.service'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { Observable, of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { PostRequest } from '../data/request.models'; +import { + XSRF_REQUEST_HEADER, + XSRF_RESPONSE_HEADER +} from '../xsrf/xsrf.interceptor'; describe(`ServerAuthRequestService`, () => { let href: string; let requestService: RequestService; let service: AuthRequestService; + let httpClient: HttpClient; + let httpResponse: HttpResponse; + let halService: HALEndpointService; + const mockToken = 'mock-token'; beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' }); - service = new ServerAuthRequestService(null, requestService, null); + let headers = new HttpHeaders(); + headers = headers.set(XSRF_RESPONSE_HEADER, mockToken); + httpResponse = { + body: { bar: false }, + headers: headers, + statusText: '200' + } as HttpResponse; + httpClient = jasmine.createSpyObj('httpClient', { + get: observableOf(httpResponse), + }); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + service = new ServerAuthRequestService(halService, requestService, null, httpClient); }); describe(`createShortLivedTokenRequest`, () => { - it(`should return a GetRequest`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.constructor.name).toBe('GetRequest'); + it(`should return a PostRequest`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.constructor.name).toBe('PostRequest'); + done(); + }); }); - it(`should return a request with the given href`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.href).toBe(href) ; + it(`should return a request with the given href`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.href).toBe(href); + done(); + }); }); - it(`should have a responseMsToLive of 2 seconds`, () => { - const result = (service as any).createShortLivedTokenRequest(href); - expect(result.responseMsToLive).toBe(2 * 1000) ; + it(`should return a request with a xsrf header`, (done) => { + const obs = (service as any).createShortLivedTokenRequest(href) as Observable; + obs.subscribe((result: PostRequest) => { + expect(result.options.headers.get(XSRF_REQUEST_HEADER)).toBe(mockToken); + done(); + }); }); }); }); diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts index 751389f71d..d6302081bc 100644 --- a/src/app/core/auth/server-auth-request.service.ts +++ b/src/app/core/auth/server-auth-request.service.ts @@ -1,9 +1,21 @@ import { Injectable } from '@angular/core'; import { AuthRequestService } from './auth-request.service'; -import { GetRequest } from '../data/request.models'; +import { PostRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { + HttpHeaders, + HttpClient, + HttpResponse +} from '@angular/common/http'; +import { + XSRF_REQUEST_HEADER, + XSRF_RESPONSE_HEADER, + DSPACE_XSRF_COOKIE +} from '../xsrf/xsrf.interceptor'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; /** * Server side version of the service to send authentication requests @@ -14,23 +26,42 @@ export class ServerAuthRequestService extends AuthRequestService { constructor( halService: HALEndpointService, requestService: RequestService, - rdbService: RemoteDataBuildService + rdbService: RemoteDataBuildService, + protected httpClient: HttpClient, ) { super(halService, requestService, rdbService); } /** - * Factory function to create the request object to send. This needs to be a POST client side and - * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow - * only the server IP to send a GET to this endpoint. + * Factory function to create the request object to send. * * @param href The href to send the request to * @protected */ - protected createShortLivedTokenRequest(href: string): GetRequest { - return Object.assign(new GetRequest(this.requestService.generateRequestId(), href), { - responseMsToLive: 2 * 1000 // A short lived token is only valid for 2 seconds. - }); + protected createShortLivedTokenRequest(href: string): Observable { + // First do a call to the root endpoint in order to get an XSRF token + return this.httpClient.get(this.halService.getRootHref(), { observe: 'response' }).pipe( + // retrieve the XSRF token from the response header + map((response: HttpResponse) => response.headers.get(XSRF_RESPONSE_HEADER)), + // Use that token to create an HttpHeaders object + map((xsrfToken: string) => new HttpHeaders() + .set('Content-Type', 'application/json; charset=utf-8') + // set the token as the XSRF header + .set(XSRF_REQUEST_HEADER, xsrfToken) + // and as the DSPACE-XSRF-COOKIE + .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), + map((headers: HttpHeaders) => + // Create a new PostRequest using those headers and the given href + new PostRequest( + this.requestService.generateRequestId(), + href, + {}, + { + headers: headers, + }, + ) + ) + ); } } diff --git a/src/app/core/browse/browse-definition-data.service.spec.ts b/src/app/core/browse/browse-definition-data.service.spec.ts index 9377dc715f..f321c2551c 100644 --- a/src/app/core/browse/browse-definition-data.service.spec.ts +++ b/src/app/core/browse/browse-definition-data.service.spec.ts @@ -2,27 +2,66 @@ import { BrowseDefinitionDataService } from './browse-definition-data.service'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { EMPTY } from 'rxjs'; import { FindListOptions } from '../data/find-list-options.model'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { RequestService } from '../data/request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; describe(`BrowseDefinitionDataService`, () => { + let requestService: RequestService; let service: BrowseDefinitionDataService; - const findAllDataSpy = jasmine.createSpyObj('findAllData', { - findAll: EMPTY, - }); + let findAllDataSpy; + let searchDataSpy; + const browsesEndpointURL = 'https://rest.api/browses'; + const halService: any = new HALEndpointServiceStub(browsesEndpointURL); + const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), followLink('items') ]; + function initTestService() { + return new BrowseDefinitionDataService( + requestService, + getMockRemoteDataBuildService(), + getMockObjectCacheService(), + halService, + ); + } + beforeEach(() => { - service = new BrowseDefinitionDataService(null, null, null, null); + service = initTestService(); + findAllDataSpy = jasmine.createSpyObj('findAllData', { + findAll: EMPTY, + }); + searchDataSpy = jasmine.createSpyObj('searchData', { + searchBy: EMPTY, + getSearchByHref: EMPTY, + }); (service as any).findAllData = findAllDataSpy; + (service as any).searchData = searchDataSpy; }); + describe('findByFields', () => { + it(`should call searchByHref on searchData`, () => { + service.findByFields(['test'], true, false, ...linksToFollow); + expect(searchDataSpy.getSearchByHref).toHaveBeenCalled(); + }); + }); + describe('searchBy', () => { + it(`should call searchBy on searchData`, () => { + service.searchBy('test', options, true, false, ...linksToFollow); + expect(searchDataSpy.searchBy).toHaveBeenCalledWith('test', options, true, false, ...linksToFollow); + }); + }); describe(`findAll`, () => { it(`should call findAll on findAllData`, () => { service.findAll(options, true, false, ...linksToFollow); expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); }); }); + + + }); diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 32c3b44e14..88d070000e 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -13,6 +13,8 @@ import { FindListOptions } from '../data/find-list-options.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { dataService } from '../data/base/data-service.decorator'; +import { RequestParam } from '../cache/models/request-param.model'; +import { SearchData, SearchDataImpl } from '../data/base/search-data'; /** * Data service responsible for retrieving browse definitions from the REST server @@ -21,8 +23,9 @@ import { dataService } from '../data/base/data-service.decorator'; providedIn: 'root', }) @dataService(BROWSE_DEFINITION) -export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData { +export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { private findAllData: FindAllDataImpl; + private searchData: SearchDataImpl; constructor( protected requestService: RequestService, @@ -31,7 +34,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService[]): Observable>> { return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Get the browse URL by providing a list of metadata keys. The first matching browse index definition + * for any of the fields is returned. This is used in eg. item page field component, which can be configured + * with several fields for a component like 'Author', and needs to know if and how to link the values + * to configured browse indices. + * + * @param fields an array of field strings, eg. ['dc.contributor.author', 'dc.creator'] + * @param useCachedVersionIfAvailable Override the data service useCachedVersionIfAvailable parameter (default: true) + * @param reRequestOnStale Override the data service reRequestOnStale parameter (default: true) + * @param linksToFollow Override the data service linksToFollow parameter (default: empty array) + */ + findByFields( + fields: string[], + useCachedVersionIfAvailable = true, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[] + ): Observable> { + const searchParams = []; + searchParams.push(new RequestParam('fields', fields)); + + const hrefObs = this.getSearchByHref( + 'byFields', + { searchParams }, + ...linksToFollow + ); + + return this.findByHref( + hrefObs, + useCachedVersionIfAvailable, + reRequestOnStale, + ...linksToFollow, + ); + } + } diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 2fab189254..be28015069 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -19,9 +19,9 @@ import { } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -import { BrowseDefinitionDataService } from './browse-definition-data.service'; import { HrefOnlyDataService } from '../data/href-only-data.service'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { BrowseDefinitionDataService } from './browse-definition-data.service'; export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ @@ -35,7 +35,7 @@ export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ export class BrowseService { protected linkPath = 'browses'; - private static toSearchKeyArray(metadataKey: string): string[] { + public static toSearchKeyArray(metadataKey: string): string[] { const keyParts = metadataKey.split('.'); const searchFor = []; searchFor.push('*'); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index bfbecdaecb..499d05af38 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -14,6 +14,7 @@ import { RemoteData } from './remote-data'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { HttpHeaders } from '@angular/common/http'; +import { HttpParams } from '@angular/common/http'; @Injectable({ providedIn: 'root', @@ -55,7 +56,7 @@ export class EpersonRegistrationService { * @param email * @param captchaToken the value of x-recaptcha-token header */ - registerEmail(email: string, captchaToken: string = null): Observable> { + registerEmail(email: string, captchaToken: string = null, type?: string): Observable> { const registration = new Registration(); registration.email = email; @@ -70,6 +71,11 @@ export class EpersonRegistrationService { } options.headers = headers; + if (hasValue(type)) { + options.params = type ? + new HttpParams({ fromString: 'accountRequestType=' + type }) : new HttpParams(); + } + href$.pipe( find((href: string) => hasValue(href)), map((href: string) => { diff --git a/src/app/core/services/server-xhr.service.ts b/src/app/core/services/server-xhr.service.ts new file mode 100644 index 0000000000..69ae741402 --- /dev/null +++ b/src/app/core/services/server-xhr.service.ts @@ -0,0 +1,16 @@ +import { XhrFactory } from '@angular/common'; +import { Injectable } from '@angular/core'; +import { prototype, XMLHttpRequest } from 'xhr2'; + +/** + * Overrides the default XhrFactory server side, to allow us to set cookies in requests to the + * backend. This was added to be able to perform a working XSRF request from the node server, as it + * needs to set a cookie for the XSRF token + */ +@Injectable() +export class ServerXhrService implements XhrFactory { + build(): XMLHttpRequest { + prototype._restrictedHeaders.cookie = false; + return new XMLHttpRequest(); + } +} diff --git a/src/app/core/shared/metadata-representation/metadata-representation.model.ts b/src/app/core/shared/metadata-representation/metadata-representation.model.ts index 06387966f7..379a3d1be8 100644 --- a/src/app/core/shared/metadata-representation/metadata-representation.model.ts +++ b/src/app/core/shared/metadata-representation/metadata-representation.model.ts @@ -1,11 +1,14 @@ /** * An Enum defining the representation type of metadata */ +import { BrowseDefinition } from '../browse-definition.model'; + export enum MetadataRepresentationType { None = 'none', Item = 'item', AuthorityControlled = 'authority_controlled', - PlainText = 'plain_text' + PlainText = 'plain_text', + BrowseLink = 'browse_link' } /** @@ -24,8 +27,14 @@ export interface MetadataRepresentation { */ representationType: MetadataRepresentationType; + /** + * The browse definition (optional) + */ + browseDefinition?: BrowseDefinition; + /** * Fetches the value to be displayed */ getValue(): string; + } diff --git a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts index 595147f3e6..a09de12ae4 100644 --- a/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts +++ b/src/app/core/shared/metadata-representation/metadatum/metadatum-representation.model.ts @@ -1,6 +1,7 @@ import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; import { hasValue } from '../../../../shared/empty.util'; import { MetadataValue } from '../../metadata.models'; +import { BrowseDefinition } from '../../browse-definition.model'; /** * This class defines the way the metadatum it extends should be represented @@ -12,9 +13,15 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe */ itemType: string; - constructor(itemType: string) { + /** + * The browse definition ID passed in with the metadatum, if any + */ + browseDefinition?: BrowseDefinition; + + constructor(itemType: string, browseDefinition?: BrowseDefinition) { super(); this.itemType = itemType; + this.browseDefinition = browseDefinition; } /** @@ -23,6 +30,8 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe get representationType(): MetadataRepresentationType { if (hasValue(this.authority)) { return MetadataRepresentationType.AuthorityControlled; + } else if (hasValue(this.browseDefinition)) { + return MetadataRepresentationType.BrowseLink; } else { return MetadataRepresentationType.PlainText; } diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index d527924a28..cded432397 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -19,6 +19,8 @@ export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; // Name of cookie where we store the XSRF token export const XSRF_COOKIE = 'XSRF-TOKEN'; +// Name of cookie the backend expects the XSRF token to be in +export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE'; /** * Custom Http Interceptor intercepting Http Requests & Responses to diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index 7e20edca6b..6e2ded334b 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -35,6 +35,10 @@ import { VersionDataService } from '../../../../core/data/version-data.service'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { SearchService } from '../../../../core/shared/search/search.service'; import { mockRouteService } from '../../../../item-page/simple/item-types/shared/item.component.spec'; +import { + BrowseDefinitionDataServiceStub +} from '../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -100,7 +104,8 @@ describe('JournalComponent', () => { { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: WorkspaceitemDataService, useValue: {} }, { provide: SearchService, useValue: {} }, - { provide: RouteService, useValue: mockRouteService } + { provide: RouteService, useValue: mockRouteService }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub } ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.html b/src/app/forgot-password/forgot-password-email/forgot-email.component.html index 263f142c2e..995108cdbc 100644 --- a/src/app/forgot-password/forgot-password-email/forgot-email.component.html +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.html @@ -1,3 +1,3 @@ - \ No newline at end of file + [MESSAGE_PREFIX]="'forgot-email.form'" [typeRequest]="typeRequest"> + diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.ts b/src/app/forgot-password/forgot-password-email/forgot-email.component.ts index af482bdb67..66a61ed7ee 100644 --- a/src/app/forgot-password/forgot-password-email/forgot-email.component.ts +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; +import { TYPE_REQUEST_FORGOT } from '../../register-email-form/register-email-form.component'; @Component({ selector: 'ds-forgot-email', @@ -9,5 +10,5 @@ import { Component } from '@angular/core'; * Component responsible the forgot password email step */ export class ForgotEmailComponent { - + typeRequest = TYPE_REQUEST_FORGOT; } diff --git a/src/app/item-page/edit-item-page/edit-item-page.component.html b/src/app/item-page/edit-item-page/edit-item-page.component.html index 9458df2249..c370fe4f20 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.component.html +++ b/src/app/item-page/edit-item-page/edit-item-page.component.html @@ -3,8 +3,8 @@

{{'item.edit.head' | translate}}

-
diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts index e41fd1b8a7..15b7a9df21 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts @@ -12,6 +12,10 @@ import { environment } from '../../../../../environments/environment'; import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe'; import { SharedModule } from '../../../../shared/shared.module'; import { APP_CONFIG } from '../../../../../config/app-config.interface'; +import { By } from '@angular/platform-browser'; +import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; +import { BrowseDefinitionDataServiceStub } from '../../../../shared/testing/browse-definition-data-service.stub'; +import { RouterTestingModule } from '@angular/router/testing'; let comp: ItemPageFieldComponent; let fixture: ComponentFixture; @@ -20,7 +24,9 @@ let markdownSpy; const mockValue = 'test value'; const mockField = 'dc.test'; const mockLabel = 'test label'; -const mockFields = [mockField]; +const mockAuthorField = 'dc.contributor.author'; +const mockDateIssuedField = 'dc.date.issued'; +const mockFields = [mockField, mockAuthorField, mockDateIssuedField]; describe('ItemPageFieldComponent', () => { @@ -34,6 +40,7 @@ describe('ItemPageFieldComponent', () => { const buildTestEnvironment = async () => { await TestBed.configureTestingModule({ imports: [ + RouterTestingModule.withRoutes([]), TranslateModule.forRoot({ loader: { provide: TranslateLoader, @@ -44,6 +51,7 @@ describe('ItemPageFieldComponent', () => { ], providers: [ { provide: APP_CONFIG, useValue: appConfig }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub } ], declarations: [ItemPageFieldComponent, MetadataValuesComponent], schemas: [NO_ERRORS_SCHEMA] @@ -53,7 +61,7 @@ describe('ItemPageFieldComponent', () => { markdownSpy = spyOn(MarkdownPipe.prototype, 'transform'); fixture = TestBed.createComponent(ItemPageFieldComponent); comp = fixture.componentInstance; - comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.item = mockItemWithMetadataFieldsAndValue(mockFields, mockValue); comp.fields = mockFields; comp.label = mockLabel; fixture.detectChanges(); @@ -126,17 +134,57 @@ describe('ItemPageFieldComponent', () => { expect(markdownSpy).toHaveBeenCalled(); }); }); + }); + + describe('test rendering of configured browse links', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + waitForAsync(() => { + it('should have a browse link', () => { + expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockValue); + }); + }); + }); + + describe('test rendering of configured regex-based links', () => { + beforeEach(() => { + comp.urlRegex = '^test'; + fixture.detectChanges(); + }); + waitForAsync(() => { + it('should have a rendered (non-browse) link since the value matches ^test', () => { + expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link')).nativeElement.innerHTML).toContain(mockValue); + }); + }); + }); + + describe('test skipping of configured links that do NOT match regex', () => { + beforeEach(() => { + comp.urlRegex = '^nope'; + fixture.detectChanges(); + }); + beforeEach(waitForAsync(() => { + it('should NOT have a rendered (non-browse) link since the value matches ^test', () => { + expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link'))).toBeNull(); + }); + })); + }); + + }); -export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item { +export function mockItemWithMetadataFieldsAndValue(fields: string[], value: string): Item { const item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), metadata: new MetadataMap() }); - item.metadata[field] = [{ - language: 'en_US', - value: value - }] as MetadataValue[]; + fields.forEach((field: string) => { + item.metadata[field] = [{ + language: 'en_US', + value: value + }] as MetadataValue[]; + }); return item; } diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts index 681c5e16bc..fc526dabcc 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -1,5 +1,10 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { BrowseDefinition } from '../../../../core/shared/browse-definition.model'; +import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; +import { getRemoteDataPayload } from '../../../../core/shared/operators'; /** * This component can be used to represent metadata on a simple item page. @@ -12,6 +17,9 @@ import { Item } from '../../../../core/shared/item.model'; }) export class ItemPageFieldComponent { + constructor(protected browseDefinitionDataService: BrowseDefinitionDataService) { + } + /** * The item to display metadata for */ @@ -38,4 +46,19 @@ export class ItemPageFieldComponent { */ separator = '
'; + /** + * Whether any valid HTTP(S) URL should be rendered as a link + */ + urlRegex?: string; + + /** + * Return browse definition that matches any field used in this component if it is configured as a browse + * link in dspace.cfg (webui.browse.link.) + */ + get browseDefinition(): Observable { + return this.browseDefinitionDataService.findByFields(this.fields).pipe( + getRemoteDataPayload(), + map((def) => def) + ); + } } diff --git a/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts index bc661e81c9..316e08e564 100644 --- a/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component.spec.ts @@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; -import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; import { ItemPageTitleFieldComponent } from './item-page-title-field.component'; let comp: ItemPageTitleFieldComponent; @@ -31,7 +31,7 @@ describe('ItemPageTitleFieldComponent', () => { beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(ItemPageTitleFieldComponent); comp = fixture.componentInstance; - comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue); fixture.detectChanges(); })); diff --git a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts index 7c766252a3..cc55b76e3e 100644 --- a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -2,11 +2,13 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; -import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; +import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; import { ItemPageUriFieldComponent } from './item-page-uri-field.component'; import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component'; import { environment } from '../../../../../../environments/environment'; import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; +import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; let comp: ItemPageUriFieldComponent; let fixture: ComponentFixture; @@ -26,6 +28,7 @@ describe('ItemPageUriFieldComponent', () => { })], providers: [ { provide: APP_CONFIG, useValue: environment }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub } ], declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent], schemas: [NO_ERRORS_SCHEMA] @@ -37,7 +40,7 @@ describe('ItemPageUriFieldComponent', () => { beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(ItemPageUriFieldComponent); comp = fixture.componentInstance; - comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue); comp.fields = [mockField]; comp.label = mockLabel; fixture.detectChanges(); diff --git a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts index 3c2ff4f844..211ec102bc 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts @@ -36,6 +36,10 @@ import { VersionDataService } from '../../../../core/data/version-data.service'; import { RouterTestingModule } from '@angular/router/testing'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { SearchService } from '../../../../core/shared/search/search.service'; +import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; +import { + BrowseDefinitionDataServiceStub +} from '../../../../shared/testing/browse-definition-data-service.stub'; const noMetadata = new MetadataMap(); @@ -87,7 +91,8 @@ describe('PublicationComponent', () => { { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: WorkspaceitemDataService, useValue: {} }, { provide: SearchService, useValue: {} }, - { provide: RouteService, useValue: mockRouteService } + { provide: RouteService, useValue: mockRouteService }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/item-page/simple/item-types/shared/item.component.spec.ts b/src/app/item-page/simple/item-types/shared/item.component.spec.ts index cb91a31b06..5bf08fc004 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.spec.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.spec.ts @@ -23,7 +23,9 @@ import { UUIDService } from '../../../../core/shared/uuid.service'; import { isNotEmpty } from '../../../../shared/empty.util'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { + createSuccessfulRemoteDataObject$ +} from '../../../../shared/remote-data.utils'; import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; @@ -38,6 +40,10 @@ import { VersionHistoryDataService } from '../../../../core/data/version-history import { RouterTestingModule } from '@angular/router/testing'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { ResearcherProfileDataService } from '../../../../core/profile/researcher-profile-data.service'; +import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; +import { + BrowseDefinitionDataServiceStub +} from '../../../../shared/testing/browse-definition-data-service.stub'; import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; import { PageInfo } from '../../../../core/shared/page-info.model'; @@ -125,7 +131,8 @@ export function getItemPageFieldsTest(mockItem: Item, component) { { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: mockRouteService }, { provide: AuthorizationDataService, useValue: authorizationService }, - { provide: ResearcherProfileDataService, useValue: {} } + { provide: ResearcherProfileDataService, useValue: {} }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] @@ -444,7 +451,7 @@ describe('ItemComponent', () => { { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: mockRouteService }, { provide: AuthorizationDataService, useValue: {} }, - { provide: ResearcherProfileDataService, useValue: {} } + { provide: ResearcherProfileDataService, useValue: {} }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ItemComponent, { diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts index 3581694a5e..4b7da40abe 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts @@ -37,6 +37,10 @@ import { RouterTestingModule } from '@angular/router/testing'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { SearchService } from '../../../../core/shared/search/search.service'; import { ItemVersionsSharedService } from '../../../versions/item-versions-shared.service'; +import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; +import { + BrowseDefinitionDataServiceStub +} from '../../../../shared/testing/browse-definition-data-service.stub'; const noMetadata = new MetadataMap(); @@ -90,7 +94,8 @@ describe('UntypedItemComponent', () => { { provide: SearchService, useValue: {} }, { provide: ItemDataService, useValue: {} }, { provide: ItemVersionsSharedService, useValue: {} }, - { provide: RouteService, useValue: mockRouteService } + { provide: RouteService, useValue: mockRouteService }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(UntypedItemComponent, { diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts index fcd82ce678..b29c7e58f3 100644 --- a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts @@ -77,7 +77,7 @@ describe('VersionedItemComponent', () => { { provide: WorkspaceitemDataService, useValue: {} }, { provide: SearchService, useValue: {} }, { provide: ItemDataService, useValue: {} }, - { provide: RouteService, useValue: mockRouteService } + { provide: RouteService, useValue: mockRouteService }, ] }).compileComponents(); versionService = TestBed.inject(VersionDataService); diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts index a1f4cebd77..6855d9c4dc 100644 --- a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts @@ -35,7 +35,7 @@ export class VersionedItemComponent extends ItemComponent { private workspaceItemDataService: WorkspaceitemDataService, private searchService: SearchService, private itemService: ItemDataService, - protected routeService: RouteService, + protected routeService: RouteService ) { super(routeService, router); } diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts index 54420721b8..180eaaa2be 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.spec.ts @@ -11,6 +11,8 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { BrowseDefinitionDataService } from '../../../core/browse/browse-definition-data.service'; +import { BrowseDefinitionDataServiceStub } from '../../../shared/testing/browse-definition-data-service.stub'; const itemType = 'Person'; const metadataFields = ['dc.contributor.author', 'dc.creator']; @@ -104,7 +106,8 @@ describe('MetadataRepresentationListComponent', () => { imports: [TranslateModule.forRoot()], declarations: [MetadataRepresentationListComponent, VarDirective], providers: [ - { provide: RelationshipDataService, useValue: relationshipService } + { provide: RelationshipDataService, useValue: relationshipService }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(MetadataRepresentationListComponent, { diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts index 16dcf72cd4..d5e6547778 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -8,6 +8,13 @@ import { RelationshipDataService } from '../../../core/data/relationship-data.se import { MetadataValue } from '../../../core/shared/metadata.models'; import { Item } from '../../../core/shared/item.model'; import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; +import { map } from 'rxjs/operators'; +import { getRemoteDataPayload } from '../../../core/shared/operators'; +import { + MetadatumRepresentation +} from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { BrowseService } from '../../../core/browse/browse.service'; +import { BrowseDefinitionDataService } from '../../../core/browse/browse-definition-data.service'; @Component({ selector: 'ds-metadata-representation-list', @@ -52,7 +59,8 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList */ total: number; - constructor(public relationshipService: RelationshipDataService) { + constructor(public relationshipService: RelationshipDataService, + private browseDefinitionDataService: BrowseDefinitionDataService) { super(); } @@ -76,7 +84,21 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList ...metadata .slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy) .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) - .map((metadatum: MetadataValue) => this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType)), + .map((metadatum: MetadataValue) => { + if (metadatum.isVirtual) { + return this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType); + } else { + // Check for a configured browse link and return a standard metadata representation + let searchKeyArray: string[] = []; + this.metadataFields.forEach((field: string) => { + searchKeyArray = searchKeyArray.concat(BrowseService.toSearchKeyArray(field)); + }); + return this.browseDefinitionDataService.findByFields(this.metadataFields).pipe( + getRemoteDataPayload(), + map((def) => Object.assign(new MetadatumRepresentation(this.itemType, def), metadatum)) + ); + } + }), ); } } diff --git a/src/app/register-email-form/register-email-form.component.html b/src/app/register-email-form/register-email-form.component.html index cc0ce4c782..ed79b1d2d1 100644 --- a/src/app/register-email-form/register-email-form.component.html +++ b/src/app/register-email-form/register-email-form.component.html @@ -2,6 +2,10 @@

{{MESSAGE_PREFIX + '.header'|translate}}

{{MESSAGE_PREFIX + '.info' | translate}}

+

+ {{ MESSAGE_PREFIX + '.info.maildomain' | translate}} {{ validMailDomains.join(', ')}} +

+
@@ -16,8 +20,11 @@ {{ MESSAGE_PREFIX + '.email.error.required' | translate }} - - {{ MESSAGE_PREFIX + '.email.error.pattern' | translate }} + + {{ MESSAGE_PREFIX + '.email.error.not-email-form' | translate }} + + {{ MESSAGE_PREFIX + '.email.error.not-valid-domain' | translate: { domains: validMailDomains.join(', ') } }} +
@@ -53,5 +60,4 @@ - diff --git a/src/app/register-email-form/register-email-form.component.spec.ts b/src/app/register-email-form/register-email-form.component.spec.ts index cf3b4b13d2..6136db4aec 100644 --- a/src/app/register-email-form/register-email-form.component.spec.ts +++ b/src/app/register-email-form/register-email-form.component.spec.ts @@ -12,14 +12,19 @@ import { EpersonRegistrationService } from '../core/data/eperson-registration.se import { By } from '@angular/platform-browser'; import { RouterStub } from '../shared/testing/router.stub'; import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; -import { RegisterEmailFormComponent } from './register-email-form.component'; +import { + RegisterEmailFormComponent, + TYPE_REQUEST_REGISTER, + TYPE_REQUEST_FORGOT +} from './register-email-form.component'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service'; import { CookieService } from '../core/services/cookie.service'; import { CookieServiceMock } from '../shared/mocks/cookie.service.mock'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; -describe('RegisterEmailComponent', () => { +describe('RegisterEmailFormComponent', () => { let comp: RegisterEmailFormComponent; let fixture: ComponentFixture; @@ -53,6 +58,8 @@ describe('RegisterEmailComponent', () => { registerEmail: createSuccessfulRemoteDataObject$({}) }); + jasmine.getEnv().allowRespy(true); + TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], declarations: [RegisterEmailFormComponent], @@ -95,17 +102,53 @@ describe('RegisterEmailComponent', () => { comp.form.patchValue({email: 'valid@email.org'}); expect(comp.form.invalid).toBeFalse(); }); + it('should accept email with other domain names on TYPE_REQUEST_FORGOT form', () => { + spyOn(configurationDataService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'authentication-password.domain.valid', + values: ['marvel.com'], + }))); + comp.typeRequest = TYPE_REQUEST_FORGOT; + + comp.ngOnInit(); + + comp.form.patchValue({ email: 'valid@email.org' }); + expect(comp.form.invalid).toBeFalse(); + }); it('should be valid when uppercase letters are used', () => { comp.form.patchValue({email: 'VALID@email.org'}); expect(comp.form.invalid).toBeFalse(); }); + it('should not accept email with other domain names', () => { + spyOn(configurationDataService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'authentication-password.domain.valid', + values: ['marvel.com'], + }))); + comp.typeRequest = TYPE_REQUEST_REGISTER; + + comp.ngOnInit(); + + comp.form.patchValue({ email: 'valid@email.org' }); + expect(comp.form.invalid).toBeTrue(); + }); + it('should accept email with the given domain name', () => { + spyOn(configurationDataService, 'findByPropertyName').and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'authentication-password.domain.valid', + values: ['marvel.com'], + }))); + comp.typeRequest = TYPE_REQUEST_REGISTER; + + comp.ngOnInit(); + + comp.form.patchValue({ email: 'thor.odinson@marvel.com' }); + expect(comp.form.invalid).toBeFalse(); + }); }); describe('register', () => { it('should send a registration to the service and on success display a message and return to home', () => { comp.form.patchValue({email: 'valid@email.org'}); comp.register(); - expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', null, null); expect(notificationsService.success).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['/home']); }); @@ -115,7 +158,7 @@ describe('RegisterEmailComponent', () => { comp.form.patchValue({email: 'valid@email.org'}); comp.register(); - expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', null, null); expect(notificationsService.error).toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); }); @@ -133,7 +176,7 @@ describe('RegisterEmailComponent', () => { comp.form.patchValue({email: 'valid@email.org'}); comp.register(); tick(); - expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken'); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken', null); expect(notificationsService.success).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['/home']); })); @@ -144,7 +187,7 @@ describe('RegisterEmailComponent', () => { comp.register(); tick(); - expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken'); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org', 'googleRecaptchaToken', null); expect(notificationsService.error).toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); })); diff --git a/src/app/register-email-form/register-email-form.component.ts b/src/app/register-email-form/register-email-form.component.ts index 561bd53e67..9f0b186d39 100644 --- a/src/app/register-email-form/register-email-form.component.ts +++ b/src/app/register-email-form/register-email-form.component.ts @@ -1,21 +1,25 @@ -import { ChangeDetectorRef, Component, Input, OnInit, Optional } from '@angular/core'; -import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; -import { NotificationsService } from '../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { Router } from '@angular/router'; -import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { Registration } from '../core/shared/registration.model'; -import { RemoteData } from '../core/data/remote-data'; -import { ConfigurationDataService } from '../core/data/configuration-data.service'; -import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; -import { ConfigurationProperty } from '../core/shared/configuration-property.model'; -import { isNotEmpty } from '../shared/empty.util'; -import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; -import { map, startWith, take } from 'rxjs/operators'; -import { CAPTCHA_NAME, GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service'; -import { AlertType } from '../shared/alert/aletr-type'; -import { KlaroService } from '../shared/cookies/klaro.service'; -import { CookieService } from '../core/services/cookie.service'; +import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, Optional } from '@angular/core'; +import {EpersonRegistrationService} from '../core/data/eperson-registration.service'; +import {NotificationsService} from '../shared/notifications/notifications.service'; +import {TranslateService} from '@ngx-translate/core'; +import {Router} from '@angular/router'; +import { FormBuilder, FormControl, FormGroup, Validators, ValidatorFn } from '@angular/forms'; +import {Registration} from '../core/shared/registration.model'; +import {RemoteData} from '../core/data/remote-data'; +import {ConfigurationDataService} from '../core/data/configuration-data.service'; +import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; +import {ConfigurationProperty} from '../core/shared/configuration-property.model'; +import {isNotEmpty} from '../shared/empty.util'; +import {BehaviorSubject, combineLatest, Observable, of, switchMap} from 'rxjs'; +import {map, startWith, take} from 'rxjs/operators'; +import {CAPTCHA_NAME, GoogleRecaptchaService} from '../core/google-recaptcha/google-recaptcha.service'; +import {AlertType} from '../shared/alert/aletr-type'; +import {KlaroService} from '../shared/cookies/klaro.service'; +import {CookieService} from '../core/services/cookie.service'; +import { Subscription } from 'rxjs'; + +export const TYPE_REQUEST_FORGOT = 'forgot'; +export const TYPE_REQUEST_REGISTER = 'register'; @Component({ selector: 'ds-register-email-form', @@ -24,7 +28,7 @@ import { CookieService } from '../core/services/cookie.service'; /** * Component responsible to render an email registration form. */ -export class RegisterEmailFormComponent implements OnInit { +export class RegisterEmailFormComponent implements OnDestroy, OnInit { /** * The form containing the mail address @@ -37,6 +41,12 @@ export class RegisterEmailFormComponent implements OnInit { @Input() MESSAGE_PREFIX: string; + /** + * Type of register request to be done, register new email or forgot password (same endpoint) + */ + @Input() + typeRequest: string = null; + public AlertTypeEnum = AlertType; /** @@ -51,6 +61,11 @@ export class RegisterEmailFormComponent implements OnInit { disableUntilChecked = true; + validMailDomains: string[]; + TYPE_REQUEST_REGISTER = TYPE_REQUEST_REGISTER; + + subscriptions: Subscription[] = []; + captchaVersion(): Observable { return this.googleRecaptchaService.captchaVersion(); } @@ -72,31 +87,54 @@ export class RegisterEmailFormComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private notificationsService: NotificationsService, ) { + } + ngOnDestroy(): void { + this.subscriptions.forEach((sub: Subscription) => sub.unsubscribe()); } ngOnInit(): void { + const validators: ValidatorFn[] = [ + Validators.required, + Validators.email, + // Regex pattern borrowed from HTML5 specs for a valid email address: + // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + Validators.pattern('^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$') + ]; this.form = this.formBuilder.group({ email: new FormControl('', { - validators: [Validators.required, - // Regex pattern borrowed from HTML5 specs for a valid email address: - // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address - Validators.pattern('^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$') - ], + validators: validators, }) }); - this.configService.findByPropertyName('registration.verification.enabled').pipe( + this.validMailDomains = []; + if (this.typeRequest === TYPE_REQUEST_REGISTER) { + this.subscriptions.push(this.configService.findByPropertyName('authentication-password.domain.valid') + .pipe(getAllSucceededRemoteDataPayload()) + .subscribe((remoteData: ConfigurationProperty) => { + this.validMailDomains = remoteData.values; + for (const remoteValue of remoteData.values) { + if (this.validMailDomains.length !== 0) { + this.form.get('email').setValidators([ + ...validators, + Validators.pattern(this.validMailDomains.map((domain: string) => '(^.*' + domain.replace(new RegExp('\\.', 'g'), '\\.') + '$)').join('|')), + ]); + this.form.updateValueAndValidity(); + } + } + this.changeDetectorRef.detectChanges(); + })); + } + this.subscriptions.push(this.configService.findByPropertyName('registration.verification.enabled').pipe( getFirstSucceededRemoteDataPayload(), map((res: ConfigurationProperty) => res?.values[0].toLowerCase() === 'true') ).subscribe((res: boolean) => { this.registrationVerification = res; - }); + })); - this.disableUntilCheckedFcn().subscribe((res) => { + this.subscriptions.push(this.disableUntilCheckedFcn().subscribe((res) => { this.disableUntilChecked = res; this.changeDetectorRef.detectChanges(); - }); - + })); } /** @@ -112,7 +150,7 @@ export class RegisterEmailFormComponent implements OnInit { register(tokenV2?) { if (!this.form.invalid) { if (this.registrationVerification) { - combineLatest([this.captchaVersion(), this.captchaMode()]).pipe( + this.subscriptions.push(combineLatest([this.captchaVersion(), this.captchaMode()]).pipe( switchMap(([captchaVersion, captchaMode]) => { if (captchaVersion === 'v3') { return this.googleRecaptchaService.getRecaptchaToken('register_email'); @@ -134,7 +172,7 @@ export class RegisterEmailFormComponent implements OnInit { this.showNotification('error'); } } - ); + )); } else { this.registration(); } @@ -146,18 +184,20 @@ export class RegisterEmailFormComponent implements OnInit { */ registration(captchaToken = null) { let registerEmail$ = captchaToken ? - this.epersonRegistrationService.registerEmail(this.email.value, captchaToken) : - this.epersonRegistrationService.registerEmail(this.email.value); - registerEmail$.subscribe((response: RemoteData) => { + this.epersonRegistrationService.registerEmail(this.email.value, captchaToken, this.typeRequest) : + this.epersonRegistrationService.registerEmail(this.email.value, null, this.typeRequest); + this.subscriptions.push(registerEmail$.subscribe((response: RemoteData) => { if (response.hasSucceeded) { this.notificationService.success(this.translateService.get(`${this.MESSAGE_PREFIX}.success.head`), this.translateService.get(`${this.MESSAGE_PREFIX}.success.content`, {email: this.email.value})); this.router.navigate(['/home']); + } else if (response.statusCode === 422) { + this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), this.translateService.get(`${this.MESSAGE_PREFIX}.error.maildomain`, {domains: this.validMailDomains.join(', ')})); } else { this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), this.translateService.get(`${this.MESSAGE_PREFIX}.error.content`, {email: this.email.value})); } - }); + })); } /** diff --git a/src/app/register-page/register-email/register-email.component.html b/src/app/register-page/register-email/register-email.component.html index a60dc4c31e..1829bb2914 100644 --- a/src/app/register-page/register-email/register-email.component.html +++ b/src/app/register-page/register-email/register-email.component.html @@ -1,3 +1,3 @@ + [MESSAGE_PREFIX]="'register-page.registration'" [typeRequest]="typeRequest"> diff --git a/src/app/register-page/register-email/register-email.component.ts b/src/app/register-page/register-email/register-email.component.ts index 7b7b0f631b..228e8c56a0 100644 --- a/src/app/register-page/register-email/register-email.component.ts +++ b/src/app/register-page/register-email/register-email.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; +import { TYPE_REQUEST_REGISTER } from '../../register-email-form/register-email-form.component'; @Component({ selector: 'ds-register-email', @@ -9,5 +10,5 @@ import { Component } from '@angular/core'; * Component responsible the email registration step when registering as a new user */ export class RegisterEmailComponent { - + typeRequest = TYPE_REQUEST_REGISTER; } diff --git a/src/app/search-navbar/search-navbar.component.scss b/src/app/search-navbar/search-navbar.component.scss index 40433fc619..d5f3d8d615 100644 --- a/src/app/search-navbar/search-navbar.component.scss +++ b/src/app/search-navbar/search-navbar.component.scss @@ -1,9 +1,6 @@ input[type="text"] { margin-top: calc(-0.5 * var(--bs-font-size-base)); - - &:focus { - background-color: rgba(255, 255, 255, 0.5) !important; - } + background-color: #fff !important; &.collapsed { opacity: 0; diff --git a/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.html new file mode 100644 index 0000000000..8d3afea273 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.spec.ts new file mode 100644 index 0000000000..32919d9758 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowseLinkMetadataListElementComponent } from './browse-link-metadata-list-element.component'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; + +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'Test Author' +}); + +const mockMetadataRepresentationWithUrl = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.subject', + value: 'http://purl.org/test/subject' +}); + +describe('BrowseLinkMetadataListElementComponent', () => { + let comp: BrowseLinkMetadataListElementComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [BrowseLinkMetadataListElementComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(BrowseLinkMetadataListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(BrowseLinkMetadataListElementComponent); + comp = fixture.componentInstance; + comp.metadataRepresentation = mockMetadataRepresentation; + fixture.detectChanges(); + })); + + waitForAsync(() => { + it('should contain the value as a browse link', () => { + expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); + }); + it('should NOT match isLink', () => { + expect(comp.isLink).toBe(false); + }); + }); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(BrowseLinkMetadataListElementComponent); + comp = fixture.componentInstance; + comp.metadataRepresentation = mockMetadataRepresentationWithUrl; + fixture.detectChanges(); + })); + + waitForAsync(() => { + it('should contain the value expected', () => { + expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentationWithUrl.value); + }); + it('should match isLink', () => { + expect(comp.isLink).toBe(true); + }); + }); + +}); diff --git a/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.ts new file mode 100644 index 0000000000..0eb0ce05b0 --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component.ts @@ -0,0 +1,29 @@ +import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { Component } from '@angular/core'; +import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; +import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator'; +//@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText) +// For now, authority controlled fields are rendered the same way as plain text fields +//@metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled) +@metadataRepresentationComponent('Publication', MetadataRepresentationType.BrowseLink) +@Component({ + selector: 'ds-browse-link-metadata-list-element', + templateUrl: './browse-link-metadata-list-element.component.html' +}) +/** + * A component for displaying MetadataRepresentation objects in the form of plain text + * It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text + */ +export class BrowseLinkMetadataListElementComponent extends MetadataRepresentationListElementComponent { + /** + * Get the appropriate query parameters for this browse link, depending on whether the browse definition + * expects 'startsWith' (eg browse by date) or 'value' (eg browse by title) + */ + getQueryParams() { + let queryParams = {startsWith: this.metadataRepresentation.getValue()}; + if (this.metadataRepresentation.browseDefinition.metadataBrowse) { + return {value: this.metadataRepresentation.getValue()}; + } + return queryParams; + } +} diff --git a/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.spec.ts new file mode 100644 index 0000000000..f0cc150b3e --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.spec.ts @@ -0,0 +1,59 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { mockData } from '../../testing/browse-definition-data-service.stub'; +import { MetadataRepresentationListElementComponent } from './metadata-representation-list-element.component'; + +// Mock metadata representation values +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), { + key: 'dc.contributor.author', + value: 'Test Author' +}); +const mockMetadataRepresentationUrl = Object.assign(new MetadatumRepresentation('type', mockData[1]), { + key: 'dc.subject', + value: 'https://www.google.com' +}); + +describe('MetadataRepresentationListElementComponent', () => { + let comp: MetadataRepresentationListElementComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [MetadataRepresentationListElementComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MetadataRepresentationListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(MetadataRepresentationListElementComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the value is not a URL', () => { + beforeEach(() => { + comp.metadataRepresentation = mockMetadataRepresentation; + }); + it('isLink correctly detects a non-URL string as false', () => { + waitForAsync(() => { + expect(comp.isLink()).toBe(false); + }); + }); + }); + + describe('when the value is a URL', () => { + beforeEach(() => { + comp.metadataRepresentation = mockMetadataRepresentationUrl; + }); + it('isLink correctly detects a URL string as true', () => { + waitForAsync(() => { + expect(comp.isLink()).toBe(true); + }); + }); + }); + +}); diff --git a/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts index 2e14485fbb..b69f6b37dc 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/metadata-representation-list-element.component.ts @@ -13,4 +13,14 @@ export class MetadataRepresentationListElementComponent { * The metadata representation of this component */ metadataRepresentation: MetadataRepresentation; + + /** + * Returns true if this component's value matches a basic regex "Is this an HTTP URL" test + */ + isLink(): boolean { + // Match any string that begins with http:// or https:// + const linkPattern = new RegExp(/^https?\/\/.*/); + return linkPattern.test(this.metadataRepresentation.getValue()); + } + } diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html index 31b670b1a3..7b611a7d1f 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html @@ -1,3 +1,17 @@
- {{metadataRepresentation.getValue()}} + + + {{metadataRepresentation.getValue()}} + + + {{metadataRepresentation.getValue()}} + + {{metadataRepresentation.getValue()}} + + {{metadataRepresentation.getValue()}} +
diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts index af09d3c204..cfb812a475 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts @@ -2,8 +2,12 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component'; import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { By } from '@angular/platform-browser'; +import { mockData } from '../../../testing/browse-definition-data-service.stub'; -const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { +// Render the mock representation with the default mock author browse definition so it is also rendered as a link +// without affecting other tests +const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), { key: 'dc.contributor.author', value: 'Test Author' }); @@ -33,4 +37,8 @@ describe('PlainTextMetadataListElementComponent', () => { expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); }); + it('should contain the browse link as plain text', () => { + expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockMetadataRepresentation.value); + }); + }); diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts index 198c3712d9..2d21a7afe8 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts @@ -15,4 +15,15 @@ import { metadataRepresentationComponent } from '../../../metadata-representatio * It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text */ export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent { + /** + * Get the appropriate query parameters for this browse link, depending on whether the browse definition + * expects 'startsWith' (eg browse by date) or 'value' (eg browse by title) + */ + getQueryParams() { + let queryParams = {startsWith: this.metadataRepresentation.getValue()}; + if (this.metadataRepresentation.browseDefinition.metadataBrowse) { + return {value: this.metadataRepresentation.getValue()}; + } + return queryParams; + } } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index dfe8768014..bd46380452 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -84,6 +84,8 @@ import { LangSwitchComponent } from './lang-switch/lang-switch.component'; import { PlainTextMetadataListElementComponent } from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; +import { BrowseLinkMetadataListElementComponent } + from './object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component'; import { ItemMetadataListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; @@ -383,6 +385,7 @@ const ENTRY_COMPONENTS = [ EditItemSelectorComponent, ThemedEditItemSelectorComponent, PlainTextMetadataListElementComponent, + BrowseLinkMetadataListElementComponent, ItemMetadataListElementComponent, MetadataRepresentationListElementComponent, ItemMetadataRepresentationListElementComponent, diff --git a/src/app/shared/testing/browse-definition-data-service.stub.ts b/src/app/shared/testing/browse-definition-data-service.stub.ts new file mode 100644 index 0000000000..ec1fc2f05e --- /dev/null +++ b/src/app/shared/testing/browse-definition-data-service.stub.ts @@ -0,0 +1,63 @@ +import { EMPTY, Observable, of as observableOf } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { BrowseService } from '../../core/browse/browse.service'; +import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; +import { PageInfo } from '../../core/shared/page-info.model'; + +// This data is in post-serialized form (metadata -> metadataKeys) +export const mockData: BrowseDefinition[] = [ + Object.assign(new BrowseDefinition, { + 'id' : 'dateissued', + 'metadataBrowse' : false, + 'dataType' : 'date', + 'sortOptions' : EMPTY, + 'order' : 'ASC', + 'type' : 'browse', + 'metadataKeys' : [ 'dc.date.issued' ], + '_links' : EMPTY + }), + Object.assign(new BrowseDefinition, { + 'id' : 'author', + 'metadataBrowse' : true, + 'dataType' : 'text', + 'sortOptions' : EMPTY, + 'order' : 'ASC', + 'type' : 'browse', + 'metadataKeys' : [ 'dc.contributor.*', 'dc.creator' ], + '_links' : EMPTY + }) +]; + +export const BrowseDefinitionDataServiceStub: any = { + + /** + * Get all BrowseDefinitions + */ + findAll(): Observable>> { + return observableOf(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), mockData))); + }, + + /** + * Get all BrowseDefinitions with any link configuration + */ + findAllLinked(): Observable>> { + return observableOf(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), mockData))); + }, + + /** + * Get the browse URL by providing a list of metadata keys + * + * @param metadataKeys a list of fields eg. ['dc.contributor.author', 'dc.creator'] + */ + findByFields(metadataKeys: string[]): Observable> { + let searchKeyArray: string[] = []; + metadataKeys.forEach((metadataKey) => { + searchKeyArray = searchKeyArray.concat(BrowseService.toSearchKeyArray(metadataKey)); + }); + // Return just the first, as a pretend match + return observableOf(createSuccessfulRemoteDataObject(mockData[0])); + } + +}; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index f68c0ff2ce..99949c4378 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1538,7 +1538,7 @@ "forgot-email.form.email.error.required": "Please fill in an email address", - "forgot-email.form.email.error.pattern": "Please fill in a valid email address", + "forgot-email.form.email.error.not-email-form": "Please fill in a valid email address", "forgot-email.form.email.hint": "An email will be sent to this address with a further instructions.", @@ -3342,7 +3342,9 @@ "register-page.registration.email.error.required": "Please fill in an email address", - "register-page.registration.email.error.pattern": "Please fill in a valid email address", + "register-page.registration.email.error.not-email-form": "Please fill in a valid email address.", + + "register-page.registration.email.error.not-valid-domain": "Use email with allowed domains: {{ domains }}", "register-page.registration.email.hint": "This address will be verified and used as your login name.", @@ -3359,6 +3361,8 @@ "register-page.registration.error.recaptcha": "Error when trying to authenticate with recaptcha", "register-page.registration.google-recaptcha.must-accept-cookies": "In order to register you must accept the Registration and Password recovery (Google reCaptcha) cookies.", + "register-page.registration.error.maildomain": "This email address is not on the list of domains who can register. Allowed domains are {{ domains }}", + "register-page.registration.google-recaptcha.open-cookie-settings": "Open cookie settings", @@ -3367,6 +3371,7 @@ "register-page.registration.google-recaptcha.notification.message.error": "An error occurred during reCaptcha verification", "register-page.registration.google-recaptcha.notification.message.expired": "Verification expired. Please verify again.", + "register-page.registration.info.maildomain": "Accounts can be registered for mail addresses of the domains", "relationships.add.error.relationship-type.content": "No suitable match could be found for relationship type {{ type }} between the two items", diff --git a/src/config/cache-config.interface.ts b/src/config/cache-config.interface.ts index c535a96bb5..9560fe46a5 100644 --- a/src/config/cache-config.interface.ts +++ b/src/config/cache-config.interface.ts @@ -5,6 +5,31 @@ export interface CacheConfig extends Config { msToLive: { default: number; }; + // Cache-Control HTTP Header control: string; autoSync: AutoSyncConfig; + // In-memory caches of server-side rendered (SSR) content. These caches can be used to limit the frequency + // of re-generating SSR pages to improve performance. + serverSide: { + // Debug server-side caching. Set to true to see cache hits/misses/refreshes in console logs. + debug: boolean, + // Cache specific to known bots. Allows you to serve cached contents to bots only. + botCache: { + // Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache. + max: number; + // Amount of time after which cached pages are considered stale (in ms) + timeToLive: number; + // true = return page from cache after timeToLive expires. false = return a fresh page after timeToLive expires + allowStale: boolean; + }, + // Cache specific to anonymous users. Allows you to serve cached content to non-authenticated users. + anonymousCache: { + // Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache. + max: number; + // Amount of time after which cached pages are considered stale (in ms) + timeToLive: number; + // true = return page from cache after timeToLive expires. false = return a fresh page after timeToLive expires + allowStale: boolean; + } + } } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 205ea8acc0..e7851d4b34 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -67,11 +67,34 @@ export class DefaultAppConfig implements AppConfig { msToLive: { default: 15 * 60 * 1000 // 15 minutes }, - control: 'max-age=60', // revalidate browser + // Cache-Control HTTP Header + control: 'max-age=604800', // revalidate browser autoSync: { defaultTime: 0, maxBufferSize: 100, timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds + }, + // In-memory cache of server-side rendered content + serverSide: { + debug: false, + // Cache specific to known bots. Allows you to serve cached contents to bots only. + // Defaults to caching 1,000 pages. Each page expires after 1 day + botCache: { + // Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache. + max: 1000, + // Amount of time after which cached pages are considered stale (in ms) + timeToLive: 24 * 60 * 60 * 1000, // 1 day + allowStale: true, + }, + // Cache specific to anonymous users. Allows you to serve cached content to non-authenticated users. + // Defaults to caching 0 pages. But, when enabled, each page expires after 10 seconds (to minimize anonymous users seeing out-of-date content) + anonymousCache: { + // Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache. + max: 0, // disabled by default + // Amount of time after which cached pages are considered stale (in ms) + timeToLive: 10 * 1000, // 10 seconds + allowStale: true, + } } }; diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index b323fa464d..0bb36da61f 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -55,6 +55,20 @@ export const environment: BuildConfig = { defaultTime: 0, maxBufferSize: 100, timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds + }, + // In-memory cache of server-side rendered pages. Disabled in test environment (max=0) + serverSide: { + debug: false, + botCache: { + max: 0, + timeToLive: 24 * 60 * 60 * 1000, // 1 day + allowStale: true, + }, + anonymousCache: { + max: 0, + timeToLive: 10 * 1000, // 10 seconds + allowStale: true, + } } }, diff --git a/src/index.html b/src/index.html index ddd448f289..565fc0439d 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,7 @@ DSpace + diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 81426e7fcc..7d162c5fd1 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -33,6 +33,8 @@ import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mo import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; import { ServerInitService } from './server-init.service'; +import { XhrFactory } from '@angular/common'; +import { ServerXhrService } from '../../app/core/services/server-xhr.service'; export function createTranslateLoader(transferState: TransferState) { return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json'); @@ -104,6 +106,10 @@ export function createTranslateLoader(transferState: TransferState) { provide: HardRedirectService, useClass: ServerHardRedirectService, }, + { + provide: XhrFactory, + useClass: ServerXhrService, + }, ] }) export class ServerAppModule { diff --git a/yarn.lock b/yarn.lock index 2bbabcd654..3843ac4dab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6749,6 +6749,11 @@ isbinaryfile@^4.0.8: resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== +isbot@^3.6.5: + version "3.6.5" + resolved "https://registry.yarnpkg.com/isbot/-/isbot-3.6.5.tgz#a749980d9dfba9ebcc03ee7b548d1f24dd8c9f1e" + integrity sha512-BchONELXt6yMad++BwGpa0oQxo/uD0keL7N15cYVf0A1oMIoNQ79OqeYdPMFWDrNhCqCbRuw9Y9F3QBjvAxZ5g== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -7468,7 +7473,7 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.7.1: +lru-cache@^7.14.1, lru-cache@^7.7.1: version "7.14.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea" integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==