Add GitHub plugin release imports and update discovery
This commit is contained in:
@@ -84,6 +84,17 @@ CREATE TABLE IF NOT EXISTS script_versions (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id);
|
||||
|
||||
-- Provenance belongs to the exact binary version, including when it is rolled back.
|
||||
CREATE TABLE IF NOT EXISTS plugin_imports (
|
||||
version_id INTEGER PRIMARY KEY REFERENCES script_versions(id) ON DELETE CASCADE,
|
||||
repository TEXT NOT NULL,
|
||||
release_id INTEGER NOT NULL,
|
||||
release_url TEXT NOT NULL,
|
||||
asset_id INTEGER NOT NULL,
|
||||
asset_name TEXT NOT NULL,
|
||||
published_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_scripts (
|
||||
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
||||
rel_path TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { createScript, getScript, getVersion, uploadNewVersion, type ScopeType } from './scripts.js';
|
||||
import { findRunningBetaForScript } from './beta.js';
|
||||
import { downloadGithubAsset, getGithubRelease, GithubReleaseError } from '../integrations/github-releases.js';
|
||||
import type { PluginSource, PluginUpdate } from '$lib/plugin-github.js';
|
||||
|
||||
export function pluginSource(pluginId: number): PluginSource | null {
|
||||
return db().prepare(`SELECT p.* FROM plugin_imports p JOIN script_versions v ON v.id = p.version_id
|
||||
WHERE v.script_id = ? ORDER BY v.version DESC LIMIT 1`).get(pluginId) as PluginSource | undefined ?? null;
|
||||
}
|
||||
|
||||
export async function checkPluginUpdate(pluginId: number): Promise<PluginUpdate> {
|
||||
const plugin = getScript(pluginId);
|
||||
if (!plugin || plugin.kind !== 'plugin') throw new GithubReleaseError('Plugin not found.', 404);
|
||||
const source = pluginSource(pluginId);
|
||||
const base = { pluginId, name: plugin.name, source };
|
||||
if (!source) return { ...base, release: null, status: 'untracked' };
|
||||
try {
|
||||
const release = await getGithubRelease(`https://github.com/${source.repository}`);
|
||||
// A latest-stable release may already be in history when a newer prerelease is imported.
|
||||
if (release.id !== source.release_id && Date.parse(release.publishedAt) <= Date.parse(source.published_at)) {
|
||||
return { ...base, release, status: 'ahead' };
|
||||
}
|
||||
const imported = db().prepare(`SELECT p.version_id FROM plugin_imports p JOIN script_versions v ON v.id = p.version_id
|
||||
WHERE v.script_id = ? AND lower(p.repository) = lower(?) AND p.release_id = ? AND p.asset_name = ?
|
||||
ORDER BY v.version DESC LIMIT 1`).get(pluginId, source.repository, release.id, source.asset_name) as { version_id: number } | undefined;
|
||||
if (imported) return { ...base, release, status: imported.version_id === plugin.current_version_id ? 'current' : 'imported', versionId: imported.version_id };
|
||||
return { ...base, release, status: 'available' };
|
||||
} catch (error) {
|
||||
return { ...base, release: null, status: 'error', error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImportPluginReleaseInput {
|
||||
url: string;
|
||||
assetId: number;
|
||||
pluginId?: number;
|
||||
name?: string;
|
||||
scopeType?: ScopeType | null;
|
||||
scopeValue?: string | null;
|
||||
}
|
||||
|
||||
export async function importPluginRelease(input: ImportPluginReleaseInput) {
|
||||
if (typeof input.url !== 'string' || !Number.isSafeInteger(input.assetId) || input.assetId < 1) {
|
||||
throw new GithubReleaseError('A release URL and DLL asset selection are required.');
|
||||
}
|
||||
if (input.pluginId !== undefined && (!Number.isSafeInteger(input.pluginId) || input.pluginId < 1)) {
|
||||
throw new GithubReleaseError('Invalid plugin id.');
|
||||
}
|
||||
const requirePlugin = () => {
|
||||
const plugin = input.pluginId === undefined ? undefined : getScript(input.pluginId);
|
||||
if (input.pluginId !== undefined && (!plugin || plugin.kind !== 'plugin')) throw new GithubReleaseError('Plugin not found.', 404);
|
||||
if (plugin && findRunningBetaForScript(plugin.id)) throw new GithubReleaseError('Finish the running beta before importing another release.', 409);
|
||||
return plugin;
|
||||
};
|
||||
requirePlugin();
|
||||
if (input.pluginId === undefined && (typeof input.name !== 'string' || !input.name.trim())) throw new GithubReleaseError('A plugin name is required.');
|
||||
// Refetch the exact reviewed release: asset names, notes, tags and digests are never trusted from the client.
|
||||
const release = await getGithubRelease(input.url, true);
|
||||
const asset = release.assets.find(a => a.id === input.assetId);
|
||||
if (!asset) throw new GithubReleaseError('That DLL is no longer available in this release. Preview it again.');
|
||||
const content = await downloadGithubAsset(release, asset);
|
||||
const sha = createHash('sha256').update(content).digest('hex');
|
||||
return db().transaction(() => {
|
||||
const plugin = requirePlugin();
|
||||
if (plugin) {
|
||||
const prior = db().prepare(`SELECT v.id FROM script_versions v JOIN plugin_imports p ON p.version_id = v.id
|
||||
WHERE v.script_id = ? AND lower(p.repository) = lower(?) AND p.release_id = ? AND p.asset_id = ? AND v.sha256 = ?
|
||||
ORDER BY v.version DESC LIMIT 1`).get(plugin.id, release.repository, release.id, asset.id, sha) as { id: number } | undefined;
|
||||
if (prior) return result(plugin.id, getVersion(prior.id)!, true);
|
||||
}
|
||||
const created = plugin ? { script: plugin, version: uploadNewVersion(plugin.id, content, release.notes, release.tag) }
|
||||
: createScript({ name: input.name!.trim(), kind: 'plugin', targetRelPath: asset.name, content,
|
||||
releaseVersion: release.tag, notes: release.notes, scopeType: input.scopeType, scopeValue: input.scopeValue });
|
||||
db().prepare(`INSERT INTO plugin_imports (version_id, repository, release_id, release_url, asset_id, asset_name, published_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(created.version.id, release.repository, release.id, release.url, asset.id, asset.name, release.publishedAt);
|
||||
return result(created.script.id, created.version, false);
|
||||
})();
|
||||
}
|
||||
|
||||
function result(pluginId: number, version: NonNullable<ReturnType<typeof getVersion>>, alreadyImported: boolean) {
|
||||
return { pluginId, versionId: version.id, releaseVersion: version.release_version, sha256: version.sha256,
|
||||
alreadyImported, betaUrl: `/betas?pluginId=${pluginId}&versionId=${version.id}` };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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.'); }
|
||||
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' && /^[^<>:"/\\|?*\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 || /[\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((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 }))
|
||||
};
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user