Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(downloads): do not download into browser memory #319

Merged
merged 8 commits into from
Nov 17, 2021
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1 +1 @@
CRYOSTAT_AUTHORITY=http://localhost:8181
CRYOSTAT_AUTHORITY=http://0.0.0.0:8181
8 changes: 4 additions & 4 deletions src/app/Recordings/RecordingActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const RecordingActions: React.FunctionComponent<RecordingActionsProps> =
context.api.downloadRecording(props.recording);
}, [context.api, props.recording]);

const handleDownloadReport = React.useCallback(() => {
const handleViewReport = React.useCallback(() => {
context.api.downloadReport(props.recording);
}, [context.api, props.recording]);

Expand All @@ -93,8 +93,8 @@ export const RecordingActions: React.FunctionComponent<RecordingActionsProps> =
onClick: handleDownloadRecording
},
{
title: "Download Report",
onClick: handleDownloadReport
title: "View Report ...",
onClick: handleViewReport
}
];
if (grafanaEnabled) {
Expand All @@ -106,7 +106,7 @@ export const RecordingActions: React.FunctionComponent<RecordingActionsProps> =
);
}
return actionItems;
}, [handleDownloadRecording, handleDownloadReport, grafanaEnabled, grafanaUpload]);
}, [handleDownloadRecording, handleViewReport, grafanaEnabled, grafanaUpload]);

return (
<Td
Expand Down
128 changes: 72 additions & 56 deletions src/app/Shared/Services/Api.service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { Target, TargetService } from './Target.service';
import { Notifications } from '@app/Notifications/Notifications';
import { AuthMethod, LoginService, SessionState } from './Login.service';

type ApiVersion = "v1" | "v2";
type ApiVersion = 'v1' | 'v2' | 'v2.1' | 'beta';

export class HttpError extends Error {
readonly httpResponse: Response;
Expand Down Expand Up @@ -333,65 +333,63 @@ export class ApiService {
}

downloadReport(recording: SavedRecording): void {
this.login.getHeaders().subscribe(headers => {
const req = () =>
fromFetch(recording.reportUrl, {
credentials: 'include',
mode: 'cors',
headers,
})
.pipe(
map(resp => {
if (resp.ok) return resp;
throw new HttpError(resp);
}),
catchError(err => this.handleError<Response>(err, req)),
concatMap(resp => resp.blob()),
);
req().subscribe(resp =>
const body = new window.FormData();
body.append('resource', recording.reportUrl.replace('/api/v1', '/api/beta'));
this.sendRequest('beta', 'auth/token', {
method: 'POST',
body,
})
.pipe(
concatMap(resp => resp.json()),
map((response: AssetJwtResponse) => response.data.result.resourceUrl)
).subscribe(resourceUrl => {
this.downloadFile(
resourceUrl,
`${recording.name}.report.html`,
resp,
'text/html')
)
});
false
);
});
}

downloadRecording(recording: SavedRecording): void {
this.login.getHeaders().subscribe(headers => {
const req = () => fromFetch(recording.downloadUrl, {
credentials: 'include',
mode: 'cors',
headers,
})
.pipe(
map(resp => {
if (resp.ok) return resp;
throw new HttpError(resp);
}),
catchError(err => this.handleError<Response>(err, req)),
concatMap(resp => resp.blob()),
);
req().subscribe(resp =>
const body = new window.FormData();
body.append('resource', recording.downloadUrl.replace('/api/v1', '/api/beta'));
this.sendRequest('beta', 'auth/token', {
method: 'POST',
body,
})
.pipe(
concatMap(resp => resp.json()),
map((response: AssetJwtResponse) => response.data.result.resourceUrl)
).subscribe(resourceUrl => {
this.downloadFile(
recording.name + (recording.name.endsWith('.jfr') ? '' : '.jfr'),
resp,
'application/octet-stream')
)
});
resourceUrl,
recording.name + (recording.name.endsWith('.jfr') ? '' : '.jfr')
);
});
}

