Skip to content

Commit 9c1d7d3

Browse files
authored
Merge branch 'download-directory:main' into main
2 parents 2efa378 + 3f7b711 commit 9c1d7d3

18 files changed

+8487
-22166
lines changed

.parcelrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"extends": "@parcel/config-default",
3-
"resolvers": ["parcel-resolver-ignore", "..."]
3+
"resolvers": ["parcel-resolver-ignore", "..."],
4+
"reporters": ["...", "parcel-reporter-static-files-copy"]
45
}

authenticated-fetch.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export default async function authenticatedFetch(
2+
url: string,
3+
{signal, method}: {signal?: AbortSignal; method?: 'HEAD'} = {},
4+
): Promise<Response> {
5+
const token = globalThis.localStorage?.getItem('token');
6+
7+
const response = await fetch(url, {
8+
method,
9+
signal,
10+
...(token
11+
? {
12+
headers: {
13+
// eslint-disable-next-line @typescript-eslint/naming-convention
14+
Authorization: `Bearer ${token}`,
15+
},
16+
}
17+
: {}),
18+
});
19+
20+
switch (response.status) {
21+
case 401: {
22+
throw new Error('Invalid token');
23+
}
24+
25+
case 403:
26+
case 429: {
27+
// See https://developer.github.com/v3/#rate-limiting
28+
if (response.headers.get('X-RateLimit-Remaining') === '0') {
29+
throw new Error('Rate limit exceeded');
30+
}
31+
32+
break;
33+
}
34+
35+
default:
36+
}
37+
38+
return response;
39+
}

download.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {test, expect} from 'vitest';
2+
import {type TreeResponseObject} from 'list-github-dir-content';
3+
import {downloadFile} from './download.js';
4+
5+
test('downloadFile', async () => {
6+
await expect(downloadFile({
7+
user: 'refined-github',
8+
repository: 'sandbox',
9+
reference: 'github-moji',
10+
file: {
11+
path: '.github/workflows/wait-for-checks.yml',
12+
} as unknown as TreeResponseObject,
13+
signal: new AbortController().signal,
14+
isPrivate: false,
15+
})).resolves.toBeInstanceOf(Blob);
16+
});
17+
18+
test.skip('downloadFile private', async () => {
19+
// It will eventually have to immediately skip if the token is missing
20+
await expect(downloadFile({
21+
user: 'refined-github',
22+
repository: 'private',
23+
reference: 'github-moji',
24+
file: {
25+
path: '.github/workflows/wait-for-checks.yml',
26+
} as unknown as TreeResponseObject,
27+
signal: new AbortController().signal,
28+
isPrivate: true,
29+
})).resolves.toBeInstanceOf(Blob);
30+
});

download.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {type ContentsReponseObject, type TreeResponseObject} from 'list-github-dir-content';
2+
import pRetry, {type FailedAttemptError} from 'p-retry';
3+
import authenticatedFetch from './authenticated-fetch.js';
4+
5+
function escapeFilepath(path: string) {
6+
return path.replaceAll('#', '%23');
7+
}
8+
9+
async function maybeResponseLfs(response: Response): Promise<boolean> {
10+
const length = Number(response.headers.get('content-length'));
11+
if (length > 128 && length < 140) {
12+
const contents = await response.clone().text();
13+
return contents.startsWith('version https://git-lfs.github.com/spec/v1');
14+
}
15+
16+
return false;
17+
}
18+
19+
type FileRequest = {
20+
user: string;
21+
repository: string;
22+
reference: string;
23+
file: TreeResponseObject | ContentsReponseObject;
24+
signal: AbortSignal;
25+
};
26+
27+
async function fetchPublicFile({
28+
user,
29+
repository,
30+
reference,
31+
file,
32+
signal,
33+
}: FileRequest) {
34+
const response = await authenticatedFetch(
35+
`https://raw.githubusercontent.com/${user}/${repository}/${reference}/${escapeFilepath(file.path)}`,
36+
{signal},
37+
);
38+
39+
if (!response.ok) {
40+
throw new Error(`HTTP ${response.statusText} for ${file.path}`);
41+
}
42+
43+
const lfsCompatibleResponse = (await maybeResponseLfs(response))
44+
? await authenticatedFetch(
45+
`https://media.githubusercontent.com/media/${user}/${repository}/${reference}/${escapeFilepath(file.path)}`,
46+
{signal},
47+
)
48+
: response;
49+
50+
if (!response.ok) {
51+
throw new Error(`HTTP ${response.statusText} for ${file.path}`);
52+
}
53+
54+
return lfsCompatibleResponse.blob();
55+
}
56+
57+
async function fetchPrivateFile({
58+
file,
59+
signal,
60+
}: FileRequest) {
61+
const response = await authenticatedFetch(file.url, {signal});
62+
63+
if (!response.ok) {
64+
throw new Error(`HTTP ${response.statusText} for ${file.path}`);
65+
}
66+
67+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
68+
const {content} = await response.json();
69+
const decoder = await fetch(
70+
`data:application/octet-stream;base64,${content}`,
71+
);
72+
return decoder.blob();
73+
}
74+
75+
export async function downloadFile({
76+
user,
77+
repository,
78+
reference,
79+
file,
80+
isPrivate,
81+
signal,
82+
}: {
83+
user: string;
84+
repository: string;
85+
reference: string;
86+
isPrivate: boolean;
87+
file: TreeResponseObject | ContentsReponseObject;
88+
signal: AbortSignal;
89+
}) {
90+
const fileRequest = {
91+
user, repository, reference, file, signal,
92+
};
93+
const localDownload = async () =>
94+
isPrivate
95+
? fetchPrivateFile(fileRequest)
96+
: fetchPublicFile(fileRequest);
97+
const onFailedAttempt = (error: FailedAttemptError) => {
98+
console.error(
99+
`Error downloading ${file.path}. Attempt ${error.attemptNumber}. ${error.retriesLeft} retries left.`,
100+
);
101+
};
102+
103+
return pRetry(localDownload, {onFailedAttempt});
104+
}

