Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+164 -37
View File
@@ -6,25 +6,48 @@ 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); }
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) {
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.');
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 (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.'); }
} 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.');
}
@@ -32,7 +55,12 @@ export function parseGithubReleaseUrl(input: string): { repository: string; tag:
}
function safeAssetName(name: unknown): name is string {
return typeof name === 'string' && /^[^<>:"/\\|?*\u0000-\u001f]+\.dll$/i.test(name) && name.length <= 200;
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> {
@@ -49,45 +77,102 @@ async function limitedBody(response: Response, limit: number): Promise<Buffer> {
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);
if (size > limit)
throw new GithubReleaseError(
'GitHub response exceeds the download size limit.',
502
);
chunks.push(next.value);
}
} finally { await reader.cancel(); }
} 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'
});
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);
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 || /[\u0000-\u001f\u007f]/.test(data.tag_name) ||
!Number.isFinite(Date.parse(data.published_at)) || !Array.isArray(data.assets)) {
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,
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,
notes: typeof data.body === 'string' ? data.body : '',
publishedAt: data.published_at,
prerelease: data.prerelease === true,
assets: data.assets.filter((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)
.map((a: any) => ({ id: a.id, name: a.name, size: a.size, digest: typeof a.digest === 'string' ? a.digest : null }))
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);
throw new GithubReleaseError(
'Could not reach GitHub or read the release. Try again shortly.',
502
);
}
}
@@ -99,20 +184,46 @@ export async function getGithubRelease(url: string, fresh = false): Promise<Gith
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; }
try {
return await release;
} catch (error) {
cache.delete(key);
throw error;
}
}
export async function downloadGithubAsset(release: GithubPluginRelease, asset: GithubPluginAsset): Promise<Buffer> {
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)}`);
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);
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' } });
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');
@@ -122,19 +233,35 @@ export async function downloadGithubAsset(release: GithubPluginRelease, asset: G
}
if (!response.ok) {
await response.body?.cancel();
throw new GithubReleaseError(`GitHub asset download failed (HTTP ${response.status}).`, 502);
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);
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);
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);
throw new GithubReleaseError(
'Could not download the DLL from GitHub. Try again shortly.',
502
);
}
}