feat: add update-available detection
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { UpdateStatus, UpdateSeverity } from './types';
|
||||
|
||||
const SEVERITIES: ReadonlySet<UpdateSeverity> = new Set([
|
||||
'normal', 'security', 'deprecated', 'none', 'unknown',
|
||||
]);
|
||||
|
||||
function isString(v: unknown): v is string {
|
||||
return typeof v === 'string';
|
||||
}
|
||||
|
||||
function isNullableString(v: unknown): v is string | null {
|
||||
return v === null || typeof v === 'string';
|
||||
}
|
||||
|
||||
// Validate the response from the version server before we trust it. Returns
|
||||
// null on any malformed field so a hostile or buggy upstream can't poison the
|
||||
// UI with arbitrary strings (the URL, in particular, is rendered in <a href>).
|
||||
export function parseStatus(raw: unknown): UpdateStatus | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (r.schema !== 1) return null;
|
||||
if (!isString(r.current)) return null;
|
||||
if (!isNullableString(r.latest)) return null;
|
||||
if (typeof r.updateAvailable !== 'boolean') return null;
|
||||
if (!isString(r.severity) || !SEVERITIES.has(r.severity as UpdateSeverity)) return null;
|
||||
if (!isNullableString(r.url)) return null;
|
||||
if (!isNullableString(r.advisory)) return null;
|
||||
if (!isString(r.checkedAt)) return null;
|
||||
|
||||
// Only http(s) URLs are renderable; reject anything else so we don't end
|
||||
// up with a javascript: link in the banner.
|
||||
if (r.url && !/^https?:\/\//i.test(r.url)) return null;
|
||||
|
||||
return {
|
||||
schema: 1,
|
||||
current: r.current,
|
||||
latest: r.latest,
|
||||
updateAvailable: r.updateAvailable,
|
||||
severity: r.severity as UpdateSeverity,
|
||||
url: r.url,
|
||||
advisory: r.advisory,
|
||||
checkedAt: r.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchStatus(
|
||||
endpoint: string,
|
||||
currentVersion: string,
|
||||
): Promise<{ ok: true; status: UpdateStatus } | { ok: false; error: string }> {
|
||||
if (!endpoint) return { ok: false, error: 'endpoint blank' };
|
||||
if (!currentVersion) return { ok: false, error: 'current version blank' };
|
||||
|
||||
// Build the URL safely — never inject the version as a raw path component.
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return { ok: false, error: 'endpoint not a URL' };
|
||||
}
|
||||
url.searchParams.set('v', currentVersion);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const body: unknown = await res.json();
|
||||
const parsed = parseStatus(body);
|
||||
if (!parsed) return { ok: false, error: 'malformed response' };
|
||||
return { ok: true, status: parsed };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn('version-check: fetch failed', { error: msg });
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user