mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge pull request #1004 from atmire/w2p-76150_Add-a-bitstream-download-page
Bitstream download page
This commit is contained in:
@@ -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 `<head>` 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.
|
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 `<head>` 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` `<meta>` 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"
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@@ -160,6 +160,11 @@ function ngApp(req, res) {
|
|||||||
}, (err, data) => {
|
}, (err, data) => {
|
||||||
if (hasNoValue(err) && hasValue(data)) {
|
if (hasNoValue(err) && hasValue(data)) {
|
||||||
res.send(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 {
|
} else {
|
||||||
console.warn('Error in SSR, serving for direct CSR.');
|
console.warn('Error in SSR, serving for direct CSR.');
|
||||||
if (hasValue(err)) {
|
if (hasValue(err)) {
|
||||||
|
@@ -3,6 +3,7 @@ import { RouterModule } from '@angular/router';
|
|||||||
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
|
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
|
||||||
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||||
import { BitstreamPageResolver } from './bitstream-page.resolver';
|
import { BitstreamPageResolver } from './bitstream-page.resolver';
|
||||||
|
import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component';
|
||||||
|
|
||||||
const EDIT_BITSTREAM_PATH = ':id/edit';
|
const EDIT_BITSTREAM_PATH = ':id/edit';
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ const EDIT_BITSTREAM_PATH = ':id/edit';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild([
|
RouterModule.forChild([
|
||||||
|
{
|
||||||
|
path:':id/download',
|
||||||
|
component: BitstreamDownloadPageComponent,
|
||||||
|
resolve: {
|
||||||
|
bitstream: BitstreamPageResolver
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: EDIT_BITSTREAM_PATH,
|
path: EDIT_BITSTREAM_PATH,
|
||||||
component: EditBitstreamPageComponent,
|
component: EditBitstreamPageComponent,
|
||||||
|
@@ -33,7 +33,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
<ds-file-download-link [bitstream]="file">
|
||||||
{{"item.page.filesection.download" | translate}}
|
{{"item.page.filesection.download" | translate}}
|
||||||
</ds-file-download-link>
|
</ds-file-download-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
<ds-file-download-link [bitstream]="file">
|
||||||
{{"item.page.filesection.download" | translate}}
|
{{"item.page.filesection.download" | translate}}
|
||||||
</ds-file-download-link>
|
</ds-file-download-link>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
<ng-container *ngVar="(bitstreams$ | async) as bitstreams">
|
<ng-container *ngVar="(bitstreams$ | async) as bitstreams">
|
||||||
<ds-metadata-field-wrapper *ngIf="bitstreams?.length > 0" [label]="label | translate">
|
<ds-metadata-field-wrapper *ngIf="bitstreams?.length > 0" [label]="label | translate">
|
||||||
<div class="file-section">
|
<div class="file-section">
|
||||||
<ds-file-download-link *ngFor="let file of bitstreams; let last=last;" [href]="file?._links.content.href" [download]="file?.name">
|
<ds-file-download-link *ngFor="let file of bitstreams; let last=last;" [bitstream]="file">
|
||||||
<span>{{file?.name}}</span>
|
<span>{{file?.name}}</span>
|
||||||
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
|
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
|
||||||
<span *ngIf="!last" innerHTML="{{separator}}"></span>
|
<span *ngIf="!last" innerHTML="{{separator}}"></span>
|
||||||
|
@@ -6,6 +6,7 @@ import { getCommunityPageRoute } from './+community-page/community-page-routing-
|
|||||||
import { getCollectionPageRoute } from './+collection-page/collection-page-routing-paths';
|
import { getCollectionPageRoute } from './+collection-page/collection-page-routing-paths';
|
||||||
import { getItemPageRoute } from './+item-page/item-page-routing-paths';
|
import { getItemPageRoute } from './+item-page/item-page-routing-paths';
|
||||||
import { hasValue } from './shared/empty.util';
|
import { hasValue } from './shared/empty.util';
|
||||||
|
import { URLCombiner } from './core/url-combiner/url-combiner';
|
||||||
|
|
||||||
export const BITSTREAM_MODULE_PATH = 'bitstreams';
|
export const BITSTREAM_MODULE_PATH = 'bitstreams';
|
||||||
|
|
||||||
@@ -13,6 +14,10 @@ export function getBitstreamModuleRoute() {
|
|||||||
return `/${BITSTREAM_MODULE_PATH}`;
|
return `/${BITSTREAM_MODULE_PATH}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBitstreamDownloadRoute(bitstream): string {
|
||||||
|
return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
|
||||||
|
}
|
||||||
|
|
||||||
export const ADMIN_MODULE_PATH = 'admin';
|
export const ADMIN_MODULE_PATH = 'admin';
|
||||||
|
|
||||||
export function getAdminModuleRoute() {
|
export function getAdminModuleRoute() {
|
||||||
|
74
src/app/core/auth/auth-request.service.spec.ts
Normal file
74
src/app/core/auth/auth-request.service.spec.ts
Normal file
@@ -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<ShortLivedToken>;
|
||||||
|
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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -1,14 +1,9 @@
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators';
|
||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { isNotEmpty } from '../../shared/empty.util';
|
import { isNotEmpty } from '../../shared/empty.util';
|
||||||
import {
|
import { GetRequest, PostRequest, RestRequest, } from '../data/request.models';
|
||||||
GetRequest,
|
|
||||||
PostRequest,
|
|
||||||
RestRequest,
|
|
||||||
} from '../data/request.models';
|
|
||||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
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 { ShortLivedToken } from './models/short-lived-token.model';
|
||||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
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 linkName = 'authn';
|
||||||
protected browseEndpoint = '';
|
protected browseEndpoint = '';
|
||||||
protected shortlivedtokensEndpoint = 'shortlivedtokens';
|
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<string> {
|
public getShortlivedToken(): Observable<string> {
|
||||||
return this.halService.getEndpoint(this.linkName).pipe(
|
return this.halService.getEndpoint(this.linkName).pipe(
|
||||||
filter((href: string) => isNotEmpty(href)),
|
filter((href: string) => isNotEmpty(href)),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()),
|
map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()),
|
||||||
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL)),
|
map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)),
|
||||||
tap((request: PostRequest) => this.requestService.send(request)),
|
tap((request: RestRequest) => this.requestService.send(request)),
|
||||||
switchMap((request: PostRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)),
|
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)),
|
||||||
getFirstCompletedRemoteData(),
|
getFirstCompletedRemoteData(),
|
||||||
map((response: RemoteData<ShortLivedToken>) => {
|
map((response: RemoteData<ShortLivedToken>) => {
|
||||||
if (response.hasSucceeded) {
|
if (response.hasSucceeded) {
|
||||||
|
29
src/app/core/auth/browser-auth-request.service.spec.ts
Normal file
29
src/app/core/auth/browser-auth-request.service.spec.ts
Normal file
@@ -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) ;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
34
src/app/core/auth/browser-auth-request.service.ts
Normal file
34
src/app/core/auth/browser-auth-request.service.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
34
src/app/core/auth/server-auth-request.service.spec.ts
Normal file
34
src/app/core/auth/server-auth-request.service.spec.ts
Normal file
@@ -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) ;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
36
src/app/core/auth/server-auth-request.service.ts
Normal file
36
src/app/core/auth/server-auth-request.service.ts
Normal file
@@ -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.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -31,7 +31,6 @@ import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
|
|||||||
import { SidebarService } from '../shared/sidebar/sidebar.service';
|
import { SidebarService } from '../shared/sidebar/sidebar.service';
|
||||||
import { UploaderService } from '../shared/uploader/uploader.service';
|
import { UploaderService } from '../shared/uploader/uploader.service';
|
||||||
import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.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 { AuthenticatedGuard } from './auth/authenticated.guard';
|
||||||
import { AuthStatus } from './auth/models/auth-status.model';
|
import { AuthStatus } from './auth/models/auth-status.model';
|
||||||
import { BrowseService } from './browse/browse.service';
|
import { BrowseService } from './browse/browse.service';
|
||||||
@@ -188,7 +187,6 @@ const EXPORTS = [];
|
|||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
ApiService,
|
ApiService,
|
||||||
AuthenticatedGuard,
|
AuthenticatedGuard,
|
||||||
AuthRequestService,
|
|
||||||
CommunityDataService,
|
CommunityDataService,
|
||||||
CollectionDataService,
|
CollectionDataService,
|
||||||
SiteDataService,
|
SiteDataService,
|
||||||
|
@@ -12,4 +12,5 @@ export enum FeatureID {
|
|||||||
CanManageGroups = 'canManageGroups',
|
CanManageGroups = 'canManageGroups',
|
||||||
IsCollectionAdmin = 'isCollectionAdmin',
|
IsCollectionAdmin = 'isCollectionAdmin',
|
||||||
IsCommunityAdmin = 'isCommunityAdmin',
|
IsCommunityAdmin = 'isCommunityAdmin',
|
||||||
|
CanDownload = 'canDownload',
|
||||||
}
|
}
|
||||||
|
@@ -181,11 +181,7 @@ describe('MetadataService', () => {
|
|||||||
Meta,
|
Meta,
|
||||||
Title,
|
Title,
|
||||||
// tslint:disable-next-line:no-empty
|
// tslint:disable-next-line:no-empty
|
||||||
{ provide: ItemDataService, useValue: { findById: () => { } } },
|
{ provide: ItemDataService, useValue: { findById: () => {} } },
|
||||||
{
|
|
||||||
provide: HardRedirectService,
|
|
||||||
useValue: { rewriteDownloadURL: (a) => a, getRequestOrigin: () => environment.ui.baseUrl }
|
|
||||||
},
|
|
||||||
BrowseService,
|
BrowseService,
|
||||||
MetadataService
|
MetadataService
|
||||||
],
|
],
|
||||||
@@ -225,8 +221,8 @@ describe('MetadataService', () => {
|
|||||||
tick();
|
tick();
|
||||||
expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document');
|
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_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_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join(''));
|
||||||
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_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('items page should set meta tags as published Technical Report', fakeAsync(() => {
|
it('items page should set meta tags as published Technical Report', fakeAsync(() => {
|
||||||
|
@@ -19,10 +19,13 @@ import { BitstreamFormat } from '../shared/bitstream-format.model';
|
|||||||
import { Bitstream } from '../shared/bitstream.model';
|
import { Bitstream } from '../shared/bitstream.model';
|
||||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||||
import { Item } from '../shared/item.model';
|
import { Item } from '../shared/item.model';
|
||||||
import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../shared/operators';
|
import {
|
||||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
getFirstSucceededRemoteDataPayload,
|
||||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
getFirstSucceededRemoteListPayload
|
||||||
|
} from '../shared/operators';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
import { RootDataService } from '../data/root-data.service';
|
import { RootDataService } from '../data/root-data.service';
|
||||||
|
import { getBitstreamDownloadRoute } from '../../app-routing-paths';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MetadataService {
|
export class MetadataService {
|
||||||
@@ -41,7 +44,6 @@ export class MetadataService {
|
|||||||
private dsoNameService: DSONameService,
|
private dsoNameService: DSONameService,
|
||||||
private bitstreamDataService: BitstreamDataService,
|
private bitstreamDataService: BitstreamDataService,
|
||||||
private bitstreamFormatDataService: BitstreamFormatDataService,
|
private bitstreamFormatDataService: BitstreamFormatDataService,
|
||||||
private redirectService: HardRedirectService,
|
|
||||||
private rootService: RootDataService
|
private rootService: RootDataService
|
||||||
) {
|
) {
|
||||||
// TODO: determine what open graph meta tags are needed and whether
|
// TODO: determine what open graph meta tags are needed and whether
|
||||||
@@ -262,7 +264,7 @@ export class MetadataService {
|
|||||||
*/
|
*/
|
||||||
private setCitationAbstractUrlTag(): void {
|
private setCitationAbstractUrlTag(): void {
|
||||||
if (this.currentObject.value instanceof Item) {
|
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);
|
this.addMetaTag('citation_abstract_html_url', value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,8 +289,8 @@ export class MetadataService {
|
|||||||
getFirstSucceededRemoteDataPayload()
|
getFirstSucceededRemoteDataPayload()
|
||||||
).subscribe((format: BitstreamFormat) => {
|
).subscribe((format: BitstreamFormat) => {
|
||||||
if (format.mimetype === 'application/pdf') {
|
if (format.mimetype === 'application/pdf') {
|
||||||
const rewrittenURL= this.redirectService.rewriteDownloadURL(bitstream._links.content.href);
|
const bitstreamLink = getBitstreamDownloadRoute(bitstream);
|
||||||
this.addMetaTag('citation_pdf_url', rewrittenURL);
|
this.addMetaTag('citation_pdf_url', bitstreamLink);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,11 @@
|
|||||||
import { Inject, Injectable } from '@angular/core';
|
import { Inject, Injectable } from '@angular/core';
|
||||||
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
|
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
|
||||||
import { AuthService } from '../auth/auth.service';
|
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 { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||||
import { hasValue } from '../../shared/empty.util';
|
import { hasValue } from '../../shared/empty.util';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides utility methods to save files on the client-side.
|
* 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
|
* @param url
|
||||||
* file url
|
* file url
|
||||||
*/
|
*/
|
||||||
downloadFile(url: string) {
|
retrieveFileDownloadLink(url: string): Observable<string> {
|
||||||
this.authService.getShortlivedToken().pipe(take(1)).subscribe((token) => {
|
return this.authService.getShortlivedToken().pipe(take(1), map((token) =>
|
||||||
this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url;
|
hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url
|
||||||
});
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives file name from the http response
|
* Derives file name from the http response
|
||||||
* by looking inside content-disposition
|
* by looking inside content-disposition
|
||||||
|
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div *ngVar="(filesRD$ | async)?.payload?.page as files">
|
<div *ngVar="(filesRD$ | async)?.payload?.page as files">
|
||||||
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files" [title]="'process.detail.output-files'">
|
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files" [title]="'process.detail.output-files'">
|
||||||
<ds-file-download-link *ngFor="let file of files; let last=last;" [href]="file?._links?.content?.href" [download]="getFileName(file)">
|
<ds-file-download-link *ngFor="let file of files; let last=last;" [bitstream]="file">
|
||||||
<span>{{getFileName(file)}}</span>
|
<span>{{getFileName(file)}}</span>
|
||||||
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
|
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
|
||||||
</ds-file-download-link>
|
</ds-file-download-link>
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h3>{{'bitstream.download.page' | translate:{bitstream: (bitstream$ | async)?.name} }}</h3>
|
||||||
|
</div>
|
@@ -0,0 +1,158 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
|
import { FileService } from '../../core/shared/file.service';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
|
import { BitstreamDownloadPageComponent } from './bitstream-download-page.component';
|
||||||
|
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||||
|
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||||
|
import { createSuccessfulRemoteDataObject } from '../remote-data.utils';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { getForbiddenRoute } from '../../app-routing-paths';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
describe('BitstreamDownloadPageComponent', () => {
|
||||||
|
let component: BitstreamDownloadPageComponent;
|
||||||
|
let fixture: ComponentFixture<BitstreamDownloadPageComponent>;
|
||||||
|
|
||||||
|
let authService: AuthService;
|
||||||
|
let fileService: FileService;
|
||||||
|
let authorizationService: AuthorizationDataService;
|
||||||
|
let hardRedirectService: HardRedirectService;
|
||||||
|
let activatedRoute;
|
||||||
|
let router;
|
||||||
|
|
||||||
|
let bitstream: Bitstream;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
authService = jasmine.createSpyObj('authService', {
|
||||||
|
isAuthenticated: observableOf(true),
|
||||||
|
setRedirectUrl: {}
|
||||||
|
});
|
||||||
|
authorizationService = jasmine.createSpyObj('authorizationSerivice', {
|
||||||
|
isAuthorized: observableOf(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
fileService = jasmine.createSpyObj('fileService', {
|
||||||
|
retrieveFileDownloadLink: observableOf('content-url-with-headers')
|
||||||
|
});
|
||||||
|
|
||||||
|
hardRedirectService = jasmine.createSpyObj('fileService', {
|
||||||
|
redirect: {}
|
||||||
|
});
|
||||||
|
bitstream = Object.assign(new Bitstream(), {
|
||||||
|
uuid: 'bitstreamUuid',
|
||||||
|
_links: {
|
||||||
|
content: {href: 'bitstream-content-link'},
|
||||||
|
self: {href: 'bitstream-self-link'},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
activatedRoute = {
|
||||||
|
data: observableOf({
|
||||||
|
bitstream: createSuccessfulRemoteDataObject(
|
||||||
|
bitstream
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
router = jasmine.createSpyObj('router', ['navigateByUrl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTestbed() {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, TranslateModule.forRoot()],
|
||||||
|
declarations: [BitstreamDownloadPageComponent],
|
||||||
|
providers: [
|
||||||
|
{provide: ActivatedRoute, useValue: activatedRoute},
|
||||||
|
{provide: Router, useValue: router},
|
||||||
|
{provide: AuthorizationDataService, useValue: authorizationService},
|
||||||
|
{provide: AuthService, useValue: authService},
|
||||||
|
{provide: FileService, useValue: fileService},
|
||||||
|
{provide: HardRedirectService, useValue: hardRedirectService},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
init();
|
||||||
|
initTestbed();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('should init the comp', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bitstream retrieval', () => {
|
||||||
|
describe('when the user is authorized and not logged in', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
init();
|
||||||
|
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false));
|
||||||
|
|
||||||
|
initTestbed();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('should redirect to the content link', () => {
|
||||||
|
expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the user is authorized and logged in', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
init();
|
||||||
|
initTestbed();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('should redirect to an updated content link', () => {
|
||||||
|
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the user is not authorized and logged in', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
init();
|
||||||
|
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
|
||||||
|
initTestbed();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('should navigate to the forbidden route', () => {
|
||||||
|
expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), {skipLocationChange: true});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the user is not authorized and not logged in', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
init();
|
||||||
|
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false));
|
||||||
|
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
|
||||||
|
initTestbed();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('should navigate to the login page', () => {
|
||||||
|
expect(authService.setRedirectUrl).toHaveBeenCalled();
|
||||||
|
expect(router.navigateByUrl).toHaveBeenCalledWith('login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,83 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { filter, map, switchMap, take } from 'rxjs/operators';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { hasValue, isNotEmpty } from '../empty.util';
|
||||||
|
import { getRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators';
|
||||||
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
|
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||||
|
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||||
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
|
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
|
||||||
|
import { FileService } from '../../core/shared/file.service';
|
||||||
|
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||||
|
import { getForbiddenRoute } from '../../app-routing-paths';
|
||||||
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-bitstream-download-page',
|
||||||
|
templateUrl: './bitstream-download-page.component.html'
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Page component for downloading a bitstream
|
||||||
|
*/
|
||||||
|
export class BitstreamDownloadPageComponent implements OnInit {
|
||||||
|
|
||||||
|
bitstream$: Observable<Bitstream>;
|
||||||
|
bitstreamRD$: Observable<RemoteData<Bitstream>>;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
protected router: Router,
|
||||||
|
private authorizationService: AuthorizationDataService,
|
||||||
|
private auth: AuthService,
|
||||||
|
private fileService: FileService,
|
||||||
|
private hardRedirectService: HardRedirectService,
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
|
||||||
|
this.bitstreamRD$ = this.route.data.pipe(
|
||||||
|
map((data) => data.bitstream));
|
||||||
|
|
||||||
|
this.bitstream$ = this.bitstreamRD$.pipe(
|
||||||
|
redirectOn4xx(this.router, this.auth),
|
||||||
|
getRemoteDataPayload()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.bitstream$.pipe(
|
||||||
|
switchMap((bitstream: Bitstream) => {
|
||||||
|
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
|
||||||
|
const isLoggedIn$ = this.auth.isAuthenticated();
|
||||||
|
return observableCombineLatest([isAuthorized$, isLoggedIn$, observableOf(bitstream)]);
|
||||||
|
}),
|
||||||
|
filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)),
|
||||||
|
take(1),
|
||||||
|
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => {
|
||||||
|
if (isAuthorized && isLoggedIn) {
|
||||||
|
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
|
||||||
|
filter((fileLink) => hasValue(fileLink)),
|
||||||
|
take(1),
|
||||||
|
map((fileLink) => {
|
||||||
|
return [isAuthorized, isLoggedIn, bitstream, fileLink];
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return [[isAuthorized, isLoggedIn, bitstream, '']];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => {
|
||||||
|
if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
|
||||||
|
this.hardRedirectService.redirect(fileLink);
|
||||||
|
} else if (isAuthorized && !isLoggedIn) {
|
||||||
|
this.hardRedirectService.redirect(bitstream._links.content.href);
|
||||||
|
} else if (!isAuthorized && isLoggedIn) {
|
||||||
|
this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true});
|
||||||
|
} else if (!isAuthorized && !isLoggedIn) {
|
||||||
|
this.auth.setRedirectUrl(this.router.url);
|
||||||
|
this.router.navigateByUrl('login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
<a *ngIf="!(isAuthenticated$ | async)" [href]="href" [download]="download"><ng-container *ngTemplateOutlet="content"></ng-container></a>
|
<a [href]="bitstreamPath"><ng-container *ngTemplateOutlet="content"></ng-container></a>
|
||||||
<a *ngIf="(isAuthenticated$ | async)" [href]="href" [download]="download" (click)="downloadFile()"><ng-container *ngTemplateOutlet="content"></ng-container></a>
|
|
||||||
|
|
||||||
<ng-template #content>
|
<ng-template #content>
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
|
@@ -3,7 +3,10 @@ import { FileDownloadLinkComponent } from './file-download-link.component';
|
|||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
import { FileService } from '../../core/shared/file.service';
|
import { FileService } from '../../core/shared/file.service';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||||
|
import { getBitstreamModuleRoute } from '../../app-routing-paths';
|
||||||
|
|
||||||
describe('FileDownloadLinkComponent', () => {
|
describe('FileDownloadLinkComponent', () => {
|
||||||
let component: FileDownloadLinkComponent;
|
let component: FileDownloadLinkComponent;
|
||||||
@@ -11,14 +14,16 @@ describe('FileDownloadLinkComponent', () => {
|
|||||||
|
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
let fileService: FileService;
|
let fileService: FileService;
|
||||||
let href: string;
|
let bitstream: Bitstream;
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
authService = jasmine.createSpyObj('authService', {
|
authService = jasmine.createSpyObj('authService', {
|
||||||
isAuthenticated: observableOf(true)
|
isAuthenticated: observableOf(true)
|
||||||
});
|
});
|
||||||
fileService = jasmine.createSpyObj('fileService', ['downloadFile']);
|
fileService = jasmine.createSpyObj('fileService', ['downloadFile']);
|
||||||
href = 'test-download-file-link';
|
bitstream = Object.assign(new Bitstream(), {
|
||||||
|
uuid: 'bitstreamUuid',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
@@ -28,7 +33,6 @@ describe('FileDownloadLinkComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useValue: authService },
|
{ provide: AuthService, useValue: authService },
|
||||||
{ provide: FileService, useValue: fileService },
|
{ provide: FileService, useValue: fileService },
|
||||||
{ provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a } },
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
@@ -37,23 +41,22 @@ describe('FileDownloadLinkComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(FileDownloadLinkComponent);
|
fixture = TestBed.createComponent(FileDownloadLinkComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
component.href = href;
|
component.bitstream = bitstream;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('downloadFile', () => {
|
describe('init', () => {
|
||||||
let result;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
describe('getBitstreamPath', () => {
|
||||||
result = component.downloadFile();
|
it('should set the bitstreamPath based on the input bitstream', () => {
|
||||||
|
expect(component.bitstreamPath).toEqual(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call fileService.downloadFile with the provided href', () => {
|
it('should init the component', () => {
|
||||||
expect(fileService.downloadFile).toHaveBeenCalledWith(href);
|
const link = fixture.debugElement.query(By.css('a')).nativeElement;
|
||||||
|
expect(link.href).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false', () => {
|
|
||||||
expect(result).toEqual(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,8 +1,6 @@
|
|||||||
import { Component, Input, OnInit } from '@angular/core';
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
import { FileService } from '../../core/shared/file.service';
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
import { Observable } from 'rxjs';
|
import { getBitstreamDownloadRoute } from '../../app-routing-paths';
|
||||||
import { AuthService } from '../../core/auth/auth.service';
|
|
||||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-file-download-link',
|
selector: 'ds-file-download-link',
|
||||||
@@ -15,37 +13,18 @@ import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
|||||||
* ensuring the user is authorized to download the file.
|
* ensuring the user is authorized to download the file.
|
||||||
*/
|
*/
|
||||||
export class FileDownloadLinkComponent implements OnInit {
|
export class FileDownloadLinkComponent implements OnInit {
|
||||||
/**
|
|
||||||
* Href to link to
|
|
||||||
*/
|
|
||||||
@Input() href: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional file name for the download
|
* Optional bitstream instead of href and file name
|
||||||
*/
|
*/
|
||||||
@Input() download: string;
|
@Input() bitstream: Bitstream;
|
||||||
|
bitstreamPath: string;
|
||||||
/**
|
|
||||||
* Whether or not the current user is authenticated
|
|
||||||
*/
|
|
||||||
isAuthenticated$: Observable<boolean>;
|
|
||||||
|
|
||||||
constructor(private fileService: FileService,
|
|
||||||
private authService: AuthService,
|
|
||||||
private redirectService: HardRedirectService) {
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.isAuthenticated$ = this.authService.isAuthenticated();
|
this.bitstreamPath = this.getBitstreamPath();
|
||||||
this.href = this.redirectService.rewriteDownloadURL(this.href);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getBitstreamPath() {
|
||||||
* Start a download of the file
|
return getBitstreamDownloadRoute(this.bitstream);
|
||||||
* Return false to ensure the original href is displayed when the user hovers over the link
|
|
||||||
*/
|
|
||||||
downloadFile(): boolean {
|
|
||||||
this.fileService.downloadFile(this.href);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -82,7 +82,7 @@ export class ItemDetailPreviewComponent {
|
|||||||
first())
|
first())
|
||||||
.subscribe((url) => {
|
.subscribe((url) => {
|
||||||
const fileUrl = `${url}/${uuid}/content`;
|
const fileUrl = `${url}/${uuid}/content`;
|
||||||
this.fileService.downloadFile(fileUrl);
|
this.fileService.retrieveFileDownloadLink(fileUrl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -223,6 +223,7 @@ import { SearchObjects } from './search/search-objects.model';
|
|||||||
import { SearchResult } from './search/search-result.model';
|
import { SearchResult } from './search/search-result.model';
|
||||||
import { FacetConfigResponse } from './search/facet-config-response.model';
|
import { FacetConfigResponse } from './search/facet-config-response.model';
|
||||||
import { FacetValues } from './search/facet-values.model';
|
import { FacetValues } from './search/facet-values.model';
|
||||||
|
import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component';
|
||||||
import { GenericItemPageFieldComponent } from '../+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component';
|
import { GenericItemPageFieldComponent } from '../+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component';
|
||||||
import { MetadataRepresentationListComponent } from '../+item-page/simple/metadata-representation-list/metadata-representation-list.component';
|
import { MetadataRepresentationListComponent } from '../+item-page/simple/metadata-representation-list/metadata-representation-list.component';
|
||||||
import { RelatedItemsComponent } from '../+item-page/simple/related-items/related-items-component';
|
import { RelatedItemsComponent } from '../+item-page/simple/related-items/related-items-component';
|
||||||
@@ -432,6 +433,7 @@ const COMPONENTS = [
|
|||||||
EpersonSearchBoxComponent,
|
EpersonSearchBoxComponent,
|
||||||
GroupSearchBoxComponent,
|
GroupSearchBoxComponent,
|
||||||
FileDownloadLinkComponent,
|
FileDownloadLinkComponent,
|
||||||
|
BitstreamDownloadPageComponent,
|
||||||
CollectionDropdownComponent,
|
CollectionDropdownComponent,
|
||||||
ExportMetadataSelectorComponent,
|
ExportMetadataSelectorComponent,
|
||||||
ConfirmationModalComponent,
|
ConfirmationModalComponent,
|
||||||
@@ -510,6 +512,14 @@ const ENTRY_COMPONENTS = [
|
|||||||
ClaimedTaskActionsRejectComponent,
|
ClaimedTaskActionsRejectComponent,
|
||||||
ClaimedTaskActionsReturnToPoolComponent,
|
ClaimedTaskActionsReturnToPoolComponent,
|
||||||
ClaimedTaskActionsEditMetadataComponent,
|
ClaimedTaskActionsEditMetadataComponent,
|
||||||
|
CollectionDropdownComponent,
|
||||||
|
FileDownloadLinkComponent,
|
||||||
|
BitstreamDownloadPageComponent,
|
||||||
|
CurationFormComponent,
|
||||||
|
ExportMetadataSelectorComponent,
|
||||||
|
ConfirmationModalComponent,
|
||||||
|
VocabularyTreeviewComponent,
|
||||||
|
SidebarSearchListElementComponent,
|
||||||
PublicationSidebarSearchListElementComponent,
|
PublicationSidebarSearchListElementComponent,
|
||||||
CollectionSidebarSearchListElementComponent,
|
CollectionSidebarSearchListElementComponent,
|
||||||
CommunitySidebarSearchListElementComponent,
|
CommunitySidebarSearchListElementComponent,
|
||||||
|
@@ -41,7 +41,7 @@ import { FormBuilderService } from '../../../../shared/form/builder/form-builder
|
|||||||
|
|
||||||
function getMockFileService(): FileService {
|
function getMockFileService(): FileService {
|
||||||
return jasmine.createSpyObj('FileService', {
|
return jasmine.createSpyObj('FileService', {
|
||||||
downloadFile: jasmine.createSpy('downloadFile'),
|
retrieveFileDownloadLink: jasmine.createSpy('retrieveFileDownloadLink'),
|
||||||
getFileNameFromResponseContentDisposition: jasmine.createSpy('getFileNameFromResponseContentDisposition')
|
getFileNameFromResponseContentDisposition: jasmine.createSpy('getFileNameFromResponseContentDisposition')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ describe('SubmissionSectionUploadFileComponent test suite', () => {
|
|||||||
|
|
||||||
tick();
|
tick();
|
||||||
|
|
||||||
expect(fileService.downloadFile).toHaveBeenCalled();
|
expect(fileService.retrieveFileDownloadLink).toHaveBeenCalled();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should save Bitstream File data properly when form is valid', fakeAsync(() => {
|
it('should save Bitstream File data properly when form is valid', fakeAsync(() => {
|
||||||
|
@@ -224,7 +224,7 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
|
|||||||
first())
|
first())
|
||||||
.subscribe((url) => {
|
.subscribe((url) => {
|
||||||
const fileUrl = `${url}/${this.fileData.uuid}/content`;
|
const fileUrl = `${url}/${this.fileData.uuid}/content`;
|
||||||
this.fileService.downloadFile(fileUrl);
|
this.fileService.retrieveFileDownloadLink(fileUrl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -526,6 +526,10 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"bitstream.download.page": "Now downloading {{bitstream}}..." ,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"bitstream.edit.bitstream": "Bitstream: ",
|
"bitstream.edit.bitstream": "Bitstream: ",
|
||||||
|
|
||||||
"bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"<i>Main article</i>\" or \"<i>Experiment data readings</i>\".",
|
"bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"<i>Main article</i>\" or \"<i>Experiment data readings</i>\".",
|
||||||
|
@@ -31,6 +31,8 @@ import {
|
|||||||
import { LocaleService } from '../../app/core/locale/locale.service';
|
import { LocaleService } from '../../app/core/locale/locale.service';
|
||||||
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
|
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
|
||||||
import { RouterModule, NoPreloading } from '@angular/router';
|
import { RouterModule, NoPreloading } from '@angular/router';
|
||||||
|
import { AuthRequestService } from '../../app/core/auth/auth-request.service';
|
||||||
|
import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service';
|
||||||
|
|
||||||
export const REQ_KEY = makeStateKey<string>('req');
|
export const REQ_KEY = makeStateKey<string>('req');
|
||||||
|
|
||||||
@@ -105,6 +107,10 @@ export function getRequest(transferState: TransferState): any {
|
|||||||
provide: GoogleAnalyticsService,
|
provide: GoogleAnalyticsService,
|
||||||
useClass: GoogleAnalyticsService,
|
useClass: GoogleAnalyticsService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: AuthRequestService,
|
||||||
|
useClass: BrowserAuthRequestService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: LocationToken,
|
provide: LocationToken,
|
||||||
useFactory: locationProvider,
|
useFactory: locationProvider,
|
||||||
|
@@ -31,6 +31,8 @@ import { ServerHardRedirectService } from '../../app/core/services/server-hard-r
|
|||||||
import { Angulartics2 } from 'angulartics2';
|
import { Angulartics2 } from 'angulartics2';
|
||||||
import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock';
|
import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { AuthRequestService } from '../../app/core/auth/auth-request.service';
|
||||||
|
import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service';
|
||||||
|
|
||||||
export function createTranslateLoader() {
|
export function createTranslateLoader() {
|
||||||
return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5');
|
return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5');
|
||||||
@@ -82,6 +84,10 @@ export function createTranslateLoader() {
|
|||||||
provide: SubmissionService,
|
provide: SubmissionService,
|
||||||
useClass: ServerSubmissionService
|
useClass: ServerSubmissionService
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: AuthRequestService,
|
||||||
|
useClass: ServerAuthRequestService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: LocaleService,
|
provide: LocaleService,
|
||||||
useClass: ServerLocaleService
|
useClass: ServerLocaleService
|
||||||
|
Reference in New Issue
Block a user