83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
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 };
|
|
}
|
|
}
|