This commit is contained in:
+68
-1
@@ -35,7 +35,11 @@ export type ReleaseAsset = {
|
||||
export type ReleaseMetadata = {
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
/** Plain-text release notes, bounded to keep API/cache payloads small. */
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
|
||||
@@ -143,6 +147,57 @@ function metadataError(): Error {
|
||||
}
|
||||
|
||||
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const RELEASE_NOTES_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function releaseNotesText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||
// Gitea exposes both Markdown (body/body_html) and releaseNotes depending on
|
||||
// endpoint/version. Keep the browser contract text-only and bounded.
|
||||
const text = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p\s*>/gi, "\n\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim();
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
if (bytes.length <= RELEASE_NOTES_MAX_BYTES) return text;
|
||||
return bytes.subarray(0, RELEASE_NOTES_MAX_BYTES).toString("utf8").replace(/\uFFFD$/u, "") + "\n[内容已截断]";
|
||||
}
|
||||
|
||||
function releaseNameText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
||||
return text.length > 0 ? text.slice(0, 200) : undefined;
|
||||
}
|
||||
|
||||
/** Gitea installations behind a reverse proxy sometimes emit internal HTTP
|
||||
* asset URLs. Rebind those URLs to the already trusted HTTPS release origin,
|
||||
* while continuing to reject arbitrary HTTPS hosts and credentials. */
|
||||
function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): string {
|
||||
let candidate: URL;
|
||||
try {
|
||||
candidate = new URL(value, current);
|
||||
} catch {
|
||||
throw new Error("更新地址无效");
|
||||
}
|
||||
if (candidate.username || candidate.password) throw new Error("更新地址不允许携带凭据");
|
||||
try {
|
||||
return validateHttpsUrl(candidate, { ...options, baseUrl: current }).toString();
|
||||
} catch {
|
||||
if (candidate.protocol !== "http:") throw new Error("更新地址必须使用 HTTPS");
|
||||
const rebound = new URL(current);
|
||||
rebound.pathname = candidate.pathname;
|
||||
rebound.search = candidate.search;
|
||||
rebound.hash = "";
|
||||
return validateHttpsUrl(rebound, { ...options, baseUrl: current }).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a fetch body without ever buffering more than the caller's bound. */
|
||||
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise<Buffer> {
|
||||
@@ -227,12 +282,24 @@ export async function fetchReleaseMetadata(
|
||||
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
|
||||
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
|
||||
}
|
||||
assets.push({ name, url: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
||||
assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
||||
}
|
||||
const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html);
|
||||
const releaseName = releaseNameText(item.name ?? item.releaseName);
|
||||
let releaseUrl: string | undefined;
|
||||
if (typeof item.html_url === "string" || typeof item.url === "string") {
|
||||
try {
|
||||
const candidate = typeof item.html_url === "string" ? item.html_url : item.url as string;
|
||||
releaseUrl = releaseResourceUrl(candidate, current, options);
|
||||
} catch { /* omit invalid optional release page URL */ }
|
||||
}
|
||||
return {
|
||||
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
|
||||
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
|
||||
...(releaseName ? { releaseName } : {}),
|
||||
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(releaseUrl ? { releaseUrl } : {}),
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user