268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
import { createHash } from 'node:crypto';
|
||
import type { GithubPluginAsset, GithubPluginRelease } from '$lib/plugin-github.js';
|
||
|
||
const MAX_DLL_BYTES = 64 * 1024 * 1024;
|
||
const CACHE_MS = 5 * 60 * 1000;
|
||
const cache = new Map<string, { expires: number; release: Promise<GithubPluginRelease> }>();
|
||
|
||
export class GithubReleaseError extends Error {
|
||
constructor(
|
||
message: string,
|
||
public readonly status = 400
|
||
) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
export function parseGithubReleaseUrl(input: string): { repository: string; tag: string | null } {
|
||
let url: URL;
|
||
try {
|
||
url = new URL(input);
|
||
} catch {
|
||
throw new GithubReleaseError('Enter a GitHub repository or release URL.');
|
||
}
|
||
if (
|
||
url.protocol !== 'https:' ||
|
||
url.hostname !== 'github.com' ||
|
||
url.port ||
|
||
url.username ||
|
||
url.password ||
|
||
url.search
|
||
) {
|
||
throw new GithubReleaseError('Use an https://github.com repository or release URL.');
|
||
}
|
||
const match = /^\/([\w-]+)\/([\w.-]+)(?:\/(.*?))?\/?$/.exec(url.pathname);
|
||
if (!match || match[2] === '.' || match[2] === '..')
|
||
throw new GithubReleaseError('Invalid GitHub repository URL.');
|
||
const repository = `${match[1]}/${match[2]}`;
|
||
const path = (match[3] ?? '').replace(/\/$/, '');
|
||
let tag: string | null = null;
|
||
try {
|
||
if (path.startsWith('releases/tag/')) tag = decodeURIComponent(path.slice(13));
|
||
else if (path === 'releases' && url.hash.startsWith('#release-'))
|
||
tag = decodeURIComponent(url.hash.slice(9));
|
||
else if (!['', 'releases', 'releases/latest'].includes(path) || url.hash) throw new Error();
|
||
} catch {
|
||
throw new GithubReleaseError(
|
||
'Use a repository URL or a link to a specific GitHub release.'
|
||
);
|
||
}
|
||
// eslint-disable-next-line no-control-regex -- Reject control characters in release metadata and asset names.
|
||
if (tag !== null && (!tag || tag.length > 80 || /[\u0000-\u001f\u007f]/.test(tag))) {
|
||
throw new GithubReleaseError('Release tags must contain 1–80 characters on one line.');
|
||
}
|
||
return { repository, tag };
|
||
}
|
||
|
||
function safeAssetName(name: unknown): name is string {
|
||
return (
|
||
typeof name === 'string' &&
|
||
// eslint-disable-next-line no-control-regex -- Reject control characters in release metadata and asset names.
|
||
/^[^<>:"/\\|?*\u0000-\u001f]+\.dll$/i.test(name) &&
|
||
name.length <= 200
|
||
);
|
||
}
|
||
|
||
async function limitedBody(response: Response, limit: number): Promise<Buffer> {
|
||
if (Number(response.headers.get('content-length')) > limit) {
|
||
await response.body?.cancel();
|
||
throw new GithubReleaseError('GitHub response exceeds the download size limit.', 502);
|
||
}
|
||
if (!response.body) throw new GithubReleaseError('GitHub returned an empty response.', 502);
|
||
const reader = response.body.getReader();
|
||
const chunks: Uint8Array[] = [];
|
||
let size = 0;
|
||
try {
|
||
for (;;) {
|
||
const next = await reader.read();
|
||
if (next.done) break;
|
||
size += next.value.byteLength;
|
||
if (size > limit)
|
||
throw new GithubReleaseError(
|
||
'GitHub response exceeds the download size limit.',
|
||
502
|
||
);
|
||
chunks.push(next.value);
|
||
}
|
||
} finally {
|
||
await reader.cancel();
|
||
}
|
||
return Buffer.concat(chunks);
|
||
}
|
||
|
||
async function fetchRelease(repository: string, tag: string | null): Promise<GithubPluginRelease> {
|
||
const suffix = tag === null ? 'latest' : `tags/${encodeURIComponent(tag)}`;
|
||
try {
|
||
const response = await fetch(
|
||
`https://api.github.com/repos/${repository}/releases/${suffix}`,
|
||
{
|
||
headers: {
|
||
Accept: 'application/vnd.github+json',
|
||
'User-Agent': 'Electric-Cherry',
|
||
'X-GitHub-Api-Version': '2022-11-28'
|
||
},
|
||
signal: AbortSignal.timeout(15_000),
|
||
redirect: 'error'
|
||
}
|
||
);
|
||
if (!response.ok) {
|
||
await response.body?.cancel();
|
||
if (response.status === 404)
|
||
throw new GithubReleaseError(
|
||
'No published release found. Check the repository and tag; only public repositories are supported.',
|
||
404
|
||
);
|
||
if ([403, 429].includes(response.status))
|
||
throw new GithubReleaseError(
|
||
'GitHub is limiting requests. Try checking again later.',
|
||
429
|
||
);
|
||
throw new GithubReleaseError(
|
||
`GitHub could not load this release (HTTP ${response.status}).`,
|
||
502
|
||
);
|
||
}
|
||
const data = JSON.parse((await limitedBody(response, 2 * 1024 * 1024)).toString('utf8'));
|
||
if (
|
||
!Number.isSafeInteger(data.id) ||
|
||
data.id < 1 ||
|
||
data.draft ||
|
||
typeof data.tag_name !== 'string' ||
|
||
!data.tag_name.trim() ||
|
||
data.tag_name.length > 80 ||
|
||
// eslint-disable-next-line no-control-regex -- Reject control characters in release metadata and asset names.
|
||
/[\u0000-\u001f\u007f]/.test(data.tag_name) ||
|
||
!Number.isFinite(Date.parse(data.published_at)) ||
|
||
!Array.isArray(data.assets)
|
||
) {
|
||
throw new GithubReleaseError('GitHub returned invalid release metadata.', 502);
|
||
}
|
||
return {
|
||
repository,
|
||
id: data.id,
|
||
tag: data.tag_name,
|
||
url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(data.tag_name)}`,
|
||
name: typeof data.name === 'string' ? data.name : data.tag_name,
|
||
notes: typeof data.body === 'string' ? data.body : '',
|
||
publishedAt: data.published_at,
|
||
prerelease: data.prerelease === true,
|
||
assets: data.assets
|
||
.filter(
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Validate dynamically decoded GitHub asset fields below.
|
||
(a: any) =>
|
||
a &&
|
||
Number.isSafeInteger(a.id) &&
|
||
a.id > 0 &&
|
||
safeAssetName(a.name) &&
|
||
a.state === 'uploaded' &&
|
||
Number.isSafeInteger(a.size) &&
|
||
a.size > 0 &&
|
||
a.size <= MAX_DLL_BYTES
|
||
)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The preceding filter validates the decoded asset fields.
|
||
.map((a: any) => ({
|
||
id: a.id,
|
||
name: a.name,
|
||
size: a.size,
|
||
digest: typeof a.digest === 'string' ? a.digest : null
|
||
}))
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof GithubReleaseError) throw error;
|
||
throw new GithubReleaseError(
|
||
'Could not reach GitHub or read the release. Try again shortly.',
|
||
502
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getGithubRelease(url: string, fresh = false): Promise<GithubPluginRelease> {
|
||
const { repository, tag } = parseGithubReleaseUrl(url);
|
||
const key = `${repository.toLowerCase()}:${tag ?? '<latest>'}`;
|
||
const hit = cache.get(key);
|
||
if (!fresh && hit && hit.expires > Date.now()) return hit.release;
|
||
if (cache.size >= 100) cache.delete(cache.keys().next().value!);
|
||
const release = fetchRelease(repository, tag);
|
||
cache.set(key, { expires: Date.now() + CACHE_MS, release });
|
||
try {
|
||
return await release;
|
||
} catch (error) {
|
||
cache.delete(key);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function downloadGithubAsset(
|
||
release: GithubPluginRelease,
|
||
asset: GithubPluginAsset
|
||
): Promise<Buffer> {
|
||
// Construct the URL from validated metadata. Never fetch a client-supplied download URL.
|
||
let url = new URL(
|
||
`https://github.com/${release.repository}/releases/download/${encodeURIComponent(release.tag)}/${encodeURIComponent(asset.name)}`
|
||
);
|
||
const signal = AbortSignal.timeout(60_000);
|
||
try {
|
||
for (let i = 0; i < 5; i++) {
|
||
if (
|
||
url.protocol !== 'https:' ||
|
||
url.port ||
|
||
url.username ||
|
||
url.password ||
|
||
![
|
||
'github.com',
|
||
'release-assets.githubusercontent.com',
|
||
'objects.githubusercontent.com'
|
||
].includes(url.hostname)
|
||
) {
|
||
throw new GithubReleaseError(
|
||
'GitHub redirected this download to an unsupported host.',
|
||
502
|
||
);
|
||
}
|
||
const response = await fetch(url, {
|
||
redirect: 'manual',
|
||
signal,
|
||
headers: { 'User-Agent': 'Electric-Cherry' }
|
||
});
|
||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||
await response.body?.cancel();
|
||
const location = response.headers.get('location');
|
||
if (!location) break;
|
||
url = new URL(location, url);
|
||
continue;
|
||
}
|
||
if (!response.ok) {
|
||
await response.body?.cancel();
|
||
throw new GithubReleaseError(
|
||
`GitHub asset download failed (HTTP ${response.status}).`,
|
||
502
|
||
);
|
||
}
|
||
const content = await limitedBody(response, MAX_DLL_BYTES);
|
||
if (content.length !== asset.size)
|
||
throw new GithubReleaseError(
|
||
'Downloaded size differs from the release asset. Preview the release again.',
|
||
502
|
||
);
|
||
const sha = createHash('sha256').update(content).digest('hex');
|
||
if (
|
||
asset.digest?.startsWith('sha256:') &&
|
||
asset.digest.toLowerCase() !== `sha256:${sha}`
|
||
) {
|
||
throw new GithubReleaseError(
|
||
'Downloaded checksum does not match GitHub’s asset digest.',
|
||
502
|
||
);
|
||
}
|
||
return content;
|
||
}
|
||
throw new GithubReleaseError('GitHub asset download redirected too many times.', 502);
|
||
} catch (error) {
|
||
if (error instanceof GithubReleaseError) throw error;
|
||
throw new GithubReleaseError(
|
||
'Could not download the DLL from GitHub. Try again shortly.',
|
||
502
|
||
);
|
||
}
|
||
}
|