Skip to content

Commit 3c6fe5d

Browse files
authored
Merge pull request conventional-changelog#110 from verdaccio/issue-token
fix: download protected tarballs
2 parents a25fc6e + f837408 commit 3c6fe5d

11 files changed

+517
-236
lines changed

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@
9595
"webpack": "4.20.2",
9696
"webpack-bundle-analyzer": "3.3.2",
9797
"webpack-bundle-size-analyzer": "3.0.0",
98-
"webpack-cli": "3.2.3",
99-
"webpack-dev-server": "3.2.1",
98+
"webpack-cli": "3.3.6",
99+
"webpack-dev-server": "3.7.2",
100100
"webpack-merge": "4.2.1",
101101
"whatwg-fetch": "3.0.0",
102102
"xss": "1.0.6"

src/components/ActionBar/ActionBar.test.tsx

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { shallow } from 'enzyme';
2+
import { mount, shallow } from 'enzyme';
33

44
describe('<ActionBar /> component', () => {
55
beforeEach(() => {
@@ -43,6 +43,31 @@ describe('<ActionBar /> component', () => {
4343

4444
const ActionBar = require('./ActionBar').default;
4545
const wrapper = shallow(<ActionBar />);
46+
// FIXME: this only renders the DetailContextConsumer, thus
47+
// the wrapper will be always empty
4648
expect(wrapper.html()).toEqual('');
4749
});
50+
51+
test('when there is a button to download a tarball', () => {
52+
const packageMeta = {
53+
latest: {
54+
dist: {
55+
tarball: 'http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz',
56+
},
57+
},
58+
};
59+
60+
jest.doMock('../../pages/version/Version', () => ({
61+
DetailContextConsumer: component => {
62+
return component.children({ packageMeta });
63+
},
64+
}));
65+
66+
const ActionBar = require('./ActionBar').default;
67+
const wrapper = mount(<ActionBar />);
68+
expect(wrapper.html()).toMatchSnapshot();
69+
70+
const button = wrapper.find('button');
71+
expect(button).toHaveLength(1);
72+
});
4873
});

src/components/ActionBar/ActionBar.tsx

+46-9
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,25 @@ import Tooltip from '@material-ui/core/Tooltip';
88

99
import { DetailContextConsumer, VersionPageConsumerProps } from '../../pages/version/Version';
1010
import { Fab, ActionListItem } from './styles';
11-
import { isURL } from '../../utils/url';
11+
import { isURL, extractFileName, downloadFile } from '../../utils/url';
12+
import api from '../../utils/api';
13+
14+
export interface Action {
15+
icon: string;
16+
title: string;
17+
handler?: Function;
18+
}
19+
20+
export async function downloadHandler(link: string): Promise<void> {
21+
const fileStream: Blob = await api.request(link, 'GET', {
22+
headers: {
23+
['accept']: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3',
24+
},
25+
credentials: 'include',
26+
});
27+
const fileName = extractFileName(link);
28+
downloadFile(fileStream, fileName);
29+
}
1230

1331
const ACTIONS = {
1432
homepage: {
@@ -22,6 +40,7 @@ const ACTIONS = {
2240
tarball: {
2341
icon: <DownloadIcon />,
2442
title: 'Download tarball',
43+
handler: downloadHandler,
2544
},
2645
};
2746

@@ -54,16 +73,34 @@ class ActionBar extends Component {
5473
tarball,
5574
};
5675

57-
const renderList = Object.keys(actionsMap).reduce((component, value, key) => {
76+
const renderList = Object.keys(actionsMap).reduce((component: React.ReactElement[], value, key) => {
5877
const link = actionsMap[value];
5978
if (link && isURL(link)) {
60-
const fab = <Fab size={'small'}>{ACTIONS[value]['icon']}</Fab>;
61-
component.push(
62-
// @ts-ignore
63-
<Tooltip key={key} title={ACTIONS[value]['title']}>
64-
<>{this.renderIconsWithLink(link, fab)}</>
65-
</Tooltip>
66-
);
79+
const actionItem: Action = ACTIONS[value];
80+
if (actionItem.handler) {
81+
const fab = (
82+
<Tooltip key={key} title={actionItem['title']}>
83+
<Fab
84+
/* eslint-disable react/jsx-no-bind */
85+
onClick={() => {
86+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
87+
actionItem.handler!(link);
88+
}}
89+
size={'small'}>
90+
{actionItem['icon']}
91+
</Fab>
92+
</Tooltip>
93+
);
94+
component.push(fab);
95+
} else {
96+
const fab = <Fab size={'small'}>{actionItem['icon']}</Fab>;
97+
component.push(
98+
// @ts-ignore
99+
<Tooltip key={key} title={actionItem['title']}>
100+
<>{this.renderIconsWithLink(link, fab)}</>
101+
</Tooltip>
102+
);
103+
}
67104
}
68105
return component;
69106
}, []);
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`<ActionBar /> component should render the component in default state 1`] = `"<ul class=\\"MuiList-root-1 MuiList-padding-2\\"><li class=\\"MuiListItem-root-5 MuiListItem-default-8 MuiListItem-gutters-13 MuiListItem-alignItemsFlexStart-10 css-9q3x3c eux6shq0\\"><a href=\\"https://verdaccio.tld\\" target=\\"_blank\\"><button class=\\"MuiButtonBase-root-35 MuiFab-root-25 MuiFab-sizeSmall-33 css-96oxa0 eux6shq1\\" tabindex=\\"0\\" type=\\"button\\"><span class=\\"MuiFab-label-26\\"><svg class=\\"MuiSvgIcon-root-38\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path d=\\"M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z\\"></path><path fill=\\"none\\" d=\\"M0 0h24v24H0z\\"></path></svg></span></button></a><a href=\\"https://verdaccio.tld/bugs\\" target=\\"_blank\\"><button class=\\"MuiButtonBase-root-35 MuiFab-root-25 MuiFab-sizeSmall-33 css-96oxa0 eux6shq1\\" tabindex=\\"0\\" type=\\"button\\"><span class=\\"MuiFab-label-26\\"><svg class=\\"MuiSvgIcon-root-38\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path fill=\\"none\\" d=\\"M0 0h24v24H0z\\"></path><path d=\\"M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z\\"></path></svg></span></button></a><a href=\\"https://verdaccio.tld/download\\" target=\\"_blank\\"><button class=\\"MuiButtonBase-root-35 MuiFab-root-25 MuiFab-sizeSmall-33 css-96oxa0 eux6shq1\\" tabindex=\\"0\\" type=\\"button\\"><span class=\\"MuiFab-label-26\\"><svg class=\\"MuiSvgIcon-root-38\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path fill=\\"none\\" d=\\"M0 0h24v24H0z\\"></path><path d=\\"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z\\"></path></svg></span></button></a></li></ul>"`;
3+
exports[`<ActionBar /> component should render the component in default state 1`] = `"<ul class=\\"MuiList-root-1 MuiList-padding-2\\"><li class=\\"MuiListItem-root-5 MuiListItem-default-8 MuiListItem-gutters-13 MuiListItem-alignItemsFlexStart-10 css-9q3x3c eux6shq0\\"><a href=\\"https://verdaccio.tld\\" target=\\"_blank\\"><button class=\\"MuiButtonBase-root-35 MuiFab-root-25 MuiFab-sizeSmall-33 css-96oxa0 eux6shq1\\" tabindex=\\"0\\" type=\\"button\\"><span class=\\"MuiFab-label-26\\"><svg class=\\"MuiSvgIcon-root-38\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path d=\\"M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z\\"></path><path fill=\\"none\\" d=\\"M0 0h24v24H0z\\"></path></svg></span></button></a><a href=\\"https://verdaccio.tld/bugs\\" target=\\"_blank\\"><button class=\\"MuiButtonBase-root-35 MuiFab-root-25 MuiFab-sizeSmall-33 css-96oxa0 eux6shq1\\" tabindex=\\"0\\" type=\\"button\\"><span class=\\"MuiFab-label-26\\"><svg class=\\"MuiSvgIcon-root-38\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path fill=\\"none\\" d=\\"M0 0h24v24H0z\\"></path><path d=\\"M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z\\"></path></svg></span></button></a><button class=\\"MuiButtonBase-root-35 MuiFab-root-25 MuiFab-sizeSmall-33 css-96oxa0 eux6shq1\\" tabindex=\\"0\\" type=\\"button\\" title=\\"Download tarball\\"><span class=\\"MuiFab-label-26\\"><svg class=\\"MuiSvgIcon-root-38\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path fill=\\"none\\" d=\\"M0 0h24v24H0z\\"></path><path d=\\"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z\\"></path></svg></span></button></li></ul>"`;
4+
5+
exports[`<ActionBar /> component when there is a button to download a tarball 1`] = `"<ul class=\\"MuiList-root-47 MuiList-padding-48\\"><li class=\\"MuiListItem-root-51 MuiListItem-default-54 MuiListItem-gutters-59 MuiListItem-alignItemsFlexStart-56 css-9q3x3c eux6shq0\\"><button class=\\"MuiButtonBase-root-81 MuiFab-root-71 MuiFab-sizeSmall-79 css-96oxa0 eux6shq1\\" tabindex=\\"0\\" type=\\"button\\" title=\\"Download tarball\\"><span class=\\"MuiFab-label-72\\"><svg class=\\"MuiSvgIcon-root-84\\" focusable=\\"false\\" viewBox=\\"0 0 24 24\\" aria-hidden=\\"true\\" role=\\"presentation\\"><path fill=\\"none\\" d=\\"M0 0h24v24H0z\\"></path><path d=\\"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z\\"></path></svg></span><span class=\\"MuiTouchRipple-root-93\\"></span></button></li></ul>"`;

src/utils/api.test.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/* eslint-disable @typescript-eslint/no-object-literal-type-assertion */
2+
3+
import { handleResponseType } from '../../src/utils/api';
4+
5+
describe('api', () => {
6+
// no the best mock, but I'd look for a mock library to work with fetch in the future
7+
// @ts-ignore
8+
const headers: Headers = {
9+
// @ts-ignore
10+
get: () => [],
11+
};
12+
13+
describe('handleResponseType', () => {
14+
test('should test tgz scenario', async () => {
15+
const blob = new Blob(['foo']);
16+
const blobPromise = Promise.resolve<Blob>(blob);
17+
const response: Response = {
18+
url: 'http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz',
19+
blob: () => blobPromise,
20+
ok: true,
21+
headers,
22+
} as Response;
23+
const handled = await handleResponseType(response);
24+
25+
expect(handled).toEqual([true, blob]);
26+
});
27+
});
28+
});

src/utils/api.ts

+15-10
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import '../../types';
66
* @param {object} response
77
* @returns {promise}
88
*/
9-
function handleResponseType(response: Response): Promise<[boolean, Blob | string]> | Promise<void> {
9+
export function handleResponseType(response: Response): Promise<[boolean, Blob | string]> | Promise<void> {
1010
if (response.headers) {
1111
const contentType = response.headers.get('Content-Type') as string;
1212
if (contentType.includes('application/pdf')) {
@@ -19,22 +19,27 @@ function handleResponseType(response: Response): Promise<[boolean, Blob | string
1919
if (contentType.includes('text/')) {
2020
return Promise.all([response.ok, response.text()]);
2121
}
22+
23+
// unfortunatelly on download files there is no header available
24+
if (response.url && response.url.endsWith('.tgz') !== null) {
25+
return Promise.all([response.ok, response.blob()]);
26+
}
2227
}
2328

2429
return Promise.resolve();
2530
}
2631

2732
class API {
28-
public request<T>(url: string, method = 'GET', options?: RequestInit): Promise<T> {
33+
public request<T>(url: string, method = 'GET', options: RequestInit = { headers: {} }): Promise<T> {
2934
if (!window.VERDACCIO_API_URL) {
3035
throw new Error('VERDACCIO_API_URL is not defined!');
3136
}
3237

3338
const token = storage.getItem('token');
34-
const headers = new Headers(options && options.headers);
35-
if (token && options && options.headers) {
36-
headers.set('Authorization', `Bearer ${token}`);
37-
options.headers = Object.assign(options.headers, headers);
39+
if (token && options.headers && typeof options.headers['Authorization'] === 'undefined') {
40+
options.headers = Object.assign({}, options.headers, {
41+
['Authorization']: `Bearer ${token}`,
42+
});
3843
}
3944

4045
if (!['http://', 'https://', '//'].some(prefix => url.startsWith(prefix))) {
@@ -50,11 +55,11 @@ class API {
5055
})
5156
// @ts-ignore
5257
.then(handleResponseType)
53-
.then(([responseOk, body]) => {
54-
if (responseOk) {
55-
resolve(body);
58+
.then(response => {
59+
if (response[0]) {
60+
resolve(response[1]);
5661
} else {
57-
reject(body);
62+
reject(new Error('something went wrong'));
5863
}
5964
})
6065
.catch(error => {

src/utils/url.test.ts

+29-20
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
1-
import { isURL, isEmail, getRegistryURL } from './url';
1+
import { isURL, isEmail, getRegistryURL, extractFileName } from './url';
22

3-
describe('url', () => {
4-
test('isURL() - should return true for localhost', () => {
5-
expect(isURL('http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz')).toBeTruthy();
6-
});
3+
describe('utils', () => {
4+
describe('url', () => {
5+
test('isURL() - should return true for localhost', () => {
6+
expect(isURL('http://localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz')).toBeTruthy();
7+
});
78

8-
test('isURL() - should return false when protocol is missing', () => {
9-
expect(isURL('localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz')).toBeFalsy();
10-
});
9+
test('isURL() - should return false when protocol is missing', () => {
10+
expect(isURL('localhost:8080/bootstrap/-/bootstrap-4.3.1.tgz')).toBeFalsy();
11+
});
1112

12-
test('isEmail() - should return true if valid', () => {
13-
expect(isEmail('[email protected]')).toBeTruthy();
14-
});
15-
test('isEmail() - should return false if invalid', () => {
16-
expect(isEmail('')).toBeFalsy();
17-
});
13+
test('isEmail() - should return true if valid', () => {
14+
expect(isEmail('[email protected]')).toBeTruthy();
15+
});
16+
test('isEmail() - should return false if invalid', () => {
17+
expect(isEmail('')).toBeFalsy();
18+
});
19+
20+
test('getRegistryURL() - should keep slash if location is a sub directory', () => {
21+
history.pushState({}, 'page title', '/-/web/detail');
22+
expect(getRegistryURL()).toBe('http://localhost/-/web/detail');
23+
history.pushState({}, 'page title', '/');
24+
});
1825

19-
test('getRegistryURL() - should keep slash if location is a sub directory', () => {
20-
history.pushState({}, 'page title', '/-/web/detail');
21-
expect(getRegistryURL()).toBe('http://localhost/-/web/detail');
22-
history.pushState({}, 'page title', '/');
26+
test('getRegistryURL() - should not add slash if location is not a sub directory', () => {
27+
expect(getRegistryURL()).toBe('http://localhost');
28+
});
2329
});
24-
test('getRegistryURL() - should not add slash if location is not a sub directory', () => {
25-
expect(getRegistryURL()).toBe('http://localhost');
30+
31+
describe('extractFileName', () => {
32+
test('should return the file name', () => {
33+
expect(extractFileName('http://localhost:4872/juan_test_webpack1/-/test-10.0.0.tgz')).toBe('test-10.0.0.tgz');
34+
});
2635
});
2736
});

src/utils/url.ts

+17
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,20 @@ export function getRegistryURL(): string {
1818
// Don't add slash if it's not a sub directory
1919
return `${location.origin}${location.pathname === '/' ? '' : location.pathname}`;
2020
}
21+
22+
export function extractFileName(url: string): string {
23+
return url.substring(url.lastIndexOf('/') + 1);
24+
}
25+
26+
export function downloadFile(fileStream: Blob, fileName: string): void {
27+
const file = new File([fileStream], fileName, { type: 'application/octet-stream', lastModified: Date.now() });
28+
const objectURL = URL.createObjectURL(file);
29+
const fileLink = document.createElement('a');
30+
fileLink.href = objectURL;
31+
fileLink.download = fileName;
32+
fileLink.click();
33+
// firefox requires remove the object url
34+
setTimeout(() => {
35+
URL.revokeObjectURL(objectURL);
36+
}, 150);
37+
}

tools/dev.server.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ new WebpackDevServer(compiler, {
3232
},
3333
proxy: [
3434
{
35-
context: ['/-/verdaccio/logo', '/-/verdaccio/packages', '/-/static/logo.png'],
35+
context: ['/-/verdaccio/**', '**/*.tgz'],
3636
target: 'http://localhost:8080',
3737
},
3838
],

tools/webpack.dev.config.babel.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default {
4141
scope: '',
4242
logo: 'https://verdaccio.org/img/logo/symbol/svg/verdaccio-tiny.svg',
4343
filename: 'index.html',
44-
verdaccioURL: '//localhost:8080',
44+
verdaccioURL: '//localhost:4872',
4545
template: `${env.SRC_ROOT}/template/index.html`,
4646
debug: true,
4747
inject: true,

0 commit comments

Comments
 (0)