diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0b5b3f9d8c..d2e8b9fe5e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,6 +53,9 @@ jobs: key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: ${{ runner.os }}-yarn- + - name: Install the latest chromedriver compatible with the installed chrome version + run: yarn global add chromedriver --detect_chromedriver_version + - name: Install Yarn dependencies run: yarn install --frozen-lockfile @@ -94,7 +97,9 @@ jobs: run: curl http://localhost:8080/server/api - name: Run e2e tests (integration tests) - run: yarn run e2e:ci + run: | + chromedriver --url-base='/wd/hub' --port=4444 & + yarn run e2e:ci - name: Shutdown Docker containers run: docker-compose -f ./docker/docker-compose-ci.yml down diff --git a/angular.json b/angular.json index 19cbe94be6..00799dc33c 100644 --- a/angular.json +++ b/angular.json @@ -56,6 +56,11 @@ "input": "src/themes/custom/styles/theme.scss", "inject": false, "bundleName": "custom-theme" + }, + { + "input": "src/themes/dspace/styles/theme.scss", + "inject": false, + "bundleName": "dspace-theme" } ], "scripts": [] diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index 592194a752..c2846286d7 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -22,7 +22,8 @@ services: networks: dspacenet: {} environment: - - LOADASSETS=https://www.dropbox.com/s/v3ahfcuatklbmi0/assetstore-2019-11-28.tar.gz?dl=1 + # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data + - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz entrypoint: - /bin/bash - '-c' diff --git a/docker/db.entities.yml b/docker/db.entities.yml index d39eedc5c6..818d14877c 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -19,4 +19,33 @@ services: image: dspace/dspace-postgres-pgcrypto:loadsql environment: # This LOADSQL should be kept in sync with the URL in DSpace/DSpace - - LOADSQL=https://www.dropbox.com/s/4ap1y6deseoc8ws/dspace7-entities-2019-11-28.sql?dl=1 + # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data + - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql + dspace: + ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### + # Ensure that the database is ready BEFORE starting tomcat + # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep + # 2. Then, run database migration to init database tables + # 3. (Custom for Entities) enable Entity-specific collection submission mappings in item-submission.xml + # This 'sed' command inserts the sample configurations specific to the Entities data set, see: + # https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49 + # 4. Finally, start Tomcat + entrypoint: + - /bin/bash + - '-c' + - | + while (! /dev/null 2>&1; do sleep 1; done; + /dspace/bin/dspace database migrate + sed -i '/name-map collection-handle="default".*/a \\n \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + ' /dspace/config/item-submission.xml + catalina.sh run \ No newline at end of file diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index ec2d0912cf..d2d02f0a55 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -8,9 +8,13 @@ # Docker Compose for running the DSpace backend for e2e testing in a CI environment # This is used by our GitHub CI at .github/workflows/build.yml +# It is based heavily on the Backend's Docker Compose: +# https://github.com/DSpace/DSpace/blob/main/docker-compose.yml +version: '3.7' networks: dspacenet: services: + # DSpace (backend) webapp container dspace: container_name: dspace depends_on: @@ -26,12 +30,28 @@ services: volumes: - assetstore:/dspace/assetstore - "./local.cfg:/dspace/config/local.cfg" + # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) + - solr_configs:/dspace/solr + # Ensure that the database is ready BEFORE starting tomcat + # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep + # 2. Then, run database migration to init database tables + # 3. Finally, start Tomcat + entrypoint: + - /bin/bash + - '-c' + - | + while (! /dev/null 2>&1; do sleep 1; done; + /dspace/bin/dspace database migrate + catalina.sh run + # DSpace database container + # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data dspacedb: container_name: dspacedb environment: # This LOADSQL should be kept in sync with the LOADSQL in # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml - LOADSQL: https://www.dropbox.com/s/4ap1y6deseoc8ws/dspace7-entities-2019-11-28.sql?dl=1 + # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data + LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql PGDATA: /pgdata image: dspace/dspace-postgres-pgcrypto:loadsql networks: @@ -40,9 +60,14 @@ services: tty: true volumes: - pgdata:/pgdata + # DSpace Solr container dspacesolr: container_name: dspacesolr - image: dspace/dspace-solr + # Uses official Solr image at https://hub.docker.com/_/solr/ + image: solr:8.8 + # Needs main 'dspace' container to start first to guarantee access to solr_configs + depends_on: + - dspace networks: dspacenet: ports: @@ -50,16 +75,27 @@ services: target: 8983 stdin_open: true tty: true + working_dir: /var/solr/data volumes: - - solr_authority:/opt/solr/server/solr/authority/data - - solr_oai:/opt/solr/server/solr/oai/data - - solr_search:/opt/solr/server/solr/search/data - - solr_statistics:/opt/solr/server/solr/statistics/data -version: '3.7' + # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder) + # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume + - solr_configs:/opt/solr/server/solr/configsets/dspace + # Keep Solr data directory between reboots + - solr_data:/var/solr/data + # Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr + entrypoint: + - /bin/bash + - '-c' + - | + init-var-solr + precreate-core authority /opt/solr/server/solr/configsets/dspace/authority + precreate-core oai /opt/solr/server/solr/configsets/dspace/oai + precreate-core search /opt/solr/server/solr/configsets/dspace/search + precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics + exec solr -f volumes: assetstore: pgdata: - solr_authority: - solr_oai: - solr_search: - solr_statistics: + solr_data: + # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) + solr_configs: \ No newline at end of file diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index e6668f3f58..c99e469941 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -14,6 +14,7 @@ version: '3.7' networks: dspacenet: services: + # DSpace (backend) webapp container dspace: container_name: dspace image: dspace/dspace:dspace-7_x-test @@ -29,6 +30,8 @@ services: volumes: - assetstore:/dspace/assetstore - "./local.cfg:/dspace/config/local.cfg" + # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) + - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables @@ -40,6 +43,7 @@ services: while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate catalina.sh run + # DSpace database container dspacedb: container_name: dspacedb environment: @@ -54,9 +58,14 @@ services: tty: true volumes: - pgdata:/pgdata + # DSpace Solr container dspacesolr: container_name: dspacesolr - image: dspace/dspace-solr + # Uses official Solr image at https://hub.docker.com/_/solr/ + image: solr:8.8 + # Needs main 'dspace' container to start first to guarantee access to solr_configs + depends_on: + - dspace networks: dspacenet: ports: @@ -64,15 +73,27 @@ services: target: 8983 stdin_open: true tty: true + working_dir: /var/solr/data volumes: - - solr_authority:/opt/solr/server/solr/authority/data - - solr_oai:/opt/solr/server/solr/oai/data - - solr_search:/opt/solr/server/solr/search/data - - solr_statistics:/opt/solr/server/solr/statistics/data + # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder) + # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume + - solr_configs:/opt/solr/server/solr/configsets/dspace + # Keep Solr data directory between reboots + - solr_data:/var/solr/data + # Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr + entrypoint: + - /bin/bash + - '-c' + - | + init-var-solr + precreate-core authority /opt/solr/server/solr/configsets/dspace/authority + precreate-core oai /opt/solr/server/solr/configsets/dspace/oai + precreate-core search /opt/solr/server/solr/configsets/dspace/search + precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics + exec solr -f volumes: assetstore: pgdata: - solr_authority: - solr_oai: - solr_search: - solr_statistics: + solr_data: + # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) + solr_configs: diff --git a/docs/Configuration.md b/docs/Configuration.md index f918622568..f4fff1166c 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -64,69 +64,3 @@ In order to start using one of these services, select it from the [Angulartics P The Google Analytics script was added in [`main.browser.ts`](https://github.com/DSpace/dspace-angular/blob/ff04760f4af91ac3e7add5e7424a46cb2439e874/src/main.browser.ts#L33) instead of the `` tag in `index.html` to ensure events get sent when the page is shown in a client's browser, and not when it's rendered on the universal server. Likely you'll want to do the same when adding a new service. -## SEO when hosting REST Api and UI on different servers - -Indexers such as Google Scholar require that files are hosted on the same domain as the page that links them. In DSpace 7, Bitstreams are served from the REST server. So if you use different servers for the REST api and the UI you'll want to ensure that Bitstream downloads are proxied through the UI server. - -In order to achieve this we'll need to do two things: -- **Proxy the Bitstream downloads through the UI server.** You'll need to put a webserver such as httpd or nginx in front of the UI server in order to achieve this. [Below](#apache-http-server-config) you'll find a section explaining how to do it in httpd. -- **Update the URLs for Bitstream downloads to match the UI server.** This can be done using a setting in the UI environment file. - -### UI config -If you set the property `rewriteDownloadUrls` to `true` in your `environment.prod.ts` file, the [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin) of any download URL will be replaced by the origin of the UI. This will also happen for the `citation_pdf_url` `` tag on Item pages. - -The app will determine the UI origin currently in use, so the external UI URL doesn't need to be configured anywhere and rewrites will still work if you host the UI from multiple domains. - -### Apache HTTP Server config - -#### Basics -In order to be able to host bitstreams from the UI Server you'll need to enable mod_proxy and add the following to the httpd config of your UI server: - -``` -ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content" -ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content" -``` - -Replace http://rest.api in with the correct origin for your REST server. - -The `ProxyPassMatch` line forwards all requests matching the regular expression for a bitstream download URL to the corresponding path on the REST server - -The `ProxyPassReverse` ensures that if the REST server were to return redirect response, httpd would also swap out its hostname for the hostname of the UI before forwarding the response to the client. - -#### Using HTTPS -If your REST server uses https, you'll need to enable mod_ssl and ensure `SSLProxyEngine on` is part of your UI server's httpd config as well - -If the UI hostname doesn't match the CN in the SSL certificate of the REST server (which is likely if they're on different domains), you'll also need to add the following lines - -``` -SSLProxyCheckPeerCN off -SSLProxyCheckPeerName off -``` -These are two names for [the same directive](https://httpd.apache.org/docs/trunk/mod/mod_ssl.html#sslproxycheckpeername) that have been used for various versions of httpd, old versions need the former, then some in-between versions need both, and newer versions only need the latter. Keeping them both doesn't harm anything. - -So the entire config becomes: - -``` -SSLProxyEngine on -SSLProxyCheckPeerCN off -SSLProxyCheckPeerName off -ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" -ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" -``` - -If you don't want httpd to verify the certificate of the REST server, you can also turn all checks off with the following config: - -``` -SSLProxyEngine on -SSLProxyVerify none -SSLProxyCheckPeerCN off -SSLProxyCheckPeerName off -SSLProxyCheckPeerExpire off -ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" -ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" -``` - - - - - diff --git a/e2e/protractor-ci.conf.js b/e2e/protractor-ci.conf.js index 63173e44e3..0cfc1f9eaf 100644 --- a/e2e/protractor-ci.conf.js +++ b/e2e/protractor-ci.conf.js @@ -7,4 +7,8 @@ config.capabilities = { } }; +// don't use protractor's webdriver, as it may be incompatible with the installed chrome version +config.directConnect = false; +config.seleniumAddress = 'http://localhost:4444/wd/hub'; + exports.config = config; diff --git a/package.json b/package.json index 4008bb0ac3..80af52e264 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "lint": "ng lint", "lint-fix": "ng lint --fix=true", "e2e": "ng e2e", - "e2e:ci": "ng e2e --protractor-config=./e2e/protractor-ci.conf.js", + "e2e:ci": "ng e2e --webdriver-update=false --protractor-config=./e2e/protractor-ci.conf.js", "compile:server": "webpack --config webpack.server.config.js --progress --color", "serve:ssr": "node dist/server", "clean:coverage": "rimraf coverage", diff --git a/server.ts b/server.ts index ada6c9f040..73b88cd0c6 100644 --- a/server.ts +++ b/server.ts @@ -160,6 +160,11 @@ function ngApp(req, res) { }, (err, data) => { if (hasNoValue(err) && hasValue(data)) { 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)) { diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts index 14d688064c..bbbd65f279 100644 --- a/src/app/+bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -3,6 +3,7 @@ import { RouterModule } from '@angular/router'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { BitstreamPageResolver } from './bitstream-page.resolver'; +import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component'; const EDIT_BITSTREAM_PATH = ':id/edit'; @@ -12,6 +13,13 @@ const EDIT_BITSTREAM_PATH = ':id/edit'; @NgModule({ imports: [ RouterModule.forChild([ + { + path:':id/download', + component: BitstreamDownloadPageComponent, + resolve: { + bitstream: BitstreamPageResolver + }, + }, { path: EDIT_BITSTREAM_PATH, component: EditBitstreamPageComponent, diff --git a/src/app/+home-page/home-news/home-news.component.html b/src/app/+home-page/home-news/home-news.component.html index 812c38f798..6bee3cd76f 100644 --- a/src/app/+home-page/home-news/home-news.component.html +++ b/src/app/+home-page/home-news/home-news.component.html @@ -2,7 +2,7 @@
-

