From 3c49437bf9f9b76a6e9a03f8d8b5f1d0add6c995 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 18 Sep 2026 01:29:38 -0400 Subject: [PATCH] Add GitHub plugin release imports and update discovery --- README.md | 5 + docs/plugin-releases.md | 61 ++++++ src/lib/plugin-github.ts | 38 ++++ src/lib/server/db/schema.sql | 11 ++ src/lib/server/fleet/plugin-releases.ts | 85 ++++++++ .../server/integrations/github-releases.ts | 140 +++++++++++++ src/lib/ui/components/ArtifactDetail.svelte | 13 +- .../ui/components/PluginGithubImport.svelte | 175 ++++++++++++++++ src/lib/ui/components/PluginUpdates.svelte | 47 +++++ .../api/plugins/github/import/+server.ts | 20 ++ .../api/plugins/github/release/+server.ts | 10 + src/routes/api/plugins/updates/+server.ts | 25 +++ src/routes/api/scripts/[id]/+server.ts | 5 + src/routes/betas/+page.svelte | 22 ++- src/routes/plugins/+page.svelte | 6 + tests/unit/github-plugin-import.test.ts | 186 ++++++++++++++++++ 16 files changed, 847 insertions(+), 2 deletions(-) create mode 100644 docs/plugin-releases.md create mode 100644 src/lib/plugin-github.ts create mode 100644 src/lib/server/fleet/plugin-releases.ts create mode 100644 src/lib/server/integrations/github-releases.ts create mode 100644 src/lib/ui/components/PluginGithubImport.svelte create mode 100644 src/lib/ui/components/PluginUpdates.svelte create mode 100644 src/routes/api/plugins/github/import/+server.ts create mode 100644 src/routes/api/plugins/github/release/+server.ts create mode 100644 src/routes/api/plugins/updates/+server.ts create mode 100644 tests/unit/github-plugin-import.test.ts diff --git a/README.md b/README.md index 4593250..fe7cd86 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ without a Docker health check use their running state as the readiness signal. - `scripts/` — legacy JSON importer, fixture seeder - `tests/` — vitest unit tests +## Plugin releases + +Plugins support named release versions, GitHub DLL imports and update discovery. +See [Plugin releases](docs/plugin-releases.md) for the import and beta workflow. + ## How restarts stay safe Betas support configurable gameplay, participation, stability and manual-review diff --git a/docs/plugin-releases.md b/docs/plugin-releases.md new file mode 100644 index 0000000..f1f4b54 --- /dev/null +++ b/docs/plugin-releases.md @@ -0,0 +1,61 @@ +# Plugin releases from GitHub + +On **Plugins**, paste a public GitHub repository or release URL into **Import +from GitHub**. Repository and `/releases/latest` URLs preview the latest stable +release. Specific `/releases/tag/v3.1.0` links and +`/releases#release-v3.1.0` links select that exact release, including prereleases. + +Review the notes, select a DLL, give the plugin a name, and choose an optional +T5/T6 game scope. Map scopes can be set on the plugin detail page. **Import only** +saves the binary; **Import and test** opens the beta builder with that exact +plugin version selected. Select servers and a test plan to start testing. +Imports alone never change server files or promote versions. + +Each import stores the publisher's tag, release notes, GitHub repository, +release URL, release and asset IDs, asset filename, publication date and a +SHA-256 checksum. If GitHub supplies a SHA-256 asset digest, the download must +match it. The existing upload number still distinguishes different builds. +Version history links back to each imported release. + +## Discovering updates + +The plugins page and each plugin's GitHub panel check for the latest stable +release. Lookups are cached per repository for five minutes, including when +using **Check for updates**. No background upgrades or automatic promotions +occur. Failed checks are shown per plugin so one unavailable repository does +not hide other updates. + +**Review update** opens release notes and a DLL picker. The previously imported +asset filename is selected when available. Multiple unfamiliar DLLs require a +selection. Importing into an existing plugin keeps its scope and deployment +filename. An active beta must be completed before another release is imported. + +Update tracking follows the most recently imported version's repository and +asset name. An imported release is distinguished from a promoted release; +importing the same release asset and binary into that plugin again reuses the +existing version. An older stable release is not offered as an upgrade over a +newer imported prerelease. A plugin uploaded manually starts tracking once a +GitHub release is imported from its detail page. + +## Limits + +- Public repositories on github.com only; no GitHub credentials are needed. +- Direct `.dll` release assets up to 64 MB; archives and source-code downloads + are not extracted. Other formats can still use the manual upload workflow. +- Release tags have the same 80-character limit as manually entered versions. +- GitHub rate limits, missing releases, download failures and checksum failures + produce errors without saving a partial import. +- Downloads follow only HTTPS redirects to GitHub's release asset hosts and + have a size limit and timeout. + +## API + +- `GET /api/plugins/github/release?url=...` previews a release and its DLL assets. +- `POST /api/plugins/github/import` accepts JSON `{ url, assetId, name, + scopeType?, scopeValue? }` for a new plugin, or `{ url, assetId, pluginId }` + for an existing plugin. Use the preview's exact release URL, not a mutable + `latest` URL. Returns plugin/version IDs, checksum and the beta builder URL. +- `GET /api/plugins/updates` checks all plugins; `?pluginId=123` checks one. + +The server resolves release metadata again during import. Client-supplied notes, +tags and download URLs are not used as provenance or download instructions. diff --git a/src/lib/plugin-github.ts b/src/lib/plugin-github.ts new file mode 100644 index 0000000..4727781 --- /dev/null +++ b/src/lib/plugin-github.ts @@ -0,0 +1,38 @@ +export interface GithubPluginAsset { + id: number; + name: string; + size: number; + digest: string | null; +} + +export interface GithubPluginRelease { + repository: string; + id: number; + tag: string; + url: string; + name: string; + notes: string; + publishedAt: string; + prerelease: boolean; + assets: GithubPluginAsset[]; +} + +export interface PluginSource { + version_id: number; + repository: string; + release_id: number; + release_url: string; + asset_id: number; + asset_name: string; + published_at: string; +} + +export interface PluginUpdate { + pluginId: number; + name: string; + source: PluginSource | null; + release: GithubPluginRelease | null; + status: 'untracked' | 'available' | 'imported' | 'current' | 'ahead' | 'error'; + versionId?: number; + error?: string; +} diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 24f9cca..9836178 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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, diff --git a/src/lib/server/fleet/plugin-releases.ts b/src/lib/server/fleet/plugin-releases.ts new file mode 100644 index 0000000..9f30ff0 --- /dev/null +++ b/src/lib/server/fleet/plugin-releases.ts @@ -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 { + 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>, alreadyImported: boolean) { + return { pluginId, versionId: version.id, releaseVersion: version.release_version, sha256: version.sha256, + alreadyImported, betaUrl: `/betas?pluginId=${pluginId}&versionId=${version.id}` }; +} diff --git a/src/lib/server/integrations/github-releases.ts b/src/lib/server/integrations/github-releases.ts new file mode 100644 index 0000000..a43f673 --- /dev/null +++ b/src/lib/server/integrations/github-releases.ts @@ -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 }>(); + +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 { + 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 { + 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 { + const { repository, tag } = parseGithubReleaseUrl(url); + const key = `${repository.toLowerCase()}:${tag ?? ''}`; + 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 { + // 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); + } +} diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index 1fa9ad0..81abb0d 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -1,6 +1,8 @@ + + +
+
+

