Files
electric-cherry/src/lib/server/integrations/github-releases.ts
T

268 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
);
}
}