downloadTemplate(template: EventTemplate): void {
this.target.target().pipe(concatMap(target => {
const url = `targets/${encodeURIComponent(target.connectUrl)}/templates/${encodeURIComponent(template.name)}/type/${encodeURIComponent(template.type)}`;
return this.sendRequest('v1', url)
.pipe(concatMap(resp => resp.text()));
}))
.subscribe(resp => {
this.downloadFile(
`${template.name}.jfc`,
resp,
'application/jfc+xml')
this.target.target()
.pipe(first(), map(target =>
`${this.login.authority}/api/beta/targets/${encodeURIComponent(target.connectUrl)}/templates/${encodeURIComponent(template.name)}/type/${encodeURIComponent(template.type)}`
))
.subscribe(resource => {
const body = new window.FormData();
body.append('resource', resource);
this.sendRequest('beta', 'auth/token', {
method: 'POST',
body,
})
.pipe(
concatMap(resp => resp.json()),
map((response: AssetJwtResponse) => response.data.result.resourceUrl),
).subscribe(resourceUrl => {
this.downloadFile(
resourceUrl,
`${template.name}.jfc`
);
});
});
}

Expand Down Expand Up @@ -455,14 +453,16 @@ export class ApiService {
return req();
}

private downloadFile(filename: string, data: BlobPart, type: string): void {
const blob = new window.Blob([ data ], { type } );
const url = window.URL.createObjectURL(blob);
private downloadFile(url: string, filename: string, download = true): void {
const anchor = document.createElement('a');
anchor.download = filename;
anchor.setAttribute('style', 'display: none; visibility: hidden;');
anchor.target = '_blank;'
if (download) {
anchor.download = filename;
}
anchor.href = url;
anchor.click();
window.setTimeout(() => window.URL.revokeObjectURL(url));
anchor.remove();
}

private handleError<T>(error: Error, retry: () => Observable<T>): ObservableInput<T> {
Expand Down Expand Up @@ -490,6 +490,22 @@ export class ApiService {

}

export interface ApiV2Response {
meta: {
status: string;
type: string;
};
data: Object;
}

interface AssetJwtResponse extends ApiV2Response {
data: {
result: {
resourceUrl: string;
}
}
}

export interface SavedRecording {
name: string;
downloadUrl: string;
Expand Down
19 changes: 6 additions & 13 deletions src/app/Shared/Services/Login.service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Base64 } from 'js-base64';
import { combineLatest, Observable, ObservableInput, of, ReplaySubject } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
import { catchError, concatMap, first, map, tap } from 'rxjs/operators';
import { ApiV2Response } from './Api.service';
import { TargetService } from './Target.service';

export enum SessionState {
Expand Down Expand Up @@ -124,14 +125,16 @@ export class LoginService {
const headers = new window.Headers();
if (!!token && !!method) {
headers.set('Authorization', `${method} ${token}`)
} else if (method === AuthMethod.NONE) {
headers.set('Authorization', AuthMethod.NONE);
}
return headers;
}

getHeaders(): Observable<Headers> {
const authorization = combineLatest([this.getToken(), this.getAuthMethod()])
.pipe(
map((parts: [string, string]) => this.getAuthHeaders(parts[0], parts[1])),
map((parts: [string, AuthMethod]) => this.getAuthHeaders(parts[0], parts[1])),
first(),
);
return combineLatest([authorization, this.target.target()])
Expand All @@ -155,7 +158,7 @@ export class LoginService {
return this.token.asObservable();
}

getAuthMethod(): Observable<string> {
getAuthMethod(): Observable<AuthMethod> {
return this.authMethod.asObservable();
}

Expand Down Expand Up @@ -235,17 +238,7 @@ export class LoginService {

}

interface ApiResponse {
meta: Meta;
data: Object;
}

interface Meta {
status: string;
type: string;
}

interface AuthV2Response extends ApiResponse {
interface AuthV2Response extends ApiV2Response {
data: {
result: {
username: string;
Expand Down