{pluginId ? 'GitHub releases' : 'Import from GitHub'}

Choose a release and DLL, then test it with your fleet.

+ {#if pluginId}{/if} +
+ {#if checking}

Checking GitHub…

{/if} + {#if update?.source} +

Following {update.source.repository} · {update.source.asset_name}

+ {/if} + {#if update?.status === 'available' && update.release} +
+ New release: {update.release.tag} + +
+ {:else if update?.status === 'imported' && update.release} +
{update.release.tag} is already imported and available for testing. +
+ {:else if update?.status === 'current'} +

The latest stable release is promoted.

+ {:else if update?.status === 'ahead'} +

Your imported release is newer than GitHub’s latest stable release.

+ {:else if update?.status === 'error'} + + {:else if update?.status === 'untracked'} +

Import a release below to track updates for this plugin.

+ {/if} +
+ + +
+ {#if disabled}

Finish the running beta before importing a release.

{/if} + {#if release} +
+

{release.name || release.tag}

{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}
+

View on GitHub ↗ · Published {new Date(release.publishedAt).toLocaleDateString()}

+
Release notes
{release.notes || 'No release notes provided.'}
+ {#if release.assets.length === 0} +

No importable DLL assets found. Choose another release or upload a DLL manually. Direct DLL assets up to 64 MB are supported.

+ {:else} +
+ + {#if !pluginId} + + + {/if} +
+ {#if targetPath}

New versions deploy to {targetPath}.

{/if} +
+ + +
+

Imports are saved for testing. Choose servers and a test plan before starting a beta.

+ {/if} +
+ {/if} + {#if success} +
+ {success.alreadyImported ? 'This binary is already imported.' : 'Release imported.'} + View plugin · Start beta + SHA-256: {success.sha256} +
+ {/if} + {#if error}{/if} +
+
+ + diff --git a/src/lib/ui/components/PluginUpdates.svelte b/src/lib/ui/components/PluginUpdates.svelte new file mode 100644 index 0000000..95e8142 --- /dev/null +++ b/src/lib/ui/components/PluginUpdates.svelte @@ -0,0 +1,47 @@ + + + +

Release updates

+

Latest stable GitHub releases. Checks are cached for five minutes.

+ {#if error}{/if} + {#if checked && !tracked.length}

Import a plugin from GitHub to start tracking its releases.

{/if} + {#each tracked as update (update.pluginId)} +
+
{update.name} +

{update.status === 'available' ? `${update.release?.tag} available` : update.status === 'imported' ? `${update.release?.tag} imported — awaiting promotion` : update.status === 'current' ? `${update.release?.tag} promoted` : update.status === 'ahead' ? 'Ahead of the latest stable release' : update.error}

+
+ {#if update.status === 'available'}{/if} +
+ {/each} +
+ + diff --git a/src/routes/api/plugins/github/import/+server.ts b/src/routes/api/plugins/github/import/+server.ts new file mode 100644 index 0000000..bef6949 --- /dev/null +++ b/src/routes/api/plugins/github/import/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import { importPluginRelease } from '$lib/server/fleet/plugin-releases.js'; +import { GithubReleaseError } from '$lib/server/integrations/github-releases.js'; +import { ScriptError } from '$lib/server/fleet/scripts.js'; + +export async function POST({ request }: { request: Request }) { + let input; + try { input = await request.json(); } catch { return json({ error: 'Invalid JSON body.' }, { status: 400 }); } + if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'Import details are required.' }, { status: 400 }); + if ((input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') || + (input.scopeValue != null && typeof input.scopeValue !== 'string')) { + return json({ error: 'Invalid plugin scope.' }, { status: 400 }); + } + try { return json({ ok: true, ...await importPluginRelease(input) }); } + catch (error) { + if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status }); + if (error instanceof ScriptError) return json({ error: error.message }, { status: 409 }); + throw error; + } +} diff --git a/src/routes/api/plugins/github/release/+server.ts b/src/routes/api/plugins/github/release/+server.ts new file mode 100644 index 0000000..3e27cd0 --- /dev/null +++ b/src/routes/api/plugins/github/release/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import { getGithubRelease, GithubReleaseError } from '$lib/server/integrations/github-releases.js'; + +export async function GET({ url }: { url: URL }) { + try { return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') }); } + catch (error) { + if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status }); + throw error; + } +} diff --git a/src/routes/api/plugins/updates/+server.ts b/src/routes/api/plugins/updates/+server.ts new file mode 100644 index 0000000..b29f83e --- /dev/null +++ b/src/routes/api/plugins/updates/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { listScripts } from '$lib/server/fleet/scripts.js'; +import { checkPluginUpdate } from '$lib/server/fleet/plugin-releases.js'; +import { GithubReleaseError } from '$lib/server/integrations/github-releases.js'; +import type { PluginUpdate } from '$lib/plugin-github.js'; + +export async function GET({ url }: { url: URL }) { + const raw = url.searchParams.get('pluginId'); + if (raw !== null) { + const id = Number(raw); + if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid plugin id.' }, { status: 400 }); + try { return json({ updates: [await checkPluginUpdate(id)] }); } + catch (error) { + if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status }); + throw error; + } + } + const plugins = listScripts('plugin'); + const updates: PluginUpdate[] = []; + // Bound outbound concurrency for large fleets; repository requests share a five-minute cache. + for (let i = 0; i < plugins.length; i += 4) { + updates.push(...await Promise.all(plugins.slice(i, i + 4).map(p => checkPluginUpdate(p.id)))); + } + return json({ updates }); +} diff --git a/src/routes/api/scripts/[id]/+server.ts b/src/routes/api/scripts/[id]/+server.ts index caf1e52..1981aea 100644 --- a/src/routes/api/scripts/[id]/+server.ts +++ b/src/routes/api/scripts/[id]/+server.ts @@ -8,6 +8,8 @@ import { type ScopeType } from '$lib/server/fleet/scripts.js'; import { findRunningBetaForScript } from '$lib/server/fleet/beta.js'; +import { db } from '$lib/server/db/index.js'; +import type { PluginSource } from '$lib/plugin-github.js'; function parseId(idRaw: string): number | null { const n = Number.parseInt(idRaw, 10); @@ -27,6 +29,9 @@ export async function GET({ params }: { params: { id: string } }) { sha256: v.sha256, size: v.size, notes: v.notes, + source: script.kind === 'plugin' + ? db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as PluginSource | undefined ?? null + : null, state: v.state, uploaded_at: v.uploaded_at })); diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 38f9bdc..9c29dab 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -82,7 +82,7 @@ let poll: ReturnType | null = null; onMount(() => { - load(); + void load().then(seedImportedPlugin).catch(error => { flash = (error as Error).message; }); poll = setInterval(refreshBetas, 5000); }); onDestroy(() => { @@ -102,6 +102,26 @@ return eligibleScripts.filter((s) => !takenIds.has(s.id)); } + async function seedImportedPlugin(): Promise { + const query = new URLSearchParams(window.location.search); + if (!query.has('pluginId')) return; + const pluginId = Number(query.get('pluginId')); + const versionId = Number(query.get('versionId')); + if (!eligibleScripts.some(s => s.id === pluginId && s.kind === 'plugin')) { + flash = 'This plugin is already promoted or has a running beta. Open its detail page to review its status.'; + return; + } + picks = [{ key: nextKey++, scriptId: pluginId, versionId: null }]; + await onPickScript(0); + if (!versionsForRow(picks[0]).some(v => v.id === versionId)) { + picks[0].versionId = null; + flash = 'The imported version is no longer available for testing. Choose a version to continue.'; + return; + } + picks[0].versionId = versionId; + picks = [...picks]; + } + async function onPickScript(rowIdx: number): Promise { flash = null; const pick = picks[rowIdx]; diff --git a/src/routes/plugins/+page.svelte b/src/routes/plugins/+page.svelte index de778cb..0f9aa6a 100644 --- a/src/routes/plugins/+page.svelte +++ b/src/routes/plugins/+page.svelte @@ -1,5 +1,7 @@ ', + published_at: '2026-07-27T12:00:00Z', draft: false, prerelease: false, + assets: [{ id: 200, name: 't6-gsc-utils.dll', size: content.length, digest: `sha256:${sha256}`, state: 'uploaded' }], + ...overrides +}); +let fetchMock: ReturnType; +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture(); + delete process.env.KOMODO_ADDRESS; + delete process.env.RCON_PASSWORD; + fetchMock = vi.fn(async (input: string | URL) => String(input).startsWith('https://api.github.com/') + ? Response.json(metadata()) : new Response(content)); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(async () => { vi.unstubAllGlobals(); await fixture.cleanup(); }); + +describe('GitHub release lookup', () => { + it('supports repository, latest, tag and the supplied release-anchor URLs', async () => { + const { parseGithubReleaseUrl, getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); + expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases#release-v3.1.0')).toEqual({ repository: 'alicealys/t6-gsc-utils', tag: 'v3.1.0' }); + expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases/latest').tag).toBeNull(); + expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils').tag).toBeNull(); + expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases/tag/release%2F3.1').tag).toBe('release/3.1'); + const release = await getGithubRelease(url); + expect(release).toMatchObject({ repository: 'alicealys/t6-gsc-utils', tag: 'v3.1.0', assets: [{ name: 't6-gsc-utils.dll' }] }); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.github.com/repos/alicealys/t6-gsc-utils/releases/tags/v3.1.0'); + }); + + it.each(['http://github.com/a/b', 'https://github.com.evil.test/a/b', 'https://github.com@127.0.0.1/a/b', + 'https://github.com:444/a/b', 'https://github.com/a/b/issues/1', 'https://github.com/a/b/releases#unknown', + 'https://github.com/a/b/releases/tag/', 'https://github.com/a/b/releases/tag/v1%0A'])('rejects unsupported URL %s without fetching it', async (input) => { + const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); + await expect(getGithubRelease(input)).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('filters out archives, unsafe filenames and oversized assets; shares concurrent lookups', async () => { + const normal = metadata().assets[0]; + fetchMock.mockImplementation(async () => Response.json(metadata({ assets: [normal, + { ...normal, id: 201, name: 'source.zip' }, { ...normal, id: 202, name: '../evil.dll' }, + { ...normal, id: 203, size: 65 * 1024 * 1024 }, { ...normal, id: 204, state: 'new' }] }))); + const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); + const [first, second] = await Promise.all([getGithubRelease(url), getGithubRelease(url)]); + expect(first.assets).toHaveLength(1); + expect(second).toEqual(first); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('reports rate limits and retries failed lookups', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 })); + const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); + await expect(getGithubRelease(url)).rejects.toMatchObject({ status: 429 }); + await expect(getGithubRelease(url)).resolves.toMatchObject({ tag: 'v3.1.0' }); + }); +}); + +describe('GitHub DLL imports', () => { + it('captures provenance and notes, leaves promotion untouched, and deduplicates concurrent imports', async () => { + const { importPluginRelease, pluginSource } = await import('../../src/lib/server/fleet/plugin-releases.js'); + const { getScript, getVersion, listVersions } = await import('../../src/lib/server/fleet/scripts.js'); + const imported = await importPluginRelease({ url, assetId: 200, name: 'Utilities', scopeType: 'game', scopeValue: 't6' }); + expect(imported.sha256).toBe(sha256); + expect(imported.betaUrl).toBe(`/betas?pluginId=${imported.pluginId}&versionId=${imported.versionId}`); + expect(getScript(imported.pluginId)).toMatchObject({ kind: 'plugin', target_rel_path: 't6-gsc-utils.dll', scope_value: 't6', current_version_id: null }); + expect(getVersion(imported.versionId)).toMatchObject({ release_version: 'v3.1.0', content, notes: metadata().body, state: 'untested' }); + expect(pluginSource(imported.pluginId)).toMatchObject({ repository: 'alicealys/t6-gsc-utils', release_id: 100, asset_id: 200, release_url: url }); + const duplicates = await Promise.all([1, 2].map(() => importPluginRelease({ url, assetId: 200, pluginId: imported.pluginId }))); + expect(duplicates.every(r => r.alreadyImported && r.versionId === imported.versionId)).toBe(true); + expect(listVersions(imported.pluginId)).toHaveLength(1); + const { GET } = await import('../../src/routes/api/scripts/[id]/+server.js'); + const response = await (await GET({ params: { id: String(imported.pluginId) } })).json(); + expect(response.versions[0].source.asset_id).toBe(200); + expect(response.versions[0]).not.toHaveProperty('content'); + }); + + it('follows GitHub asset redirects and verifies the bytes', async () => { + fetchMock.mockImplementation(async (input) => { + if (String(input).startsWith('https://api.github.com/')) return Response.json(metadata()); + if (String(input).startsWith('https://github.com/')) return new Response(null, { status: 302, headers: { location: 'https://release-assets.githubusercontent.com/blob/test' } }); + return new Response(content); + }); + const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); + await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).resolves.toMatchObject({ sha256 }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it.each(['http://127.0.0.1/internal', 'https://evil.test/file.dll', 'https://user:pass@github.com/file.dll'])('blocks unsafe download redirects to %s', async (location) => { + fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') + ? Response.json(metadata()) : new Response(null, { status: 302, headers: { location } })); + const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); + const { listScripts } = await import('../../src/lib/server/fleet/scripts.js'); + await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/unsupported host/); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(listScripts()).toHaveLength(0); + }); + + it('rejects checksum mismatches and truncated downloads without creating a plugin', async () => { + const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); + const { listScripts } = await import('../../src/lib/server/fleet/scripts.js'); + fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') + ? Response.json(metadata()) : new Response(Buffer.alloc(content.length))); + await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/checksum/); + fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') + ? Response.json(metadata()) : new Response(Buffer.alloc(1))); + await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/size differs/); + expect(listScripts()).toHaveLength(0); + }); + + it('rejects a missing asset and refuses to import into non-plugins or active betas', async () => { + const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + await expect(importPluginRelease({ url, assetId: 999, name: 'Utilities' })).rejects.toThrow(/no longer available/); + const script = createScript({ name: 'Script', targetRelPath: 's.gsc', content }); + await expect(importPluginRelease({ url, assetId: 200, pluginId: script.script.id })).rejects.toMatchObject({ status: 404 }); + const plugin = createScript({ name: 'Plugin', kind: 'plugin', targetRelPath: 'p.dll', content }); + const beta = db().prepare("INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'version', 'running')") + .run(plugin.script.id, plugin.version.id); + db().prepare('INSERT INTO beta_scripts (beta_id, script_id, script_version_id, kind) VALUES (?, ?, ?, ?)') + .run(beta.lastInsertRowid, plugin.script.id, plugin.version.id, 'plugin'); + await expect(importPluginRelease({ url, assetId: 200, pluginId: plugin.script.id })).rejects.toMatchObject({ status: 409 }); + }); +}); + +describe('plugin update discovery', () => { + it('finds newer stable releases, imports into the same plugin, and distinguishes imported from promoted', async () => { + const { importPluginRelease, checkPluginUpdate } = await import('../../src/lib/server/fleet/plugin-releases.js'); + const { getScript, listVersions } = await import('../../src/lib/server/fleet/scripts.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const first = await importPluginRelease({ url, assetId: 200, name: 'Utilities' }); + const next = metadata({ id: 101, tag_name: 'v3.1.1', published_at: '2026-08-11T12:00:00Z', + assets: [{ ...metadata().assets[0], id: 201 }] }); + fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(next) : new Response(content)); + const update = await checkPluginUpdate(first.pluginId); + expect(update).toMatchObject({ status: 'available', release: { tag: 'v3.1.1' } }); + const second = await importPluginRelease({ url: update.release!.url, assetId: 201, pluginId: first.pluginId }); + expect(listVersions(first.pluginId)).toHaveLength(2); + expect(getScript(first.pluginId)!.current_version_id).toBeNull(); + expect(await checkPluginUpdate(first.pluginId)).toMatchObject({ status: 'imported', versionId: second.versionId }); + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(second.versionId, first.pluginId); + expect(await checkPluginUpdate(first.pluginId)).toMatchObject({ status: 'current', versionId: second.versionId }); + }); + + it('does not recommend an older stable release after importing a newer prerelease', async () => { + const { importPluginRelease, checkPluginUpdate } = await import('../../src/lib/server/fleet/plugin-releases.js'); + const stable = await importPluginRelease({ url, assetId: 200, name: 'Utilities' }); + fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(metadata({ id: 105, tag_name: 'v4.0-rc1', prerelease: true, published_at: '2026-09-01T12:00:00Z' })) : new Response(content)); + const imported = await importPluginRelease({ url: url.replace('v3.1.0', 'v4.0-rc1'), assetId: 200, pluginId: stable.pluginId }); + fetchMock.mockImplementation(async () => Response.json(metadata())); + expect(await checkPluginUpdate(imported.pluginId)).toMatchObject({ status: 'ahead' }); + }); + + it('reports untracked and GitHub failures per plugin without breaking the list', async () => { + const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + await importPluginRelease({ url, assetId: 200, name: 'Imported' }); + createScript({ name: 'Manual', kind: 'plugin', targetRelPath: 'manual.dll', content }); + fetchMock.mockResolvedValue(new Response(null, { status: 404 })); + const { GET } = await import('../../src/routes/api/plugins/updates/+server.js'); + const result = await (await GET({ url: new URL('http://localhost/api/plugins/updates') })).json(); + expect(result.updates.map((u: { status: string }) => u.status)).toEqual(['error', 'untracked']); + }); + + it('serves the preview and import endpoints and validates bad requests', async () => { + const { GET } = await import('../../src/routes/api/plugins/github/release/+server.js'); + const { POST } = await import('../../src/routes/api/plugins/github/import/+server.js'); + const preview = await GET({ url: new URL(`http://localhost/api/plugins/github/release?url=${encodeURIComponent(url)}`) }); + expect((await preview.json()).release.assets[0].id).toBe(200); + const request = (body: unknown) => new Request('http://localhost/api/plugins/github/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + expect((await POST({ request: request(null) })).status).toBe(400); + expect((await POST({ request: request({ url, assetId: 200, name: 'Test', scopeType: 'other' }) })).status).toBe(400); + const response = await POST({ request: request({ url, assetId: 200, name: 'Test' }) }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, sha256, releaseVersion: 'v3.1.0' }); + }); +});