import { isRecord, readNonEmptyString } from "../utils/unknown-values"; export type GiteaReleaseAsset = { name: string; browserDownloadUrl: string; size?: number; }; export type GiteaRelease = { tagName: string; title?: string; pageUrl?: string; assets: GiteaReleaseAsset[]; }; export type InstallerAsset = { name: string; downloadUrl: string; size?: number; }; export type ReleaseUpdate = { version: string; title: string; pageUrl?: string; installer: InstallerAsset; }; type UrlPolicy = { allowInsecureHttp: boolean; }; export function parseGiteaRelease(value: unknown): GiteaRelease | null { if (!isRecord(value)) { return null; } const tagName = readRequiredString(value.tag_name); const assets = Array.isArray(value.assets) ? value.assets.map(parseGiteaReleaseAsset).filter(isPresent) : null; if (!tagName || !assets) { return null; } const title = readNonEmptyString(value.name); const pageUrl = readOptionalUrlString(value.html_url); return { tagName, assets, ...(title ? { title } : {}), ...(pageUrl ? { pageUrl } : {}), }; } export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean { const candidate = normalizeVersion(candidateVersion); const current = normalizeVersion(currentVersion); for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) { const candidatePart = candidate[index] ?? 0; const currentPart = current[index] ?? 0; if (candidatePart > currentPart) { return true; } if (candidatePart < currentPart) { return false; } } return false; } export function selectInstallerAsset( release: GiteaRelease, assetPattern: string, policy: UrlPolicy = { allowInsecureHttp: true }, ): InstallerAsset | null { const matcher = new RegExp(assetPattern, "i"); for (const asset of release.assets) { if (!matcher.test(asset.name)) { continue; } const downloadUrl = validateHttpUrl(asset.browserDownloadUrl, policy); if (!downloadUrl) { continue; } return { name: asset.name, downloadUrl, ...(typeof asset.size === "number" ? { size: asset.size } : {}), }; } return null; } export function buildReleaseUpdate( release: GiteaRelease, currentVersion: string, assetPattern: string, policy: UrlPolicy = { allowInsecureHttp: true }, ): ReleaseUpdate | null { const version = release.tagName.replace(/^v/i, ""); if (!version || !isVersionNewer(version, currentVersion)) { return null; } const installer = selectInstallerAsset(release, assetPattern, policy); if (!installer) { return null; } const pageUrl = release.pageUrl ? validateHttpUrl(release.pageUrl, policy) ?? undefined : undefined; return { version, title: release.title ?? `Scoreko ${version}`, ...(pageUrl ? { pageUrl } : {}), installer, }; } export function sanitizeFileName(fileName: string): string { const sanitized = fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim(); return sanitized.length > 0 ? sanitized : "scoreko-update-installer"; } export function validateHttpUrl(value: string, policy: UrlPolicy): string | null { try { const url = new URL(value); if (url.protocol === "https:" || (policy.allowInsecureHttp && url.protocol === "http:")) { return url.toString(); } return null; } catch { return null; } } function parseGiteaReleaseAsset(value: unknown): GiteaReleaseAsset | null { if (!isRecord(value)) { return null; } const name = readRequiredString(value.name); const browserDownloadUrl = readRequiredString(value.browser_download_url); if (!name || !browserDownloadUrl) { return null; } return { name, browserDownloadUrl, ...(typeof value.size === "number" && value.size >= 0 ? { size: value.size } : {}), }; } function normalizeVersion(version: string): number[] { return version .trim() .replace(/^v/i, "") .split(/[+-]/)[0] .split(".") .map((part) => Number.parseInt(part, 10)) .map((part) => (Number.isFinite(part) ? part : 0)); } function readRequiredString(value: unknown): string | null { const text = readNonEmptyString(value); return text && text.length > 0 ? text : null; } function readOptionalUrlString(value: unknown): string | undefined { const rawValue = readNonEmptyString(value); if (!rawValue) { return undefined; } return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined; } function isPresent(value: T | null): value is T { return value !== null; }