Welcome to the DSpace 7 Preview

+

DSpace 7

DSpace is the world leading open source repository platform that enables organisations to:

@@ -13,6 +13,8 @@
  • issue permanent urls and trustworthy identifiers, including optional integrations with handle.net and DataCite DOI
  • -

    Join an international community of leading institutions using DSpace.

    +

    Join an international community of leading institutions using DSpace. +

    diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index d593d60ce6..bc1c63cc32 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -33,7 +33,7 @@
    - + {{"item.page.filesection.download" | translate}}
    @@ -74,7 +74,7 @@
    - + {{"item.page.filesection.download" | translate}}
    diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.html b/src/app/+item-page/simple/field-components/file-section/file-section.component.html index 1fdee6dc4d..0fa5daa012 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.html @@ -1,7 +1,7 @@
    - + {{file?.name}} ({{(file?.sizeBytes) | dsFileSize }}) diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 08f7b9585f..7dfdbd2c49 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -6,6 +6,7 @@ import { getCommunityPageRoute } from './+community-page/community-page-routing- import { getCollectionPageRoute } from './+collection-page/collection-page-routing-paths'; import { getItemPageRoute } from './+item-page/item-page-routing-paths'; import { hasValue } from './shared/empty.util'; +import { URLCombiner } from './core/url-combiner/url-combiner'; export const BITSTREAM_MODULE_PATH = 'bitstreams'; @@ -13,6 +14,10 @@ export function getBitstreamModuleRoute() { return `/${BITSTREAM_MODULE_PATH}`; } +export function getBitstreamDownloadRoute(bitstream): string { + return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(); +} + export const ADMIN_MODULE_PATH = 'admin'; export function getAdminModuleRoute() { diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts new file mode 100644 index 0000000000..707daf9e30 --- /dev/null +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -0,0 +1,74 @@ +import { AuthRequestService } from './auth-request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { PostRequest } from '../data/request.models'; +import { TestScheduler } from 'rxjs/testing'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { ShortLivedToken } from './models/short-lived-token.model'; +import { RemoteData } from '../data/remote-data'; + +describe(`AuthRequestService`, () => { + let halService: HALEndpointService; + let endpointURL: string; + let shortLivedToken: ShortLivedToken; + let shortLivedTokenRD: RemoteData; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let service: AuthRequestService; + let testScheduler; + + class TestAuthRequestService extends AuthRequestService { + constructor( + hes: HALEndpointService, + rs: RequestService, + rdbs: RemoteDataBuildService + ) { + super(hes, rs, rdbs); + } + + protected createShortLivedTokenRequest(href: string): PostRequest { + return new PostRequest(this.requestService.generateRequestId(), href); + } + } + + const init = (cold: typeof TestScheduler.prototype.createColdObservable) => { + endpointURL = 'https://rest.api/auth'; + shortLivedToken = Object.assign(new ShortLivedToken(), { + value: 'some-token' + }); + shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken); + + halService = jasmine.createSpyObj('halService', { + 'getEndpoint': cold('a', { a: endpointURL }) + }); + requestService = jasmine.createSpyObj('requestService', { + 'send': null + }); + rdbService = jasmine.createSpyObj('rdbService', { + 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) + }); + + service = new TestAuthRequestService(halService, requestService, rdbService); + }; + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + describe(`getShortlivedToken`, () => { + it(`should call createShortLivedTokenRequest with the url for the endpoint`, () => { + testScheduler.run(({ cold, expectObservable, flush }) => { + init(cold); + spyOn(service as any, 'createShortLivedTokenRequest'); + // expectObservable is needed to let testScheduler know to take it in to account, but since + // we're not testing the outcome in this test, a .toBe(…) isn't necessary + expectObservable(service.getShortlivedToken()); + flush(); + expect((service as any).createShortLivedTokenRequest).toHaveBeenCalledWith(`${endpointURL}/shortlivedtokens`); + }); + }); + }); +}); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 4315ddfea8..00a94822d3 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,14 +1,9 @@ import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { isNotEmpty } from '../../shared/empty.util'; -import { - GetRequest, - PostRequest, - RestRequest, -} from '../data/request.models'; +import { GetRequest, PostRequest, RestRequest, } from '../data/request.models'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -17,8 +12,10 @@ import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; import { URLCombiner } from '../url-combiner/url-combiner'; -@Injectable() -export class AuthRequestService { +/** + * Abstract service to send authentication requests + */ +export abstract class AuthRequestService { protected linkName = 'authn'; protected browseEndpoint = ''; protected shortlivedtokensEndpoint = 'shortlivedtokens'; @@ -62,16 +59,26 @@ export class AuthRequestService { } /** - * Send a POST request to retrieve a short-lived token which provides download access of restricted files + * 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. + * + * @param href The href to send the request to + * @protected + */ + protected abstract createShortLivedTokenRequest(href: string): GetRequest | PostRequest; + + /** + * Send a request to retrieve a short-lived token which provides download access of restricted files */ public getShortlivedToken(): Observable { return this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()), - map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL)), - tap((request: PostRequest) => this.requestService.send(request)), - switchMap((request: PostRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), + map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)), + tap((request: RestRequest) => this.requestService.send(request)), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), getFirstCompletedRemoteData(), map((response: RemoteData) => { if (response.hasSucceeded) { diff --git a/src/app/core/auth/browser-auth-request.service.spec.ts b/src/app/core/auth/browser-auth-request.service.spec.ts new file mode 100644 index 0000000000..18d27340af --- /dev/null +++ b/src/app/core/auth/browser-auth-request.service.spec.ts @@ -0,0 +1,29 @@ +import { AuthRequestService } from './auth-request.service'; +import { RequestService } from '../data/request.service'; +import { BrowserAuthRequestService } from './browser-auth-request.service'; + +describe(`BrowserAuthRequestService`, () => { + let href: string; + let requestService: RequestService; + let service: AuthRequestService; + + beforeEach(() => { + href = 'https://rest.api/auth/shortlivedtokens'; + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + }); + service = new BrowserAuthRequestService(null, requestService, null); + }); + + describe(`createShortLivedTokenRequest`, () => { + it(`should return a PostRequest`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.constructor.name).toBe('PostRequest'); + }); + + it(`should return a request with the given href`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.href).toBe(href) ; + }); + }); +}); diff --git a/src/app/core/auth/browser-auth-request.service.ts b/src/app/core/auth/browser-auth-request.service.ts new file mode 100644 index 0000000000..85d5f54340 --- /dev/null +++ b/src/app/core/auth/browser-auth-request.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { AuthRequestService } from './auth-request.service'; +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'; + +/** + * Client side version of the service to send authentication requests + */ +@Injectable() +export class BrowserAuthRequestService extends AuthRequestService { + + constructor( + halService: HALEndpointService, + requestService: RequestService, + rdbService: RemoteDataBuildService + ) { + 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. + * + * @param href The href to send the request to + * @protected + */ + protected createShortLivedTokenRequest(href: string): PostRequest { + return 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 new file mode 100644 index 0000000000..69053fbb3a --- /dev/null +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -0,0 +1,34 @@ +import { AuthRequestService } from './auth-request.service'; +import { RequestService } from '../data/request.service'; +import { ServerAuthRequestService } from './server-auth-request.service'; + +describe(`ServerAuthRequestService`, () => { + let href: string; + let requestService: RequestService; + let service: AuthRequestService; + + beforeEach(() => { + href = 'https://rest.api/auth/shortlivedtokens'; + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + }); + service = new ServerAuthRequestService(null, requestService, null); + }); + + describe(`createShortLivedTokenRequest`, () => { + it(`should return a GetRequest`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.constructor.name).toBe('GetRequest'); + }); + + it(`should return a request with the given href`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.href).toBe(href) ; + }); + + it(`should have a responseMsToLive of 2 seconds`, () => { + const result = (service as any).createShortLivedTokenRequest(href); + expect(result.responseMsToLive).toBe(2 * 1000) ; + }); + }); +}); diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts new file mode 100644 index 0000000000..751389f71d --- /dev/null +++ b/src/app/core/auth/server-auth-request.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { AuthRequestService } from './auth-request.service'; +import { GetRequest } 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'; + +/** + * Server side version of the service to send authentication requests + */ +@Injectable() +export class ServerAuthRequestService extends AuthRequestService { + + constructor( + halService: HALEndpointService, + requestService: RequestService, + rdbService: RemoteDataBuildService + ) { + 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. + * + * @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. + }); + } + +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index f73bfd0bdf..619a7dbadc 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -31,7 +31,6 @@ import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { UploaderService } from '../shared/uploader/uploader.service'; import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service'; -import { AuthRequestService } from './auth/auth-request.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthStatus } from './auth/models/auth-status.model'; import { BrowseService } from './browse/browse.service'; @@ -188,7 +187,6 @@ const EXPORTS = []; const PROVIDERS = [ ApiService, AuthenticatedGuard, - AuthRequestService, CommunityDataService, CollectionDataService, SiteDataService, diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index df77dd8949..9a3d13183b 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -12,6 +12,7 @@ export enum FeatureID { CanManageGroups = 'canManageGroups', IsCollectionAdmin = 'isCollectionAdmin', IsCommunityAdmin = 'isCommunityAdmin', + CanDownload = 'canDownload', CanManageVersions = 'canManageVersions', CanManageBitstreams = 'canManageBitstreams', CanManageRelationships = 'canManageRelationships', diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 25165aba79..18421dd489 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -181,11 +181,7 @@ describe('MetadataService', () => { Meta, Title, // tslint:disable-next-line:no-empty - { provide: ItemDataService, useValue: { findById: () => { } } }, - { - provide: HardRedirectService, - useValue: { rewriteDownloadURL: (a) => a, getRequestOrigin: () => environment.ui.baseUrl } - }, + { provide: ItemDataService, useValue: { findById: () => {} } }, BrowseService, MetadataService ], @@ -225,8 +221,8 @@ describe('MetadataService', () => { tick(); expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual(new URLCombiner(environment.ui.baseUrl, router.url).toString()); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content'); + expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join('')); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'); })); it('items page should set meta tags as published Technical Report', fakeAsync(() => { diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index dac0a787fb..807f7a42ab 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -19,10 +19,13 @@ import { BitstreamFormat } from '../shared/bitstream-format.model'; import { Bitstream } from '../shared/bitstream.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; -import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../shared/operators'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { URLCombiner } from '../url-combiner/url-combiner'; +import { + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteListPayload +} from '../shared/operators'; +import { environment } from '../../../environments/environment'; import { RootDataService } from '../data/root-data.service'; +import { getBitstreamDownloadRoute } from '../../app-routing-paths'; @Injectable() export class MetadataService { @@ -41,7 +44,6 @@ export class MetadataService { private dsoNameService: DSONameService, private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, - private redirectService: HardRedirectService, private rootService: RootDataService ) { // TODO: determine what open graph meta tags are needed and whether @@ -262,7 +264,7 @@ export class MetadataService { */ private setCitationAbstractUrlTag(): void { if (this.currentObject.value instanceof Item) { - const value = new URLCombiner(this.redirectService.getRequestOrigin(), this.router.url).toString(); + const value = [environment.ui.baseUrl, this.router.url].join(''); this.addMetaTag('citation_abstract_html_url', value); } } @@ -287,8 +289,8 @@ export class MetadataService { getFirstSucceededRemoteDataPayload() ).subscribe((format: BitstreamFormat) => { if (format.mimetype === 'application/pdf') { - const rewrittenURL= this.redirectService.rewriteDownloadURL(bitstream._links.content.href); - this.addMetaTag('citation_pdf_url', rewrittenURL); + const bitstreamLink = getBitstreamDownloadRoute(bitstream); + this.addMetaTag('citation_pdf_url', bitstreamLink); } }); } diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts index fcbd86161a..98c468e9a3 100644 --- a/src/app/core/shared/file.service.ts +++ b/src/app/core/shared/file.service.ts @@ -1,10 +1,11 @@ import { Inject, Injectable } from '@angular/core'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { AuthService } from '../auth/auth.service'; -import { take } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { hasValue } from '../../shared/empty.util'; +import { Observable } from 'rxjs/internal/Observable'; /** * Provides utility methods to save files on the client-side. @@ -17,17 +18,16 @@ export class FileService { ) { } /** - * Combines an URL with a short-lived token and sets the current URL to the newly created one + * Combines an URL with a short-lived token and sets the current URL to the newly created one and returns it * * @param url * file url */ - downloadFile(url: string) { - this.authService.getShortlivedToken().pipe(take(1)).subscribe((token) => { - this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url; - }); + retrieveFileDownloadLink(url: string): Observable { + return this.authService.getShortlivedToken().pipe(take(1), map((token) => + hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url + )); } - /** * Derives file name from the http response * by looking inside content-disposition diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index fc3f2651e1..fbdca1beff 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -45,32 +45,32 @@ export const sendRequest = (requestService: RequestService) => (source: Observable): Observable => source.pipe(tap((request: RestRequest) => requestService.send(request))); -export const getRemoteDataPayload = () => - (source: Observable>): Observable => +export const getRemoteDataPayload = () => + (source: Observable>): Observable => source.pipe(map((remoteData: RemoteData) => remoteData.payload)); -export const getPaginatedListPayload = () => - (source: Observable>): Observable => +export const getPaginatedListPayload = () => + (source: Observable>): Observable => source.pipe(map((list: PaginatedList) => list.page)); -export const getAllCompletedRemoteData = () => - (source: Observable>): Observable> => +export const getAllCompletedRemoteData = () => + (source: Observable>): Observable> => source.pipe(filter((rd: RemoteData) => hasValue(rd) && rd.hasCompleted)); -export const getFirstCompletedRemoteData = () => - (source: Observable>): Observable> => +export const getFirstCompletedRemoteData = () => + (source: Observable>): Observable> => source.pipe(getAllCompletedRemoteData(), take(1)); -export const takeUntilCompletedRemoteData = () => - (source: Observable>): Observable> => +export const takeUntilCompletedRemoteData = () => + (source: Observable>): Observable> => source.pipe(takeWhile((rd: RemoteData) => hasNoValue(rd) || rd.isLoading, true)); -export const getFirstSucceededRemoteData = () => - (source: Observable>): Observable> => +export const getFirstSucceededRemoteData = () => + (source: Observable>): Observable> => source.pipe(filter((rd: RemoteData) => rd.hasSucceeded), take(1)); -export const getFirstSucceededRemoteWithNotEmptyData = () => - (source: Observable>): Observable> => +export const getFirstSucceededRemoteWithNotEmptyData = () => + (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => rd.hasSucceeded && isNotEmpty(rd.payload))); /** @@ -83,8 +83,8 @@ export const getFirstSucceededRemoteWithNotEmptyData = () => * These operators were created as a first step in refactoring * out all the instances where this is used incorrectly. */ -export const getFirstSucceededRemoteDataPayload = () => - (source: Observable>): Observable => +export const getFirstSucceededRemoteDataPayload = () => + (source: Observable>): Observable => source.pipe( getFirstSucceededRemoteData(), getRemoteDataPayload() @@ -100,8 +100,8 @@ export const getFirstSucceededRemoteDataPayload = () => * These operators were created as a first step in refactoring * out all the instances where this is used incorrectly. */ -export const getFirstSucceededRemoteDataWithNotEmptyPayload = () => - (source: Observable>): Observable => +export const getFirstSucceededRemoteDataWithNotEmptyPayload = () => + (source: Observable>): Observable => source.pipe( getFirstSucceededRemoteWithNotEmptyData(), getRemoteDataPayload() @@ -117,8 +117,8 @@ export const getFirstSucceededRemoteDataWithNotEmptyPayload = () => * These operators were created as a first step in refactoring * out all the instances where this is used incorrectly. */ -export const getAllSucceededRemoteDataPayload = () => - (source: Observable>): Observable => +export const getAllSucceededRemoteDataPayload = () => + (source: Observable>): Observable => source.pipe( getAllSucceededRemoteData(), getRemoteDataPayload() @@ -138,8 +138,8 @@ export const getAllSucceededRemoteDataPayload = () => * These operators were created as a first step in refactoring * out all the instances where this is used incorrectly. */ -export const getFirstSucceededRemoteListPayload = () => - (source: Observable>>): Observable => +export const getFirstSucceededRemoteListPayload = () => + (source: Observable>>): Observable => source.pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), @@ -160,8 +160,8 @@ export const getFirstSucceededRemoteListPayload = () => * These operators were created as a first step in refactoring * out all the instances where this is used incorrectly. */ -export const getAllSucceededRemoteListPayload = () => - (source: Observable>>): Observable => +export const getAllSucceededRemoteListPayload = () => + (source: Observable>>): Observable => source.pipe( getAllSucceededRemoteData(), getRemoteDataPayload(), @@ -174,8 +174,8 @@ export const getAllSucceededRemoteListPayload = () => * @param router The router used to navigate to a new page * @param authService Service to check if the user is authenticated */ -export const redirectOn4xx = (router: Router, authService: AuthService) => - (source: Observable>): Observable> => +export const redirectOn4xx = (router: Router, authService: AuthService) => + (source: Observable>): Observable> => observableCombineLatest(source, authService.isAuthenticated()).pipe( map(([rd, isAuthenticated]: [RemoteData, boolean]) => { if (rd.hasFailed) { @@ -242,16 +242,16 @@ export const returnEndUserAgreementUrlTreeOnFalse = (router: Router, redirect: s return hasAgreed ? hasAgreed : router.createUrlTree([getEndUserAgreementPath()], { queryParams }); })); -export const getFinishedRemoteData = () => - (source: Observable>): Observable> => +export const getFinishedRemoteData = () => + (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => !rd.isLoading)); -export const getAllSucceededRemoteData = () => - (source: Observable>): Observable> => +export const getAllSucceededRemoteData = () => + (source: Observable>): Observable> => source.pipe(filter((rd: RemoteData) => rd.hasSucceeded)); -export const toDSpaceObjectListRD = () => - (source: Observable>>>): Observable>> => +export const toDSpaceObjectListRD = () => + (source: Observable>>>): Observable>> => source.pipe( filter((rd: RemoteData>>) => rd.hasSucceeded), map((rd: RemoteData>>) => { diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 3756bce188..bc407c2a97 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -56,7 +56,7 @@

    {{ 'footer.link.dspace' | translate}} {{ 'footer.copyright' | translate:{year: dateObj | date:'y'} }} - {{ 'footer.link.duraspace' | translate}} + {{ 'footer.link.lyrasis' | translate}}