favicon.ico

14.7 KB
Binary file not shown.

index.css

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
box-sizing: border-box;
1111
}
1212

13+
::selection {
14+
background: #fff3;
15+
}
16+
1317
html {
1418
overflow-y: auto;
1519
min-width: 320px;
@@ -205,6 +209,7 @@ input[type="url"],
205209
text-align: center;
206210
max-width: 100%;
207211
}
212+
208213
a,
209214
label {
210215
cursor: pointer;
@@ -219,12 +224,12 @@ label {
219224
display: none;
220225
}
221226
#token:valid {
222-
color: var(--github-green);
227+
color: var(--green);
223228
border: 0.1em solid;
224229
}
225230
#token:invalid {
226-
color: var(--github-red);
227-
border: 0.1em solid;
231+
outline: solid 0.3em #ffee00;
232+
border-color: transparent;
228233
}
229234
[name="url"] {
230235
font-size: 0.6em;
@@ -240,6 +245,13 @@ pre {
240245
max-height: 100vh;
241246
overflow: auto;
242247
}
243-
pre:first-line {
248+
pre div:first-child {
244249
font-size: 20px;
250+
white-space: pre-wrap;
251+
}
252+
253+
header {
254+
background-color: rgba(100%, 100%, 100%, 13%);
255+
padding: 3em;
256+
text-align: center;
245257
}

index.html

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@
33
<meta charset="UTF-8">
44
<title>Download GitHub directory</title>
55
<link rel="stylesheet" href="index.css">
6-
<script src="index.js" type="module"></script>
6+
<script src="index.ts" type="module"></script>
77
<link rel="preconnect" href="https://fonts.gstatic.com"/>
8+
<link rel="canonical" href="https://download-directory.github.io/"/>
89
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Slab:wght@300&display=swap" as="style"/>
910
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto&family=Roboto+Slab:wght@300&display=swap" media="print" onload="this.media='all'"/>
1011
<meta name="viewport" content="width=device-width,initial-scale=1">
12+
<meta name="google-site-verification" content="0AMR3lQZu5bzGS6wcqw1b9udtj7QCqRuDB-vQ52zylU"/>
13+
<link rel="icon" href="favicon.ico"/>
1114
</head>
15+
<header>
16+
No content is hosted on this website. This is a tool to download files from a user-provided GitHub repository URL.
17+
</header>
1218
<main class="text-align-center">
1319
<h1><a href="/">download-directory • github • io</a></h1>
1420
<form>
15-
<input autofocus name="url" type="url" size="38" placeholder="Paste GitHub.com folder URL + press Enter">
21+
<input id="url" autofocus name="url" type="url" size="38" placeholder="Paste GitHub.com folder URL + press Enter">
1622
</form>
1723
<footer class="text-align-center">
1824
<label for="info-toggle">info</label>
@@ -22,7 +28,7 @@ <h1><a href="/">download-directory • github • io</a></h1>
2228
Enter
2329
<a href="https://github.com/settings/tokens/new?description=Download GitHub directory&scopes=repo" target="_blank">
2430
your GitHub token</a>:<br>
25-
<input id="token" placeholder="Token" autocomplete="off" size="45" pattern="[\da-f]{40}|ghp_\w{36,251}" required><br>
31+
<input id="token" placeholder="Token" autocomplete="off" size="45" pattern="[\da-f]{40}|ghp_.+|gho_.+|github_pat_.+" required><br>
2632
Brought to you by the developers of <a href="https://github.com/sindresorhus/refined-github/pull/951#issuecomment-358301513" target="_blank">Refined GitHub</a>
2733
</span>
2834

@@ -33,6 +39,8 @@ <h1><a href="/">download-directory • github • io</a></h1>
3339
<p>This tool will handle the download of all the files in a directory, in a single click, after you entered your token.</p>
3440
<p>The download starts automatically when you visit pass the link to the GitHub directory as <code>url</code> parameter, like:</p>
3541
<p><a href="/?url=https://github.com/mrdoob/three.js/tree/dev/build"><strong>download-directory.github.io</strong><code>?url=https://github.com/mrdoob/three.js/tree/dev/build</code></a></p>
42+
<p>You can also specify download filename by adding <code>filename</code> parameter, like:</p>
43+
<p><a href="/?url=https://github.com/mrdoob/three.js/tree/dev/build&filename=three-js-build"><strong>download-directory.github.io</strong><code>?url=https://github.com/mrdoob/three.js/tree/dev/build&filename=three-js-build</code></a> to save the file as <strong>three-js-build.zip</strong>.</p>
3644
</div>
3745
</footer>
3846
<pre class="status">Loading</pre>

0 commit comments

Comments
 (0)