diff --git a/convex/lib/skillStats.ts b/convex/lib/skillStats.ts index f5101463..a48405d1 100644 --- a/convex/lib/skillStats.ts +++ b/convex/lib/skillStats.ts @@ -9,7 +9,7 @@ type SkillStatDeltas = { installsAllTime?: number; }; -type SkillStatReadable = { +export type SkillStatReadable = { stats: Partial< Pick["stats"], "downloads" | "stars" | "installsCurrent" | "installsAllTime"> >; diff --git a/proof/org-og-profile-images/expediagroup-og-browser.png b/proof/org-og-profile-images/expediagroup-og-browser.png new file mode 100644 index 00000000..95622430 Binary files /dev/null and b/proof/org-og-profile-images/expediagroup-og-browser.png differ diff --git a/proof/org-og-profile-images/expediagroup-og.png b/proof/org-og-profile-images/expediagroup-og.png new file mode 100644 index 00000000..52cbe2df Binary files /dev/null and b/proof/org-og-profile-images/expediagroup-og.png differ diff --git a/proof/org-og-profile-images/expediagroup-profile.png b/proof/org-og-profile-images/expediagroup-profile.png new file mode 100644 index 00000000..307c027d Binary files /dev/null and b/proof/org-og-profile-images/expediagroup-profile.png differ diff --git a/proof/org-og-profile-images/nvidia-og-browser.png b/proof/org-og-profile-images/nvidia-og-browser.png new file mode 100644 index 00000000..0d18ffca Binary files /dev/null and b/proof/org-og-profile-images/nvidia-og-browser.png differ diff --git a/proof/org-og-profile-images/nvidia-og.png b/proof/org-og-profile-images/nvidia-og.png new file mode 100644 index 00000000..62a81ea8 Binary files /dev/null and b/proof/org-og-profile-images/nvidia-og.png differ diff --git a/proof/org-og-profile-images/nvidia-profile.png b/proof/org-og-profile-images/nvidia-profile.png new file mode 100644 index 00000000..5be5b229 Binary files /dev/null and b/proof/org-og-profile-images/nvidia-profile.png differ diff --git a/proof/org-og-profile-images/openclaw-og-browser.png b/proof/org-og-profile-images/openclaw-og-browser.png new file mode 100644 index 00000000..2e212b39 Binary files /dev/null and b/proof/org-og-profile-images/openclaw-og-browser.png differ diff --git a/proof/org-og-profile-images/openclaw-og.png b/proof/org-og-profile-images/openclaw-og.png new file mode 100644 index 00000000..42536964 Binary files /dev/null and b/proof/org-og-profile-images/openclaw-og.png differ diff --git a/proof/org-og-profile-images/openclaw-profile.png b/proof/org-og-profile-images/openclaw-profile.png new file mode 100644 index 00000000..e9eee74c Binary files /dev/null and b/proof/org-og-profile-images/openclaw-profile.png differ diff --git a/proof/org-og-profile-images/pr-visual-proof.md b/proof/org-og-profile-images/pr-visual-proof.md new file mode 100644 index 00000000..6b92156c --- /dev/null +++ b/proof/org-og-profile-images/pr-visual-proof.md @@ -0,0 +1,17 @@ +## Visual proof + +Publisher OG images captured from local ClawHub at `http://localhost:3000/og/profile` against production Convex data. + +Each card shows the org logo (or default lobster mark) and compact downloads (e.g. `43.5k`) with a muted **Downloads** label — not Installs. + +### @nvidia (org with custom profile image + compact downloads) + +![NVIDIA publisher OG image](nvidia-og.png) + +### @openclaw (org with GitHub avatar + compact downloads) + +![OpenClaw publisher OG image](openclaw-og.png) + +### @expediagroup (org without profile image — default mark + compact downloads) + +![Expedia Group publisher OG image](expediagroup-og.png) diff --git a/server/og/fetchImageDataUrl.test.ts b/server/og/fetchImageDataUrl.test.ts index a18aa4af..d015f267 100644 --- a/server/og/fetchImageDataUrl.test.ts +++ b/server/og/fetchImageDataUrl.test.ts @@ -1,7 +1,11 @@ /* @vitest-environment node */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { fetchImageDataUrl, isTrustedOgImageUrl } from "./fetchImageDataUrl"; +import { + fetchImageDataUrl, + isSafePublicHttpsOgImageUrl, + isTrustedOgImageUrl, +} from "./fetchImageDataUrl"; describe("fetchImageDataUrl", () => { afterEach(() => { @@ -17,6 +21,18 @@ describe("fetchImageDataUrl", () => { expect(isTrustedOgImageUrl("https://example.com/avatar.png")).toBe(false); }); + it("allows public https org profile images on domain names", () => { + expect( + isSafePublicHttpsOgImageUrl("https://iprsoftwaremedia.com/219/files/202512/nvidia-logo.png"), + ).toBe(true); + expect(isSafePublicHttpsOgImageUrl("https://avatars.githubusercontent.com/u/1?v=4")).toBe(true); + expect(isSafePublicHttpsOgImageUrl("http://example.com/logo.png")).toBe(false); + expect(isSafePublicHttpsOgImageUrl("https://127.0.0.1/logo.png")).toBe(false); + expect(isSafePublicHttpsOgImageUrl("https://localhost/logo.png")).toBe(false); + expect(isSafePublicHttpsOgImageUrl("https://metadata.google.internal/logo.png")).toBe(false); + expect(isSafePublicHttpsOgImageUrl("https://192.168.0.10/logo.png")).toBe(false); + }); + it("does not fetch untrusted image URLs", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -26,6 +42,22 @@ describe("fetchImageDataUrl", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("fetches public https org profile images", async () => { + const fetchMock = vi.fn(async () => { + return new Response(new Uint8Array([1, 2]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchImageDataUrl("https://cdn.example.com/org-logo.png", { + allowPublicHttps: true, + }), + ).resolves.toBe("data:image/png;base64,AQI="); + }); + it("converts trusted image responses to data URLs", async () => { const fetchMock = vi.fn(async () => { return new Response(new Uint8Array([1, 2]), { diff --git a/server/og/fetchImageDataUrl.ts b/server/og/fetchImageDataUrl.ts index de697fc8..ea9c4adc 100644 --- a/server/og/fetchImageDataUrl.ts +++ b/server/og/fetchImageDataUrl.ts @@ -1,5 +1,6 @@ const MAX_IMAGE_BYTES = 1_500_000; const IMAGE_FETCH_TIMEOUT_MS = 1_500; +const MAX_IMAGE_REDIRECTS = 3; const TRUSTED_IMAGE_HOSTS = new Set([ "avatars.githubusercontent.com", "camo.githubusercontent.com", @@ -11,6 +12,17 @@ const TRUSTED_IMAGE_HOSTS = new Set([ "www.gravatar.com", ]); +const BLOCKED_IMAGE_HOSTNAMES = new Set([ + "localhost", + "metadata.google.internal", + "metadata.google", +]); + +type FetchImageDataUrlOptions = { + allowPublicHttps?: boolean; + followRedirects?: boolean; +}; + export function isTrustedOgImageUrl(url: string | null | undefined) { if (!url) return false; try { @@ -22,20 +34,46 @@ export function isTrustedOgImageUrl(url: string | null | undefined) { } } -export async function fetchImageDataUrl(url: string | null | undefined) { +export function isSafePublicHttpsOgImageUrl(url: string | null | undefined) { + if (!url) return false; + if (isTrustedOgImageUrl(url)) return true; + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + if (parsed.username || parsed.password) return false; + const hostname = parsed.hostname.toLowerCase().replace(/\.$/, ""); + if (BLOCKED_IMAGE_HOSTNAMES.has(hostname)) return false; + if ( + hostname.endsWith(".localhost") || + hostname.endsWith(".local") || + hostname.endsWith(".internal") + ) { + return false; + } + if (isPrivateIpv4(hostname) || isPrivateIpv6(hostname)) return false; + return !isIpv4Hostname(hostname) && !hostname.includes(":"); + } catch { + return false; + } +} + +function isAllowedOgImageUrl(url: string, options: FetchImageDataUrlOptions) { + return options.allowPublicHttps ? isSafePublicHttpsOgImageUrl(url) : isTrustedOgImageUrl(url); +} + +export async function fetchImageDataUrl( + url: string | null | undefined, + options: FetchImageDataUrlOptions = {}, +) { if (!url) return null; try { const parsed = new URL(url); - if (!isTrustedOgImageUrl(parsed.toString())) return null; + if (!isAllowedOgImageUrl(parsed.toString(), options)) return null; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS); try { - const response = await fetch(parsed, { - headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/*" }, - redirect: "manual", - signal: controller.signal, - }); - if (!response.ok) return null; + const response = await fetchOgImageResponse(parsed, controller.signal, options); + if (!response?.ok) return null; const contentType = response.headers.get("content-type")?.split(";")[0]?.trim(); if (!contentType?.startsWith("image/")) return null; const buffer = await readLimitedImageBody(response); @@ -49,6 +87,72 @@ export async function fetchImageDataUrl(url: string | null | undefined) { } } +export async function fetchPublisherProfileImageDataUrl(url: string | null | undefined) { + return fetchImageDataUrl(url, { allowPublicHttps: true, followRedirects: true }); +} + +async function fetchOgImageResponse( + initialUrl: URL, + signal: AbortSignal, + options: FetchImageDataUrlOptions, +) { + let currentUrl = initialUrl; + for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop += 1) { + const response = await fetch(currentUrl, { + headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/*" }, + redirect: "manual", + signal, + }); + if ( + options.followRedirects && + response.status >= 300 && + response.status < 400 && + hop < MAX_IMAGE_REDIRECTS + ) { + const location = response.headers.get("location")?.trim(); + if (!location) return null; + const nextUrl = new URL(location, currentUrl); + if (!isAllowedOgImageUrl(nextUrl.toString(), options)) return null; + currentUrl = nextUrl; + continue; + } + return response; + } + return null; +} + +function isIpv4Hostname(hostname: string) { + return parseIpv4(hostname) !== null; +} + +function parseIpv4(hostname: string) { + const parts = hostname.split("."); + if (parts.length !== 4) return null; + const nums = parts.map((part) => Number.parseInt(part, 10)); + if (nums.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) return null; + return nums; +} + +function isPrivateIpv4(hostname: string) { + const nums = parseIpv4(hostname); + if (!nums) return false; + const [a, b] = nums; + if (a === 10 || a === 127 || a === 0) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +} + +function isPrivateIpv6(hostname: string) { + const lower = hostname.toLowerCase(); + if (lower === "::1") return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + if (lower.startsWith("fe80")) return true; + return false; +} + async function readLimitedImageBody(response: Response) { const contentLength = response.headers.get("content-length"); if (contentLength) { diff --git a/server/og/fetchPluginOgMeta.test.ts b/server/og/fetchPluginOgMeta.test.ts index 2f233ece..a9c128b8 100644 --- a/server/og/fetchPluginOgMeta.test.ts +++ b/server/og/fetchPluginOgMeta.test.ts @@ -8,7 +8,7 @@ describe("fetchPluginOgMeta", () => { vi.unstubAllGlobals(); }); - it("reads installs from package API stats", async () => { + it("reads downloads from package API stats", async () => { const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ @@ -31,6 +31,6 @@ describe("fetchPluginOgMeta", () => { "https://clawhub.ai/api/v1/packages/%40openclaw%2Fcodex", { headers: { Accept: "application/json" } }, ); - expect(meta?.stats.installs).toBe(1200); + expect(meta?.stats.downloads).toBe(99); }); }); diff --git a/server/og/fetchPluginOgMeta.ts b/server/og/fetchPluginOgMeta.ts index 521fce5d..bbe5baa9 100644 --- a/server/og/fetchPluginOgMeta.ts +++ b/server/og/fetchPluginOgMeta.ts @@ -6,7 +6,7 @@ export type PluginOgMeta = { ownerImage: string | null; latestVersion: string | null; stats: { - installs: number; + downloads: number; }; verification: { scanStatus: string | null; @@ -41,7 +41,7 @@ export async function fetchPluginOgMeta( ownerImage: payload.owner?.image ?? null, latestVersion: payload.package?.latestVersion ?? null, stats: { - installs: readNumber(stats.installs), + downloads: readNumber(stats.downloads), }, verification: payload.package?.verification ? { scanStatus: payload.package.verification.scanStatus ?? null } diff --git a/server/og/fetchPublisherOgMeta.test.ts b/server/og/fetchPublisherOgMeta.test.ts index 2f46e76d..46241deb 100644 --- a/server/og/fetchPublisherOgMeta.test.ts +++ b/server/og/fetchPublisherOgMeta.test.ts @@ -29,7 +29,7 @@ describe("fetchPublisherOgMeta", () => { vi.resetModules(); }); - it("reads installs from publisher profile stats", async () => { + it("reads downloads from publisher profile stats", async () => { queryMock.mockResolvedValue({ handle: "openclaw", kind: "org", @@ -46,6 +46,6 @@ describe("fetchPublisherOgMeta", () => { expect(queryMock).toHaveBeenCalledWith("publishers.getProfileByHandle", { handle: "openclaw", }); - expect(meta?.stats.installs).toBe(1200); + expect(meta?.stats.downloads).toBe(99); }); }); diff --git a/server/og/fetchPublisherOgMeta.ts b/server/og/fetchPublisherOgMeta.ts index 1bf65886..835c1788 100644 --- a/server/og/fetchPublisherOgMeta.ts +++ b/server/og/fetchPublisherOgMeta.ts @@ -8,7 +8,7 @@ export type PublisherOgMeta = { bio: string | null; image: string | null; stats: { - installs: number; + downloads: number; }; }; @@ -19,6 +19,7 @@ type PublisherProfileResult = { bio?: string | null; image?: string | null; stats?: { + downloads?: number; installs?: number; }; } | null; @@ -40,7 +41,7 @@ export async function fetchPublisherOgMeta( bio: profile.bio ?? null, image: profile.image ?? null, stats: { - installs: readNumber(profile.stats?.installs), + downloads: readNumber(profile.stats?.downloads), }, }; } catch { diff --git a/server/og/fetchSkillOgMeta.test.ts b/server/og/fetchSkillOgMeta.test.ts index 1b716ff5..c6f6bf26 100644 --- a/server/og/fetchSkillOgMeta.test.ts +++ b/server/og/fetchSkillOgMeta.test.ts @@ -8,7 +8,7 @@ describe("fetchSkillOgMeta", () => { vi.unstubAllGlobals(); }); - it("reads all-time installs from the public skill API stats", async () => { + it("reads downloads from the public skill API stats", async () => { const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ @@ -16,6 +16,7 @@ describe("fetchSkillOgMeta", () => { displayName: "Gifgrep", summary: "Search GIFs fast", stats: { downloads: 99, installsAllTime: 1200 }, + statsDownloads: 1200, }, owner: { handle: "steipete", image: "https://avatars.githubusercontent.com/u/1?v=4" }, latestVersion: { version: "1.0.1" }, @@ -32,6 +33,6 @@ describe("fetchSkillOgMeta", () => { headers: { Accept: "application/json" }, }, ); - expect(meta?.stats.installsAllTime).toBe(1200); + expect(meta?.stats.downloads).toBe(1200); }); }); diff --git a/server/og/fetchSkillOgMeta.ts b/server/og/fetchSkillOgMeta.ts index d6dff883..b999ed4f 100644 --- a/server/og/fetchSkillOgMeta.ts +++ b/server/og/fetchSkillOgMeta.ts @@ -1,3 +1,10 @@ +import { readCanonicalStat, type SkillStatReadable } from "../../convex/lib/skillStats"; + +type SkillApiPayload = SkillStatReadable & { + displayName?: string; + summary?: string | null; +}; + export type SkillOgMeta = { displayName: string | null; summary: string | null; @@ -5,7 +12,7 @@ export type SkillOgMeta = { ownerImage: string | null; version: string | null; stats: { - installsAllTime: number; + downloads: number; }; moderation: { verdict: "clean" | "suspicious" | "malicious" | null; @@ -26,7 +33,7 @@ export async function fetchSkillOgMeta( const response = await fetch(url.toString(), { headers: { Accept: "application/json" } }); if (!response.ok) return null; const payload = (await response.json()) as { - skill?: { displayName?: string; summary?: string | null; stats?: unknown } | null; + skill?: SkillApiPayload | null; owner?: { handle?: string | null; image?: string | null } | null; latestVersion?: { version?: string | null } | null; moderation?: { @@ -35,7 +42,6 @@ export async function fetchSkillOgMeta( isMalwareBlocked?: boolean; } | null; }; - const stats = readStats(payload.skill?.stats); return { displayName: payload.skill?.displayName ?? null, summary: payload.skill?.summary ?? null, @@ -43,7 +49,7 @@ export async function fetchSkillOgMeta( ownerImage: payload.owner?.image ?? null, version: payload.latestVersion?.version ?? null, stats: { - installsAllTime: readNumber(stats.installsAllTime), + downloads: payload.skill ? readCanonicalStat(payload.skill, "downloads") : 0, }, moderation: payload.moderation ? { @@ -57,11 +63,3 @@ export async function fetchSkillOgMeta( return null; } } - -function readStats(value: unknown): Record { - return value && typeof value === "object" ? (value as Record) : {}; -} - -function readNumber(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} diff --git a/server/og/formatOgStats.test.ts b/server/og/formatOgStats.test.ts new file mode 100644 index 00000000..3942fc30 --- /dev/null +++ b/server/og/formatOgStats.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { formatOgStat, readOgDownloadsQuery, resolveOgDownloadsDisplay } from "./formatOgStats"; + +describe("formatOgStat", () => { + it("formats large counts with compact k suffix", () => { + expect(formatOgStat(43_456)).toBe("43.5k"); + expect(formatOgStat(282_345)).toBe("282k"); + }); + + it("formats millions with compact M suffix", () => { + expect(formatOgStat(2_360_000)).toBe("2.4M"); + }); +}); + +describe("readOgDownloadsQuery", () => { + it("prefers downloads over legacy installs", () => { + expect(readOgDownloadsQuery({ downloads: "1200", installs: "9.9k" })).toBe("1200"); + }); + + it("falls back to installs when downloads is missing", () => { + expect(readOgDownloadsQuery({ installs: "9.9k" })).toBe("9.9k"); + }); +}); + +describe("resolveOgDownloadsDisplay", () => { + it("formats raw integer query params", () => { + expect(resolveOgDownloadsDisplay({ downloads: "43456" })).toBe("43.5k"); + expect(resolveOgDownloadsDisplay({ downloads: "282345" })).toBe("282k"); + }); + + it("formats metadata fallback values", () => { + expect(resolveOgDownloadsDisplay({}, 1200)).toBe("1.2k"); + }); + + it("passes through already-compact query values", () => { + expect(resolveOgDownloadsDisplay({ downloads: "43.5k" })).toBe("43.5k"); + }); +}); diff --git a/server/og/formatOgStats.ts b/server/og/formatOgStats.ts index 797e4af3..b9b313ba 100644 --- a/server/og/formatOgStats.ts +++ b/server/og/formatOgStats.ts @@ -1,7 +1,44 @@ +import { formatCompactStat } from "../../src/lib/numberFormat"; + +function cleanQueryString(value: unknown) { + if (typeof value !== "string") return ""; + return value.trim(); +} + +export function readOgDownloadsQuery(query: { downloads?: unknown; installs?: unknown }) { + const downloads = cleanQueryString(query.downloads); + if (downloads) return downloads; + return cleanQueryString(query.installs); +} + export function formatOgStat(value: number | null | undefined) { const numericValue = typeof value === "number" && Number.isFinite(value) ? value : 0; - const abs = Math.abs(numericValue); - if (abs >= 1_000_000) return `${(numericValue / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}m`; - if (abs >= 1_000) return `${(numericValue / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`; - return String(numericValue); + return formatCompactStat(numericValue); +} + +function parseRawOgStatValue(raw: string) { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (/^-?\d+$/.test(trimmed)) { + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : null; + } + const compact = /^(-?\d+(?:\.\d+)?)([kKmM])$/.exec(trimmed); + if (!compact) return null; + const base = Number.parseFloat(compact[1]); + if (!Number.isFinite(base)) return null; + return compact[2].toLowerCase() === "k" ? Math.round(base * 1_000) : Math.round(base * 1_000_000); +} + +export function resolveOgDownloadsDisplay( + query: { downloads?: unknown; installs?: unknown }, + fallback?: number | null, +) { + const raw = readOgDownloadsQuery(query); + if (raw) { + const numeric = parseRawOgStatValue(raw); + if (numeric !== null) return formatOgStat(numeric); + return raw; + } + return formatOgStat(fallback); } diff --git a/server/og/publisherOgSvg.ts b/server/og/publisherOgSvg.ts index e825e0d5..efde4af6 100644 --- a/server/og/publisherOgSvg.ts +++ b/server/og/publisherOgSvg.ts @@ -1,5 +1,11 @@ import { FONT_SANS } from "./ogAssets"; -import { escapeXml, OPENCLAW_RED, type RegistryOgStat, wrapText } from "./registryOgSvg"; +import { + escapeXml, + OPENCLAW_RED, + type RegistryOgStat, + statLabelMarkup, + wrapText, +} from "./registryOgSvg"; export type PublisherOgSvgParams = { markDataUrl: string; @@ -15,11 +21,7 @@ export type PublisherOgSvgParams = { function statBlock(stats: RegistryOgStat[] | undefined, x: number, y: number) { const stat = stats?.[0] ?? { value: "ClawHub", label: "Publisher" }; return ` - ${escapeXml(stat.label)} + ${statLabelMarkup(x, y, stat.label, { fontSize: 22 })} ${escapeXml(label)}`; +} + export type RegistryOgCommand = { subject: string; action: string; @@ -152,11 +170,7 @@ function statColumns(stats: RegistryOgStat[], contentX: number) { .map((stat, index) => { const x = contentX + index * 190; return ` - ${escapeXml(stat.label)} + ${statLabelMarkup(x, 424, stat.label)} { target: "discord-doctor", }, stats: [ - { value: "1.2k", label: "Installs" }, + { value: "1.2k", label: "Downloads" }, { value: "PASS", label: "Audit" }, ], }); diff --git a/server/routes/og/plugin.png.test.ts b/server/routes/og/plugin.png.test.ts index 78bc2db4..28bb6b6d 100644 --- a/server/routes/og/plugin.png.test.ts +++ b/server/routes/og/plugin.png.test.ts @@ -111,7 +111,7 @@ describe("plugin og route", () => { latestVersion: null, displayName: "Codex", summary: "OpenClaw Codex harness.", - stats: { installs: 1200 }, + stats: { downloads: 1200 }, verification: { scanStatus: "pending" }, }); @@ -126,7 +126,7 @@ describe("plugin og route", () => { expect(buildPluginOgSvgMock).toHaveBeenCalledWith( expect.objectContaining({ stats: [ - { value: "1.2k", label: "Installs" }, + { value: "1.2k", label: "Downloads" }, { value: "PENDING", label: "Audit" }, ], }), @@ -142,7 +142,7 @@ describe("plugin og route", () => { latestVersion: "1.0.0", displayName: "Codex", summary: "OpenClaw Codex harness.", - stats: { installs: 1200 }, + stats: { downloads: 1200 }, verification: { scanStatus: "clean" }, }); @@ -153,7 +153,7 @@ describe("plugin og route", () => { expect(buildPluginOgSvgMock).toHaveBeenCalledWith( expect.objectContaining({ stats: [ - { value: "1.2k", label: "Installs" }, + { value: "1.2k", label: "Downloads" }, { value: "PASS", label: "Audit" }, ], }), @@ -166,7 +166,7 @@ describe("plugin og route", () => { owner: "openclaw", title: "Codex", description: "OpenClaw Codex harness.", - installs: "0", + downloads: "0", }); const handler = (await import("./plugin.png")).default; @@ -176,21 +176,21 @@ describe("plugin og route", () => { expect(buildPluginOgSvgMock).toHaveBeenCalledWith( expect.objectContaining({ stats: [ - { value: "0", label: "Installs" }, + { value: "0", label: "Downloads" }, { value: "UNKNOWN", label: "Audit" }, ], }), ); }); - it("uses explicit installs over legacy downloads query params", async () => { + it("uses explicit downloads over legacy installs query params", async () => { getQueryMock.mockReturnValue({ name: "@openclaw/codex", owner: "openclaw", title: "Codex", description: "OpenClaw Codex harness.", - installs: "0", - downloads: "9.9k", + downloads: "0", + installs: "9.9k", }); const handler = (await import("./plugin.png")).default; @@ -200,7 +200,7 @@ describe("plugin og route", () => { expect(buildPluginOgSvgMock).toHaveBeenCalledWith( expect.objectContaining({ stats: [ - { value: "0", label: "Installs" }, + { value: "0", label: "Downloads" }, { value: "UNKNOWN", label: "Audit" }, ], }), diff --git a/server/routes/og/plugin.png.ts b/server/routes/og/plugin.png.ts index 2ce4f297..3b9255cb 100644 --- a/server/routes/og/plugin.png.ts +++ b/server/routes/og/plugin.png.ts @@ -2,7 +2,7 @@ import { Resvg } from "@resvg/resvg-wasm"; import { defineEventHandler, getQuery, getRequestHost, setHeader } from "h3"; import { fetchImageDataUrl } from "../../og/fetchImageDataUrl"; import { fetchPluginOgMeta } from "../../og/fetchPluginOgMeta"; -import { formatOgStat } from "../../og/formatOgStats"; +import { resolveOgDownloadsDisplay } from "../../og/formatOgStats"; import { ensureResvgWasm, FONT_MONO, @@ -13,12 +13,14 @@ import { } from "../../og/ogAssets"; import { buildPluginOgSvg } from "../../og/pluginOgSvg"; import { pngResponse } from "../../og/pngResponse"; +import { buildOgDownloadsStat } from "../../og/registryOgSvg"; type OgQuery = { name?: string; owner?: string; title?: string; description?: string; + downloads?: string; installs?: string; audit?: string; avatar?: string; @@ -63,7 +65,6 @@ export default defineEventHandler(async (event) => { const ownerFromQuery = cleanString(query.owner); const titleFromQuery = cleanString(query.title); const descriptionFromQuery = cleanString(query.description); - const installsFromQuery = cleanString(query.installs); const auditFromQuery = cleanString(query.audit); const avatarFromQuery = cleanString(query.avatar); const needFetch = !ownerFromQuery || !titleFromQuery || !descriptionFromQuery; @@ -99,10 +100,7 @@ export default defineEventHandler(async (event) => { target: `clawhub:${packageName}`, }, stats: [ - { - value: installsFromQuery || formatOgStat(meta?.stats.installs), - label: "Installs", - }, + buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads)), { value: (auditFromQuery || getAuditLabel(meta?.verification?.scanStatus)).replace( /^Audit\s+/i, diff --git a/server/routes/og/profile.png.ts b/server/routes/og/profile.png.ts index 4639d668..51eccd6e 100644 --- a/server/routes/og/profile.png.ts +++ b/server/routes/og/profile.png.ts @@ -1,8 +1,8 @@ import { Resvg } from "@resvg/resvg-wasm"; import { defineEventHandler, getQuery, setHeader } from "h3"; -import { fetchImageDataUrl } from "../../og/fetchImageDataUrl"; +import { fetchPublisherProfileImageDataUrl } from "../../og/fetchImageDataUrl"; import { fetchPublisherOgMeta } from "../../og/fetchPublisherOgMeta"; -import { formatOgStat } from "../../og/formatOgStats"; +import { readOgDownloadsQuery, resolveOgDownloadsDisplay } from "../../og/formatOgStats"; import { ensureResvgWasm, FONT_MONO, @@ -13,11 +13,13 @@ import { } from "../../og/ogAssets"; import { pngResponse } from "../../og/pngResponse"; import { buildPublisherOgSvg } from "../../og/publisherOgSvg"; +import { buildOgDownloadsStat } from "../../og/registryOgSvg"; type OgQuery = { handle?: string; title?: string; description?: string; + downloads?: string; installs?: string; kind?: string; avatar?: string; @@ -43,11 +45,11 @@ export default defineEventHandler(async (event) => { const titleFromQuery = cleanString(query.title); const descriptionFromQuery = cleanString(query.description); - const installsFromQuery = cleanString(query.installs); const kindFromQuery = cleanString(query.kind); const avatarFromQuery = cleanString(query.avatar); const convexUrl = getConvexUrl(); - const needFetch = !titleFromQuery || !descriptionFromQuery || !installsFromQuery; + const needFetch = + !titleFromQuery || !descriptionFromQuery || !readOgDownloadsQuery(query) || !avatarFromQuery; const meta = needFetch && convexUrl ? await fetchPublisherOgMeta(handle, convexUrl) : null; const handleLabel = `@${meta?.handle || handle}`; const title = titleFromQuery || meta?.displayName || handleLabel; @@ -58,7 +60,7 @@ export default defineEventHandler(async (event) => { getWatermarkDataUrl(), ensureResvgWasm().then(() => getFontBuffers()), ]); - const avatarDataUrl = await fetchImageDataUrl(avatarFromQuery || meta?.image); + const avatarDataUrl = await fetchPublisherProfileImageDataUrl(avatarFromQuery || meta?.image); const svg = buildPublisherOgSvg({ markDataUrl, @@ -68,12 +70,7 @@ export default defineEventHandler(async (event) => { title, description, handleLabel, - stats: [ - { - value: installsFromQuery || formatOgStat(meta?.stats.installs), - label: "Installs", - }, - ], + stats: [buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads))], }); const resvg = new Resvg(svg, { diff --git a/server/routes/og/skill.png.test.ts b/server/routes/og/skill.png.test.ts index 7d410426..5dfde039 100644 --- a/server/routes/og/skill.png.test.ts +++ b/server/routes/og/skill.png.test.ts @@ -108,7 +108,7 @@ describe("skill og route", () => { version: "1.0.1", title: "Gifgrep", description: "Search GIFs fast", - installs: "0", + downloads: "0", }); const handler = (await import("./skill.png")).default; @@ -132,7 +132,7 @@ describe("skill og route", () => { target: "gifgrep", }, stats: [ - { value: "0", label: "Installs" }, + { value: "0", label: "Downloads" }, { value: "PASS", label: "Audit" }, ], }); @@ -157,7 +157,7 @@ describe("skill og route", () => { displayName: "Gifgrep", summary: "Search GIFs fast", ownerImage: null, - stats: { installsAllTime: 1200 }, + stats: { downloads: 1200 }, moderation: { verdict: "clean", isSuspicious: false, isMalwareBlocked: false }, }); @@ -177,22 +177,22 @@ describe("skill og route", () => { ownerLabel: "@steipete", versionLabel: "latest", stats: [ - { value: "1.2k", label: "Installs" }, + { value: "1.2k", label: "Downloads" }, { value: "PASS", label: "Audit" }, ], }), ); }); - it("uses explicit installs over legacy downloads query params", async () => { + it("uses explicit downloads over legacy installs query params", async () => { getQueryMock.mockReturnValue({ slug: "gifgrep", owner: "steipete", version: "1.0.1", title: "Gifgrep", description: "Search GIFs fast", - installs: "0", - downloads: "9.9k", + downloads: "0", + installs: "9.9k", }); const handler = (await import("./skill.png")).default; @@ -202,7 +202,30 @@ describe("skill og route", () => { expect(buildSkillOgSvgMock).toHaveBeenCalledWith( expect.objectContaining({ stats: [ - { value: "0", label: "Installs" }, + { value: "0", label: "Downloads" }, + { value: "PASS", label: "Audit" }, + ], + }), + ); + }); + + it("formats raw integer downloads query params", async () => { + getQueryMock.mockReturnValue({ + slug: "gifgrep", + owner: "steipete", + version: "1.0.1", + title: "Gifgrep", + description: "Search GIFs fast", + downloads: "43456", + }); + + const handler = (await import("./skill.png")).default; + await handler({} as never); + + expect(buildSkillOgSvgMock).toHaveBeenCalledWith( + expect.objectContaining({ + stats: [ + { value: "43.5k", label: "Downloads" }, { value: "PASS", label: "Audit" }, ], }), diff --git a/server/routes/og/skill.png.ts b/server/routes/og/skill.png.ts index 598419a5..4763a351 100644 --- a/server/routes/og/skill.png.ts +++ b/server/routes/og/skill.png.ts @@ -2,7 +2,7 @@ import { Resvg } from "@resvg/resvg-wasm"; import { defineEventHandler, getQuery, getRequestHost, setHeader } from "h3"; import { fetchImageDataUrl } from "../../og/fetchImageDataUrl"; import { fetchSkillOgMeta } from "../../og/fetchSkillOgMeta"; -import { formatOgStat } from "../../og/formatOgStats"; +import { resolveOgDownloadsDisplay } from "../../og/formatOgStats"; import { ensureResvgWasm, FONT_MONO, @@ -12,6 +12,7 @@ import { getWatermarkDataUrl, } from "../../og/ogAssets"; import { pngResponse } from "../../og/pngResponse"; +import { buildOgDownloadsStat } from "../../og/registryOgSvg"; import { buildSkillOgSvg } from "../../og/skillOgSvg"; type OgQuery = { @@ -20,6 +21,7 @@ type OgQuery = { version?: string; title?: string; description?: string; + downloads?: string; installs?: string; audit?: string; avatar?: string; @@ -54,7 +56,6 @@ export default defineEventHandler(async (event) => { const versionFromQuery = cleanString(query.version); const titleFromQuery = cleanString(query.title); const descriptionFromQuery = cleanString(query.description); - const installsFromQuery = cleanString(query.installs); const auditFromQuery = cleanString(query.audit); const avatarFromQuery = cleanString(query.avatar); @@ -101,10 +102,7 @@ export default defineEventHandler(async (event) => { target: slug, }, stats: [ - { - value: installsFromQuery || formatOgStat(meta?.stats.installsAllTime), - label: "Installs", - }, + buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads)), { value: auditLabel.replace(/^Audit\s+/i, ""), label: "Audit" }, ], }); diff --git a/src/__tests__/skill-route-loader.test.ts b/src/__tests__/skill-route-loader.test.ts index 35981725..7a6ec677 100644 --- a/src/__tests__/skill-route-loader.test.ts +++ b/src/__tests__/skill-route-loader.test.ts @@ -337,11 +337,11 @@ describe("skill route loader", () => { { property: "og:url", content: "https://clawhub.ai/steipete/skills/weather" }, { property: "og:image", - content: "https://clawhub.ai/og/skill?v=8&slug=weather&owner=steipete&version=1.0.0", + content: "https://clawhub.ai/og/skill?v=10&slug=weather&owner=steipete&version=1.0.0", }, { name: "twitter:image", - content: "https://clawhub.ai/og/skill?v=8&slug=weather&owner=steipete&version=1.0.0", + content: "https://clawhub.ai/og/skill?v=10&slug=weather&owner=steipete&version=1.0.0", }, ]), ); diff --git a/src/lib/og.test.ts b/src/lib/og.test.ts index a9d131cb..4170cf8d 100644 --- a/src/lib/og.test.ts +++ b/src/lib/og.test.ts @@ -19,7 +19,7 @@ describe("og helpers", () => { expect(meta.url).toContain("/steipete/skills/weather"); expect(meta.owner).toBe("steipete"); expect(meta.image).toContain("/og/skill?"); - expect(meta.image).toContain("v=8"); + expect(meta.image).toContain("v=10"); expect(meta.image).toContain("slug=weather"); expect(meta.image).toContain("owner=steipete"); expect(meta.image).toContain("version=1.2.3"); @@ -39,7 +39,7 @@ describe("og helpers", () => { expect(meta.description).toBe("OpenClaw Codex harness."); expect(meta.url).toBe("https://clawhub.ai/openclaw/plugins/codex"); expect(meta.image).toContain("/og/plugin?"); - expect(meta.image).toContain("v=3"); + expect(meta.image).toContain("v=5"); expect(meta.image).toContain("name=%40openclaw%2Fcodex"); expect(meta.image).toContain("version=1.0.0"); }); @@ -49,13 +49,21 @@ describe("og helpers", () => { handle: "@byungkyu", displayName: "byungkyu", bio: "maton.ai", + image: "https://example.com/logo.png", + kind: "org", + downloads: 1200, }); expect(meta.title).toBe("byungkyu — ClawHub"); expect(meta.description).toBe("maton.ai"); expect(meta.url).toBe("https://clawhub.ai/byungkyu"); expect(meta.image).toContain("/og/profile?"); - expect(meta.image).toContain("v=3"); + expect(meta.image).toContain("v=7"); expect(meta.image).toContain("handle=byungkyu"); + expect(meta.image).toContain("title=byungkyu"); + expect(meta.image).toContain("description=maton.ai"); + expect(meta.image).toContain("kind=org"); + expect(meta.image).toContain("avatar=https%3A%2F%2Fexample.com%2Flogo.png"); + expect(meta.image).toContain("downloads=1200"); }); it("uses defaults when owner and summary are missing", () => { diff --git a/src/lib/og.ts b/src/lib/og.ts index 63b7e68a..00c45170 100644 --- a/src/lib/og.ts +++ b/src/lib/og.ts @@ -32,6 +32,9 @@ type PublisherMetaSource = { handle: string; displayName?: string | null; bio?: string | null; + image?: string | null; + kind?: "user" | "org"; + downloads?: number | null; }; type BasicMeta = { @@ -41,9 +44,9 @@ type BasicMeta = { url: string; }; -const OG_SKILL_IMAGE_LAYOUT_VERSION = "8"; -const OG_PLUGIN_IMAGE_LAYOUT_VERSION = "3"; -const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "3"; +const OG_SKILL_IMAGE_LAYOUT_VERSION = "10"; +const OG_PLUGIN_IMAGE_LAYOUT_VERSION = "5"; +const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "7"; function getSiteUrl() { return getClawHubSiteUrl(); @@ -129,11 +132,19 @@ export function buildPublisherMeta(source: PublisherMetaSource): BasicMeta { const handle = clean(source.handle).replace(/^@+/, ""); const displayName = clean(source.displayName) || `@${handle}`; const bio = clean(source.bio); + const image = clean(source.image); const title = `${displayName} — ClawHub`; const description = bio || `Publisher @${handle} on ClawHub.`; const imageParams = new URLSearchParams(); imageParams.set("v", OG_PUBLISHER_IMAGE_LAYOUT_VERSION); imageParams.set("handle", handle); + imageParams.set("title", displayName); + imageParams.set("description", truncate(description, 200)); + if (source.kind === "org") imageParams.set("kind", "org"); + if (image) imageParams.set("avatar", image); + if (typeof source.downloads === "number" && Number.isFinite(source.downloads)) { + imageParams.set("downloads", String(Math.max(0, Math.trunc(source.downloads)))); + } return { title, description: truncate(description, 200), diff --git a/src/routes/$slug.tsx b/src/routes/$slug.tsx index eb13b466..54092c86 100644 --- a/src/routes/$slug.tsx +++ b/src/routes/$slug.tsx @@ -19,6 +19,9 @@ export const Route = createFileRoute("/$slug")({ handle: publisher.handle ?? params.slug, displayName: publisher.displayName, bio: publisher.bio, + image: publisher.image, + kind: publisher.kind, + downloads: publisher.stats.downloads, }); return { meta: [ diff --git a/src/routes/user/$handle.tsx b/src/routes/user/$handle.tsx index c2ef14ad..5f8380bf 100644 --- a/src/routes/user/$handle.tsx +++ b/src/routes/user/$handle.tsx @@ -94,8 +94,16 @@ export const Route = createFileRoute("/user/$handle")({ if (!publisher) throw notFound(); return { publisher }; }, - head: ({ params }) => { - const meta = buildPublisherMeta({ handle: params.handle }); + head: ({ params, loaderData }) => { + const publisher = loaderData?.publisher; + const meta = buildPublisherMeta({ + handle: publisher?.handle ?? params.handle, + displayName: publisher?.displayName, + bio: publisher?.bio, + image: publisher?.image, + kind: publisher?.kind, + downloads: publisher?.stats.downloads, + }); return { meta: [ { title: meta.title },