mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix: harden publisher OG profile images
This commit is contained in:
Vendored
+6
-2
@@ -91,6 +91,7 @@ import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
|
||||
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publicBrowse from "../lib/publicBrowse.js";
|
||||
import type * as lib_publicRouteReservations from "../lib/publicRouteReservations.js";
|
||||
import type * as lib_publishLimits from "../lib/publishLimits.js";
|
||||
import type * as lib_publisherAbuseScoring from "../lib/publisherAbuseScoring.js";
|
||||
@@ -126,14 +127,15 @@ import type * as lib_tokens from "../lib/tokens.js";
|
||||
import type * as lib_userSearch from "../lib/userSearch.js";
|
||||
import type * as lib_userSkillStats from "../lib/userSkillStats.js";
|
||||
import type * as lib_webhooks from "../lib/webhooks.js";
|
||||
import type * as lib_workerTextRedaction from "../lib/workerTextRedaction.js";
|
||||
import type * as maintenance from "../maintenance.js";
|
||||
import type * as managementDevSeed from "../managementDevSeed.js";
|
||||
import type * as migrations from "../migrations.js";
|
||||
import type * as packageInspectorHttp from "../packageInspectorHttp.js";
|
||||
import type * as packageInspectorNode from "../packageInspectorNode.js";
|
||||
import type * as packageLeaderboards from "../packageLeaderboards.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as packageLeaderboards from "../packageLeaderboards.js";
|
||||
import type * as publisherAbuse from "../publisherAbuse.js";
|
||||
import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
@@ -246,6 +248,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
|
||||
"lib/packageSecurity": typeof lib_packageSecurity;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/publicBrowse": typeof lib_publicBrowse;
|
||||
"lib/publicRouteReservations": typeof lib_publicRouteReservations;
|
||||
"lib/publishLimits": typeof lib_publishLimits;
|
||||
"lib/publisherAbuseScoring": typeof lib_publisherAbuseScoring;
|
||||
@@ -281,14 +284,15 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/userSearch": typeof lib_userSearch;
|
||||
"lib/userSkillStats": typeof lib_userSkillStats;
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
"lib/workerTextRedaction": typeof lib_workerTextRedaction;
|
||||
maintenance: typeof maintenance;
|
||||
managementDevSeed: typeof managementDevSeed;
|
||||
migrations: typeof migrations;
|
||||
packageInspectorHttp: typeof packageInspectorHttp;
|
||||
packageInspectorNode: typeof packageInspectorNode;
|
||||
packageLeaderboards: typeof packageLeaderboards;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
packageLeaderboards: typeof packageLeaderboards;
|
||||
publisherAbuse: typeof publisherAbuse;
|
||||
publisherAbuseDevSeed: typeof publisherAbuseDevSeed;
|
||||
publishers: typeof publishers;
|
||||
|
||||
+316
-11
@@ -17,6 +17,7 @@ import {
|
||||
getProfileByHandle,
|
||||
createMemberInvite,
|
||||
declineMemberInvite,
|
||||
getOgMetaByHandle,
|
||||
listMembers,
|
||||
listPublishedPage,
|
||||
listStarredPage,
|
||||
@@ -303,6 +304,24 @@ const getProfileByHandleHandler = (
|
||||
getProfileByHandle as unknown as WrappedHandler<{ handle: string }>
|
||||
)._handler;
|
||||
|
||||
const getOgMetaByHandleHandler = (
|
||||
getOgMetaByHandle as unknown as WrappedHandler<
|
||||
{ handle: string },
|
||||
{
|
||||
displayName?: string | null;
|
||||
affiliations?: Array<{
|
||||
publisher?: {
|
||||
handle?: string | null;
|
||||
displayName?: string | null;
|
||||
image?: string | null;
|
||||
} | null;
|
||||
role?: string;
|
||||
}>;
|
||||
stats: { downloads: number; installs: number; stars: number };
|
||||
} | null
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getPublishedDisplayManifestHandler = (
|
||||
getPublishedDisplayManifest as unknown as WrappedHandler<
|
||||
{
|
||||
@@ -451,22 +470,30 @@ const resolvePublishTargetForUserInternalHandler = (
|
||||
)._handler;
|
||||
|
||||
function indexedRows(rows: unknown[]) {
|
||||
const paginate = vi.fn(
|
||||
async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
|
||||
const offset = cursor ? Number(cursor) : 0;
|
||||
const page = rows.slice(offset, offset + numItems);
|
||||
const nextOffset = offset + page.length;
|
||||
const isDone = nextOffset >= rows.length;
|
||||
return {
|
||||
page,
|
||||
isDone,
|
||||
continueCursor: isDone ? "" : String(nextOffset),
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const row of rows) yield row;
|
||||
},
|
||||
collect: vi.fn(async () => rows),
|
||||
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
||||
paginate,
|
||||
order: vi.fn(() => ({
|
||||
collect: vi.fn(async () => rows),
|
||||
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
||||
paginate: vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
|
||||
const offset = cursor ? Number(cursor) : 0;
|
||||
const page = rows.slice(offset, offset + numItems);
|
||||
const nextOffset = offset + page.length;
|
||||
const isDone = nextOffset >= rows.length;
|
||||
return {
|
||||
page,
|
||||
isDone,
|
||||
continueCursor: isDone ? "" : String(nextOffset),
|
||||
};
|
||||
}),
|
||||
paginate,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -2787,6 +2814,284 @@ describe("publishers membership controls", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes OG metadata stats when publisher denormalized stats are missing", async () => {
|
||||
const publisher = {
|
||||
_id: "publishers:openclaw",
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:demo",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
stats: { downloads: 42, stars: 2, installsCurrent: 4, installsAllTime: 7 },
|
||||
};
|
||||
const pkg = {
|
||||
_id: "packages:demo",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
stats: { downloads: 8, installs: 5, stars: 1 },
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
fields[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
buildQuery(q);
|
||||
if (table === "publishers" && indexName === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () => (fields.handle === "openclaw" ? publisher : null)),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows(fields.ownerPublisherId === publisher._id ? [skill] : []);
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows(fields.ownerPublisherId === publisher._id ? [pkg] : []);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getOgMetaByHandleHandler(ctx as never, { handle: "openclaw" });
|
||||
|
||||
expect(result?.stats).toEqual({
|
||||
skills: 1,
|
||||
packages: 1,
|
||||
installs: 12,
|
||||
downloads: 50,
|
||||
stars: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns verified visible organization affiliations for user OG metadata", async () => {
|
||||
const personalPublisher = {
|
||||
_id: "publishers:teoslayer",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
userId: "users:teoslayer",
|
||||
linkedUserId: "users:teoslayer",
|
||||
handle: "teoslayer",
|
||||
displayName: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
publishedSkills: 0,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 3,
|
||||
totalDownloads: 10,
|
||||
totalStars: 4,
|
||||
statsDownloads: 10,
|
||||
statsInstallsCurrent: 2,
|
||||
statsInstallsAllTime: 3,
|
||||
statsStars: 4,
|
||||
stats: { downloads: 10, installsCurrent: 2, installsAllTime: 3, stars: 4 },
|
||||
};
|
||||
const user = {
|
||||
_id: "users:teoslayer",
|
||||
handle: "teoslayer",
|
||||
displayName: "Calin Teodor",
|
||||
image: "https://example.com/avatar.png",
|
||||
bio: "Publisher @teoslayer on ClawHub.",
|
||||
};
|
||||
const openclawPublisher = {
|
||||
_id: "publishers:openclaw",
|
||||
_creationTime: 2,
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
image: "https://example.com/openclaw.png",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const deactivatedPublisher = {
|
||||
_id: "publishers:archived",
|
||||
_creationTime: 3,
|
||||
kind: "org",
|
||||
handle: "archived",
|
||||
displayName: "Archived",
|
||||
image: "https://example.com/archived.png",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
deactivatedAt: 2,
|
||||
};
|
||||
const irrelevantMemberships = Array.from({ length: 70 }, (_, index) => ({
|
||||
_id: `publisherMembers:missing-${index}`,
|
||||
publisherId: `publishers:missing-${index}`,
|
||||
userId: user._id,
|
||||
role: "publisher",
|
||||
}));
|
||||
const memberships = [
|
||||
{
|
||||
_id: "publisherMembers:self",
|
||||
publisherId: personalPublisher._id,
|
||||
userId: user._id,
|
||||
role: "owner",
|
||||
},
|
||||
...irrelevantMemberships,
|
||||
{
|
||||
_id: "publisherMembers:openclaw",
|
||||
publisherId: openclawPublisher._id,
|
||||
userId: user._id,
|
||||
role: "publisher",
|
||||
},
|
||||
{
|
||||
_id: "publisherMembers:archived",
|
||||
publisherId: deactivatedPublisher._id,
|
||||
userId: user._id,
|
||||
role: "publisher",
|
||||
},
|
||||
];
|
||||
const publishersById = new Map<string, unknown>([
|
||||
[personalPublisher._id, personalPublisher],
|
||||
[openclawPublisher._id, openclawPublisher],
|
||||
[deactivatedPublisher._id, deactivatedPublisher],
|
||||
]);
|
||||
const usersById = new Map<string, unknown>([[user._id, user]]);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => publishersById.get(id) ?? usersById.get(id) ?? null),
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
fields[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
buildQuery(q);
|
||||
if (table === "publishers" && indexName === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
fields.handle === personalPublisher.handle ? personalPublisher : null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (table === "publisherMembers" && indexName === "by_user") {
|
||||
return indexedRows(fields.userId === user._id ? memberships : []);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getOgMetaByHandleHandler(ctx as never, { handle: "teoslayer" });
|
||||
|
||||
expect(result?.displayName).toBe("Calin Teodor");
|
||||
expect(result?.affiliations).toEqual([
|
||||
{
|
||||
publisher: expect.objectContaining({
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
image: "https://example.com/openclaw.png",
|
||||
}),
|
||||
role: "publisher",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds raw membership rows scanned for user OG metadata", async () => {
|
||||
const personalPublisher = {
|
||||
_id: "publishers:teoslayer",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
linkedUserId: "users:teoslayer",
|
||||
handle: "teoslayer",
|
||||
displayName: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
publishedSkills: 0,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 3,
|
||||
totalDownloads: 10,
|
||||
totalStars: 4,
|
||||
statsDownloads: 10,
|
||||
statsInstallsCurrent: 2,
|
||||
statsInstallsAllTime: 3,
|
||||
statsStars: 4,
|
||||
stats: { downloads: 10, installsCurrent: 2, installsAllTime: 3, stars: 4 },
|
||||
};
|
||||
const user = {
|
||||
_id: "users:teoslayer",
|
||||
handle: "teoslayer",
|
||||
displayName: "Calin Teodor",
|
||||
image: "https://example.com/avatar.png",
|
||||
bio: "Publisher @teoslayer on ClawHub.",
|
||||
};
|
||||
const memberships = [
|
||||
{
|
||||
_id: "publisherMembers:self",
|
||||
publisherId: personalPublisher._id,
|
||||
userId: user._id,
|
||||
role: "owner",
|
||||
},
|
||||
...Array.from({ length: 600 }, (_, index) => ({
|
||||
_id: `publisherMembers:missing-${index}`,
|
||||
publisherId: `publishers:missing-${index}`,
|
||||
userId: user._id,
|
||||
role: "publisher",
|
||||
})),
|
||||
];
|
||||
const usersById = new Map<string, unknown>([[user._id, user]]);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => usersById.get(id) ?? null),
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
fields[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
buildQuery(q);
|
||||
if (table === "publishers" && indexName === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
fields.handle === personalPublisher.handle ? personalPublisher : null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (table === "publisherMembers" && indexName === "by_user") {
|
||||
return indexedRows(fields.userId === user._id ? memberships : []);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getOgMetaByHandleHandler(ctx as never, { handle: "teoslayer" });
|
||||
|
||||
expect(result?.affiliations).toEqual([]);
|
||||
expect(ctx.db.get).toHaveBeenCalledWith("publishers:missing-510");
|
||||
expect(ctx.db.get).not.toHaveBeenCalledWith("publishers:missing-511");
|
||||
});
|
||||
|
||||
it("excludes hidden and removed skills from publisher catalogs", async () => {
|
||||
const publisher = {
|
||||
_id: "publishers:nvidia",
|
||||
|
||||
@@ -57,6 +57,9 @@ const PUBLISHER_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const PUBLISHER_IMAGE_CONTENT_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
const PUBLISHER_INVITE_TTL_MS = 7 * 24 * 60 * 60_000;
|
||||
const MAX_PENDING_PUBLISHER_INVITES = 100;
|
||||
const PUBLISHER_OG_AFFILIATION_LIMIT = 5;
|
||||
const PUBLISHER_OG_MEMBERSHIP_PAGE_SIZE = 64;
|
||||
const PUBLISHER_OG_MEMBERSHIP_SCAN_LIMIT = 512;
|
||||
const publisherRoleValidator = v.union(
|
||||
v.literal("owner"),
|
||||
v.literal("admin"),
|
||||
@@ -797,6 +800,61 @@ async function getUserPublisherAffiliations(
|
||||
);
|
||||
}
|
||||
|
||||
async function getUserPublisherOgAffiliations(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
userId: Id<"users">,
|
||||
currentPublisherId: Id<"publishers">,
|
||||
) {
|
||||
const affiliations: Array<{
|
||||
publisher: NonNullable<ReturnType<typeof toPublicPublisher>>;
|
||||
role: Doc<"publisherMembers">["role"];
|
||||
}> = [];
|
||||
let cursor: string | null = null;
|
||||
let scannedMemberships = 0;
|
||||
|
||||
while (
|
||||
affiliations.length < PUBLISHER_OG_AFFILIATION_LIMIT &&
|
||||
scannedMemberships < PUBLISHER_OG_MEMBERSHIP_SCAN_LIMIT
|
||||
) {
|
||||
const page = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.paginate({
|
||||
cursor,
|
||||
numItems: Math.min(
|
||||
PUBLISHER_OG_MEMBERSHIP_PAGE_SIZE,
|
||||
PUBLISHER_OG_MEMBERSHIP_SCAN_LIMIT - scannedMemberships,
|
||||
),
|
||||
});
|
||||
scannedMemberships += page.page.length;
|
||||
|
||||
for (const membership of page.page) {
|
||||
if (affiliations.length >= PUBLISHER_OG_AFFILIATION_LIMIT) break;
|
||||
if (membership.publisherId === currentPublisherId) continue;
|
||||
const publisher = await ctx.db.get(membership.publisherId);
|
||||
if (
|
||||
!publisher ||
|
||||
publisher.kind !== "org" ||
|
||||
publisher.deletedAt ||
|
||||
publisher.deactivatedAt
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const publicPublisher = await toPublicPublisherWithOfficial(ctx, publisher);
|
||||
if (!publicPublisher) continue;
|
||||
affiliations.push({
|
||||
publisher: publicPublisher,
|
||||
role: membership.role,
|
||||
});
|
||||
}
|
||||
|
||||
if (page.isDone || page.page.length === 0) break;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
|
||||
return affiliations;
|
||||
}
|
||||
|
||||
async function toPublicPublisherWithLinkedImage(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publisher: Doc<"publishers"> | null,
|
||||
@@ -2199,6 +2257,35 @@ export const getProfileByHandle = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getOgMetaByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const visibility = await getPublicPublisherVisibility(
|
||||
ctx,
|
||||
await getPublisherByHandle(ctx, args.handle),
|
||||
);
|
||||
if (!visibility) return null;
|
||||
const publicPublisher = await toPublicPublisherWithOfficial(ctx, visibility.publisher);
|
||||
if (!publicPublisher) return null;
|
||||
const visibleUserId = visibility.publisher.kind === "user" ? visibility.linkedUser?._id : null;
|
||||
const stats = hasPublisherStats(visibility.publisher)
|
||||
? getPublisherDenormalizedStats(visibility.publisher)
|
||||
: getIndexedPublisherStatsFromRows(
|
||||
await getPublisherPublishedRows(ctx, visibility.publisher._id),
|
||||
);
|
||||
return {
|
||||
...publicPublisher,
|
||||
displayName: resolvePublisherDisplayName(visibility.publisher, visibility.linkedUser),
|
||||
image: publicPublisher.image ?? visibility.linkedUser?.image,
|
||||
bio: publicPublisher.bio ?? visibility.linkedUser?.bio,
|
||||
stats,
|
||||
affiliations: visibleUserId
|
||||
? await getUserPublisherOgAffiliations(ctx, visibleUserId, visibility.publisher._id)
|
||||
: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listStarredPage = query({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock("convex/browser", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: { publishers: { getProfileByHandle: "publishers.getProfileByHandle" } },
|
||||
api: { publishers: { getOgMetaByHandle: "publishers.getOgMetaByHandle" } },
|
||||
}));
|
||||
|
||||
describe("fetchPublisherOgMeta", () => {
|
||||
@@ -53,7 +53,7 @@ describe("fetchPublisherOgMeta", () => {
|
||||
const meta = await fetchPublisherOgMeta("openclaw", "https://example.convex.cloud");
|
||||
|
||||
expect(clientCtorMock).toHaveBeenCalledWith("https://example.convex.cloud");
|
||||
expect(queryMock).toHaveBeenCalledWith("publishers.getProfileByHandle", {
|
||||
expect(queryMock).toHaveBeenCalledWith("publishers.getOgMetaByHandle", {
|
||||
handle: "openclaw",
|
||||
});
|
||||
expect(meta?.stats.downloads).toBe(99);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
|
||||
export type PublisherOgMeta = {
|
||||
@@ -18,26 +19,7 @@ export type PublisherOgMeta = {
|
||||
};
|
||||
};
|
||||
|
||||
type PublisherProfileResult = {
|
||||
handle?: string | null;
|
||||
kind?: "user" | "org";
|
||||
official?: boolean;
|
||||
displayName?: string | null;
|
||||
bio?: string | null;
|
||||
image?: string | null;
|
||||
affiliations?: Array<{
|
||||
publisher?: {
|
||||
handle?: string | null;
|
||||
displayName?: string | null;
|
||||
image?: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
stats?: {
|
||||
downloads?: number;
|
||||
installs?: number;
|
||||
};
|
||||
} | null;
|
||||
|
||||
type PublisherProfileResult = FunctionReturnType<typeof api.publishers.getOgMetaByHandle>;
|
||||
type PublisherProfileAffiliations = NonNullable<PublisherProfileResult>["affiliations"];
|
||||
|
||||
export async function fetchPublisherOgMeta(
|
||||
@@ -46,9 +28,9 @@ export async function fetchPublisherOgMeta(
|
||||
): Promise<PublisherOgMeta | null> {
|
||||
try {
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
const profile = (await client.query(api.publishers.getProfileByHandle, {
|
||||
const profile = await client.query(api.publishers.getOgMetaByHandle, {
|
||||
handle,
|
||||
})) as PublisherProfileResult;
|
||||
});
|
||||
if (!profile) return null;
|
||||
return {
|
||||
handle: profile.handle ?? null,
|
||||
|
||||
@@ -59,4 +59,26 @@ describe("normalizeOgLogoDataUrl", () => {
|
||||
await expect(visibleAlphaBounds(paddedLogo ?? "")).resolves.toEqual({ width: 48, height: 48 });
|
||||
await expect(visibleAlphaBounds(tightLogo ?? "")).resolves.toEqual({ width: 48, height: 48 });
|
||||
});
|
||||
|
||||
it("fails closed for non-image and invalid image data URLs", async () => {
|
||||
await expect(normalizeOgLogoDataUrl("data:text/plain;base64,SGVsbG8=")).resolves.toBeNull();
|
||||
await expect(normalizeOgLogoDataUrl("data:image/png;base64,bm90LWEtcG5n")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed for images above the input pixel cap", async () => {
|
||||
const oversizedImage = await sharp({
|
||||
create: {
|
||||
width: 2001,
|
||||
height: 2001,
|
||||
channels: 4,
|
||||
background: { r: 212, g: 69, b: 58, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await expect(
|
||||
normalizeOgLogoDataUrl(`data:image/png;base64,${oversizedImage.toString("base64")}`),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
const NORMALIZED_LOGO_SIZE = 48;
|
||||
const NORMALIZED_LOGO_MAX_INPUT_PIXELS = 4_000_000;
|
||||
|
||||
function readDataUrl(dataUrl: string) {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
|
||||
@@ -14,10 +15,12 @@ function readDataUrl(dataUrl: string) {
|
||||
export async function normalizeOgLogoDataUrl(dataUrl: string | null | undefined) {
|
||||
if (!dataUrl) return null;
|
||||
const parsed = readDataUrl(dataUrl);
|
||||
if (!parsed || !parsed.mimeType.startsWith("image/")) return dataUrl;
|
||||
if (!parsed || !parsed.mimeType.startsWith("image/")) return null;
|
||||
|
||||
try {
|
||||
const normalized = await sharp(parsed.buffer)
|
||||
const normalized = await sharp(parsed.buffer, {
|
||||
limitInputPixels: NORMALIZED_LOGO_MAX_INPUT_PIXELS,
|
||||
})
|
||||
.ensureAlpha()
|
||||
.trim({ threshold: 8 })
|
||||
.resize(NORMALIZED_LOGO_SIZE, NORMALIZED_LOGO_SIZE, {
|
||||
@@ -29,6 +32,6 @@ export async function normalizeOgLogoDataUrl(dataUrl: string | null | undefined)
|
||||
.toBuffer();
|
||||
return `data:image/png;base64,${normalized.toString("base64")}`;
|
||||
} catch {
|
||||
return dataUrl;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ vi.mock("h3", () => ({
|
||||
setHeader: (...args: unknown[]) => setHeaderMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/fetchPluginOgMeta", () => ({
|
||||
vi.mock("./fetchPluginOgMeta", () => ({
|
||||
fetchPluginOgMeta: (...args: unknown[]) => fetchPluginOgMetaMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/ogAssets", () => ({
|
||||
vi.mock("./ogAssets", () => ({
|
||||
FONT_MONO: "IBM Plex Mono",
|
||||
FONT_SANS: "Bricolage Grotesque",
|
||||
getMarkDataUrl: (...args: unknown[]) => getMarkDataUrlMock(...args),
|
||||
@@ -49,11 +49,11 @@ vi.mock("../../og/ogAssets", () => ({
|
||||
getFontBuffers: (...args: unknown[]) => getFontBuffersMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/fetchImageDataUrl", () => ({
|
||||
vi.mock("./fetchImageDataUrl", () => ({
|
||||
fetchImageDataUrl: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/pluginOgSvg", () => ({
|
||||
vi.mock("./pluginOgSvg", () => ({
|
||||
buildPluginOgSvg: (...args: unknown[]) => buildPluginOgSvgMock(...args),
|
||||
}));
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("plugin og route", () => {
|
||||
it("returns plain text when name is missing", async () => {
|
||||
getQueryMock.mockReturnValue({});
|
||||
|
||||
const handler = (await import("./plugin.png")).default;
|
||||
const handler = (await import("../routes/og/plugin.png")).default;
|
||||
await expect(handler({} as never)).resolves.toBe("Missing `name` query param.");
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, "Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -115,7 +115,7 @@ describe("plugin og route", () => {
|
||||
verification: { scanStatus: "pending" },
|
||||
});
|
||||
|
||||
const handler = (await import("./plugin.png")).default;
|
||||
const handler = (await import("../routes/og/plugin.png")).default;
|
||||
const response = (await handler({} as never)) as Response;
|
||||
|
||||
expect(fetchPluginOgMetaMock).toHaveBeenCalledWith(
|
||||
@@ -146,7 +146,7 @@ describe("plugin og route", () => {
|
||||
verification: { scanStatus: "clean" },
|
||||
});
|
||||
|
||||
const handler = (await import("./plugin.png")).default;
|
||||
const handler = (await import("../routes/og/plugin.png")).default;
|
||||
const response = (await handler({} as never)) as Response;
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
@@ -169,7 +169,7 @@ describe("plugin og route", () => {
|
||||
downloads: "0",
|
||||
});
|
||||
|
||||
const handler = (await import("./plugin.png")).default;
|
||||
const handler = (await import("../routes/og/plugin.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchPluginOgMetaMock).not.toHaveBeenCalled();
|
||||
@@ -193,7 +193,7 @@ describe("plugin og route", () => {
|
||||
installs: "9.9k",
|
||||
});
|
||||
|
||||
const handler = (await import("./plugin.png")).default;
|
||||
const handler = (await import("../routes/og/plugin.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchPluginOgMetaMock).not.toHaveBeenCalled();
|
||||
@@ -0,0 +1,394 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getQueryMock = vi.fn();
|
||||
const setHeaderMock = vi.fn();
|
||||
const fetchImageDataUrlMock = vi.fn();
|
||||
const fetchPublisherProfileImageDataUrlMock = vi.fn();
|
||||
const fetchPublisherOgMetaMock = vi.fn();
|
||||
const normalizeOgLogoDataUrlMock = vi.fn();
|
||||
const getClawHubLogoDataUrlMock = vi.fn();
|
||||
const ensureResvgWasmMock = vi.fn();
|
||||
const getPublisherFontBuffersMock = vi.fn();
|
||||
const buildPublisherOgSvgMock = vi.fn();
|
||||
const renderAsPngMock = vi.fn();
|
||||
const freeMock = vi.fn();
|
||||
const resvgCtorMock = vi.fn();
|
||||
|
||||
class ResvgMockClass {
|
||||
constructor(...args: unknown[]) {
|
||||
resvgCtorMock(...args);
|
||||
}
|
||||
|
||||
render() {
|
||||
return { asPng: renderAsPngMock };
|
||||
}
|
||||
|
||||
free() {
|
||||
return freeMock();
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("h3", () => ({
|
||||
defineEventHandler: (handler: unknown) => handler,
|
||||
getQuery: (...args: unknown[]) => getQueryMock(...args),
|
||||
setHeader: (...args: unknown[]) => setHeaderMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./fetchImageDataUrl", () => ({
|
||||
fetchImageDataUrl: (...args: unknown[]) => fetchImageDataUrlMock(...args),
|
||||
fetchPublisherProfileImageDataUrl: (...args: unknown[]) =>
|
||||
fetchPublisherProfileImageDataUrlMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./fetchPublisherOgMeta", () => ({
|
||||
fetchPublisherOgMeta: (...args: unknown[]) => fetchPublisherOgMetaMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./normalizeLogoDataUrl", () => ({
|
||||
normalizeOgLogoDataUrl: (...args: unknown[]) => normalizeOgLogoDataUrlMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./ogAssets", () => ({
|
||||
FONT_MONO: "IBM Plex Mono",
|
||||
FONT_SANS: "Bricolage Grotesque",
|
||||
getClawHubLogoDataUrl: (...args: unknown[]) => getClawHubLogoDataUrlMock(...args),
|
||||
ensureResvgWasm: (...args: unknown[]) => ensureResvgWasmMock(...args),
|
||||
getPublisherFontBuffers: (...args: unknown[]) => getPublisherFontBuffersMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./publisherOgSvg", () => ({
|
||||
buildPublisherOgSvg: (...args: unknown[]) => buildPublisherOgSvgMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@resvg/resvg-wasm", () => ({
|
||||
Resvg: ResvgMockClass,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
getQueryMock.mockReset();
|
||||
setHeaderMock.mockReset();
|
||||
fetchImageDataUrlMock.mockReset();
|
||||
fetchPublisherProfileImageDataUrlMock.mockReset();
|
||||
fetchPublisherOgMetaMock.mockReset();
|
||||
normalizeOgLogoDataUrlMock.mockReset();
|
||||
getClawHubLogoDataUrlMock.mockReset();
|
||||
ensureResvgWasmMock.mockReset();
|
||||
getPublisherFontBuffersMock.mockReset();
|
||||
buildPublisherOgSvgMock.mockReset();
|
||||
renderAsPngMock.mockReset();
|
||||
freeMock.mockReset();
|
||||
resvgCtorMock.mockReset();
|
||||
|
||||
getClawHubLogoDataUrlMock.mockResolvedValue("data:image/png;base64,TE9HTw==");
|
||||
ensureResvgWasmMock.mockResolvedValue(undefined);
|
||||
getPublisherFontBuffersMock.mockResolvedValue([new Uint8Array([1, 2, 3])]);
|
||||
fetchPublisherProfileImageDataUrlMock.mockResolvedValue("data:image/png;base64,QVZBVEFS");
|
||||
fetchImageDataUrlMock.mockImplementation(async (url: string) => `data:image/png;base64,${url}`);
|
||||
normalizeOgLogoDataUrlMock.mockImplementation(async (dataUrl: string) => `${dataUrl}-normalized`);
|
||||
buildPublisherOgSvgMock.mockReturnValue("<svg>profile</svg>");
|
||||
renderAsPngMock.mockReturnValue(new Uint8Array([7, 8, 9]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.VITE_CONVEX_URL;
|
||||
delete process.env.CONVEX_URL;
|
||||
});
|
||||
|
||||
describe("profile og route", () => {
|
||||
it("returns plain text when handle is missing", async () => {
|
||||
getQueryMock.mockReturnValue({});
|
||||
|
||||
const handler = (await import("../routes/og/profile.png")).default;
|
||||
await expect(handler({} as never)).resolves.toBe("Missing `handle` query param.");
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, "Content-Type", "text/plain; charset=utf-8");
|
||||
expect(fetchPublisherOgMetaMock).not.toHaveBeenCalled();
|
||||
expect(resvgCtorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders explicit query params without fetching metadata", async () => {
|
||||
getQueryMock.mockReturnValue({
|
||||
handle: "nvidia",
|
||||
title: "NVIDIA",
|
||||
downloads: "1200",
|
||||
kind: "org",
|
||||
official: "0",
|
||||
orgState: "0",
|
||||
orgImages: "0",
|
||||
avatar: "https://cdn.example.com/avatar.png",
|
||||
});
|
||||
|
||||
const handler = (await import("../routes/og/profile.png")).default;
|
||||
const response = (await handler({} as never)) as Response;
|
||||
|
||||
await expect(response.arrayBuffer()).resolves.toEqual(new Uint8Array([7, 8, 9]).buffer);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(fetchPublisherOgMetaMock).not.toHaveBeenCalled();
|
||||
expect(fetchPublisherProfileImageDataUrlMock).toHaveBeenCalledWith(
|
||||
"https://cdn.example.com/avatar.png",
|
||||
);
|
||||
expect(fetchImageDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(normalizeOgLogoDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith({
|
||||
clawHubLogoDataUrl: "data:image/png;base64,TE9HTw==",
|
||||
avatarDataUrl: "data:image/png;base64,QVZBVEFS",
|
||||
avatarShape: "rounded",
|
||||
official: false,
|
||||
title: "NVIDIA",
|
||||
handleLabel: "@nvidia",
|
||||
organizationCount: 0,
|
||||
organizationLogos: [],
|
||||
stats: [{ value: "1.2k", label: "Downloads" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies trust indicators before rendering query-requested official state", async () => {
|
||||
process.env.VITE_CONVEX_URL = "https://convex.example";
|
||||
getQueryMock.mockReturnValue({
|
||||
handle: "nvidia",
|
||||
title: "Fake NVIDIA",
|
||||
downloads: "999999",
|
||||
official: "1",
|
||||
orgState: "many",
|
||||
orgImages: "https://attacker.example/0.png|https://attacker.example/1.png",
|
||||
avatar: "https://attacker.example/avatar.png",
|
||||
});
|
||||
fetchPublisherOgMetaMock.mockResolvedValue({
|
||||
handle: "nvidia",
|
||||
kind: "user",
|
||||
official: false,
|
||||
displayName: "Verified NVIDIA",
|
||||
image: "https://cdn.example.com/verified-avatar.png",
|
||||
affiliations: [],
|
||||
stats: { downloads: 1200 },
|
||||
});
|
||||
|
||||
const handler = (await import("../routes/og/profile.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchPublisherOgMetaMock).toHaveBeenCalledWith("nvidia", "https://convex.example");
|
||||
expect(fetchPublisherProfileImageDataUrlMock).toHaveBeenCalledWith(
|
||||
"https://cdn.example.com/verified-avatar.png",
|
||||
);
|
||||
expect(fetchImageDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(normalizeOgLogoDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
official: false,
|
||||
title: "Verified NVIDIA",
|
||||
organizationCount: 0,
|
||||
organizationLogos: [],
|
||||
stats: [{ value: "1.2k", label: "Downloads" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies non-zero organization state even when affiliations have no images", async () => {
|
||||
process.env.VITE_CONVEX_URL = "https://convex.example";
|
||||
getQueryMock.mockReturnValue({
|
||||
handle: "nvidia",
|
||||
title: "Fake NVIDIA",
|
||||
downloads: "999999",
|
||||
kind: "user",
|
||||
official: "0",
|
||||
orgState: "many",
|
||||
orgImages: "0",
|
||||
avatar: "https://attacker.example/avatar.png",
|
||||
});
|
||||
fetchPublisherOgMetaMock.mockResolvedValue({
|
||||
handle: "nvidia",
|
||||
kind: "user",
|
||||
official: false,
|
||||
displayName: "Verified NVIDIA",
|
||||
image: "https://cdn.example.com/verified-avatar.png",
|
||||
affiliations: [
|
||||
{
|
||||
handle: "verified-org-1",
|
||||
displayName: "Verified Org 1",
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
handle: "verified-org-2",
|
||||
displayName: "Verified Org 2",
|
||||
image: null,
|
||||
},
|
||||
],
|
||||
stats: { downloads: 1200 },
|
||||
});
|
||||
|
||||
const handler = (await import("../routes/og/profile.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchPublisherOgMetaMock).toHaveBeenCalledWith("nvidia", "https://convex.example");
|
||||
expect(fetchPublisherProfileImageDataUrlMock).toHaveBeenCalledWith(
|
||||
"https://cdn.example.com/verified-avatar.png",
|
||||
);
|
||||
expect(fetchImageDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(normalizeOgLogoDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
official: false,
|
||||
title: "Verified NVIDIA",
|
||||
organizationCount: 2,
|
||||
organizationLogos: [],
|
||||
stats: [{ value: "1.2k", label: "Downloads" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses verified metadata for official cards requested by query params", async () => {
|
||||
process.env.VITE_CONVEX_URL = "https://convex.example";
|
||||
getQueryMock.mockReturnValue({
|
||||
handle: "nvidia",
|
||||
title: "Fake NVIDIA",
|
||||
downloads: "999999",
|
||||
kind: "user",
|
||||
official: "1",
|
||||
orgImages: "https://attacker.example/org.png",
|
||||
avatar: "https://attacker.example/avatar.png",
|
||||
});
|
||||
fetchPublisherOgMetaMock.mockResolvedValue({
|
||||
handle: "nvidia",
|
||||
kind: "org",
|
||||
official: true,
|
||||
displayName: "Verified NVIDIA",
|
||||
image: "https://cdn.example.com/verified-avatar.png",
|
||||
affiliations: [
|
||||
{
|
||||
handle: "verified-org",
|
||||
displayName: "Verified Org",
|
||||
image: "https://cdn.example.com/verified-org.png",
|
||||
},
|
||||
],
|
||||
stats: { downloads: 1200 },
|
||||
});
|
||||
|
||||
const handler = (await import("../routes/og/profile.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchPublisherOgMetaMock).toHaveBeenCalledWith("nvidia", "https://convex.example");
|
||||
expect(fetchPublisherProfileImageDataUrlMock).toHaveBeenCalledWith(
|
||||
"https://cdn.example.com/verified-avatar.png",
|
||||
);
|
||||
expect(fetchImageDataUrlMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImageDataUrlMock).toHaveBeenCalledWith("https://cdn.example.com/verified-org.png", {
|
||||
allowPublicHttps: true,
|
||||
followRedirects: true,
|
||||
});
|
||||
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
avatarShape: "rounded",
|
||||
official: true,
|
||||
title: "Verified NVIDIA",
|
||||
organizationCount: 1,
|
||||
organizationLogos: [
|
||||
"data:image/png;base64,https://cdn.example.com/verified-org.png-normalized",
|
||||
],
|
||||
stats: [{ value: "1.2k", label: "Downloads" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses verified metadata when fetched metadata adds trust indicators", async () => {
|
||||
process.env.VITE_CONVEX_URL = "https://convex.example";
|
||||
getQueryMock.mockReturnValue({
|
||||
handle: "nvidia",
|
||||
title: "Fake NVIDIA",
|
||||
downloads: "999999",
|
||||
kind: "user",
|
||||
official: "0",
|
||||
orgImages: "0",
|
||||
});
|
||||
fetchPublisherOgMetaMock.mockResolvedValue({
|
||||
handle: "nvidia",
|
||||
kind: "org",
|
||||
official: true,
|
||||
displayName: "Verified NVIDIA",
|
||||
image: "https://cdn.example.com/verified-avatar.png",
|
||||
affiliations: [
|
||||
{
|
||||
handle: "verified-org",
|
||||
displayName: "Verified Org",
|
||||
image: "https://cdn.example.com/verified-org.png",
|
||||
},
|
||||
],
|
||||
stats: { downloads: 1200 },
|
||||
});
|
||||
|
||||
const handler = (await import("../routes/og/profile.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchPublisherOgMetaMock).toHaveBeenCalledWith("nvidia", "https://convex.example");
|
||||
expect(fetchPublisherProfileImageDataUrlMock).toHaveBeenCalledWith(
|
||||
"https://cdn.example.com/verified-avatar.png",
|
||||
);
|
||||
expect(fetchImageDataUrlMock).toHaveBeenCalledWith("https://cdn.example.com/verified-org.png", {
|
||||
allowPublicHttps: true,
|
||||
followRedirects: true,
|
||||
});
|
||||
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
avatarShape: "rounded",
|
||||
official: true,
|
||||
title: "Verified NVIDIA",
|
||||
organizationCount: 1,
|
||||
organizationLogos: [
|
||||
"data:image/png;base64,https://cdn.example.com/verified-org.png-normalized",
|
||||
],
|
||||
stats: [{ value: "1.2k", label: "Downloads" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("caps metadata affiliation logos before fetching and normalizing", async () => {
|
||||
process.env.VITE_CONVEX_URL = "https://convex.example";
|
||||
getQueryMock.mockReturnValue({ handle: "nvidia" });
|
||||
fetchPublisherOgMetaMock.mockResolvedValue({
|
||||
handle: "nvidia",
|
||||
kind: "user",
|
||||
official: true,
|
||||
displayName: "NVIDIA",
|
||||
image: "https://cdn.example.com/avatar.png",
|
||||
affiliations: Array.from({ length: 7 }, (_, index) => ({
|
||||
handle: `org-${index}`,
|
||||
displayName: `Org ${index}`,
|
||||
image: `https://cdn.example.com/org-${index}.png`,
|
||||
})),
|
||||
stats: { downloads: 1200 },
|
||||
});
|
||||
normalizeOgLogoDataUrlMock.mockImplementation(async (dataUrl: string) =>
|
||||
dataUrl.includes("org-2") ? null : `${dataUrl}-normalized`,
|
||||
);
|
||||
|
||||
const handler = (await import("../routes/og/profile.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchPublisherOgMetaMock).toHaveBeenCalledWith("nvidia", "https://convex.example");
|
||||
expect(fetchImageDataUrlMock).toHaveBeenCalledTimes(5);
|
||||
expect(fetchImageDataUrlMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"https://cdn.example.com/org-0.png",
|
||||
"https://cdn.example.com/org-1.png",
|
||||
"https://cdn.example.com/org-2.png",
|
||||
"https://cdn.example.com/org-3.png",
|
||||
"https://cdn.example.com/org-4.png",
|
||||
]);
|
||||
expect(normalizeOgLogoDataUrlMock).toHaveBeenCalledTimes(5);
|
||||
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
official: true,
|
||||
title: "NVIDIA",
|
||||
handleLabel: "@nvidia",
|
||||
organizationCount: 5,
|
||||
organizationLogos: [
|
||||
"data:image/png;base64,https://cdn.example.com/org-0.png-normalized",
|
||||
"data:image/png;base64,https://cdn.example.com/org-1.png-normalized",
|
||||
"data:image/png;base64,https://cdn.example.com/org-3.png-normalized",
|
||||
"data:image/png;base64,https://cdn.example.com/org-4.png-normalized",
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -144,8 +144,23 @@ describe("buildPublisherOgSvg", () => {
|
||||
expect(svg).not.toContain('rx="8" fill="#F7F1EA"');
|
||||
});
|
||||
|
||||
it("renders fallback organization tiles when verified affiliations have no logos", () => {
|
||||
const svg = buildSvg({
|
||||
organizationCount: 2,
|
||||
organizationLogos: [],
|
||||
});
|
||||
expect(svg).toContain("Organizations");
|
||||
expect(svg).toContain("orgLogoClip0");
|
||||
expect(svg).toContain("orgLogoClip1");
|
||||
expect(svg).not.toContain("orgLogoClip2");
|
||||
expect(svg).toContain(
|
||||
`<image href="${clawHubLogoDataUrl}" x="169" y="459" width="48" height="48" clip-path="url(#orgLogoClip0)"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("caps rendered organization logos at five", () => {
|
||||
const svg = buildSvg({
|
||||
organizationCount: 6,
|
||||
organizationLogos: [
|
||||
transparentPixel,
|
||||
transparentPixel,
|
||||
@@ -187,4 +202,18 @@ describe("buildPublisherOgSvg", () => {
|
||||
expect(svg).toContain('x="447" y="547"');
|
||||
expect(svg).toContain(">41.9k</text>");
|
||||
});
|
||||
|
||||
it("accounts for full-width glyphs when fitting publisher text", () => {
|
||||
const svg = buildSvg({
|
||||
official: true,
|
||||
title: "这是一个非常长的发布者名称测试测试测试测试测试",
|
||||
handleLabel: "@测试测试测试测试测试测试测试测试测试测试",
|
||||
});
|
||||
|
||||
expect(svg).toContain('<tspan x="447" dy="0">这是一个非常长的</tspan>');
|
||||
expect(svg).toContain('<tspan x="447" dy="84">发布者名称测试...</tspan>');
|
||||
expect(svg).toContain("@测试测试测试测试测试测试测...");
|
||||
expect(readOfficialBadgeX(svg)).toBe(1037.7);
|
||||
expect(svg).not.toContain("发布者名称测试测试测试测试测试</tspan>");
|
||||
});
|
||||
});
|
||||
|
||||
+45
-15
@@ -8,6 +8,7 @@ export type PublisherOgSvgParams = {
|
||||
official?: boolean;
|
||||
title: string;
|
||||
handleLabel: string;
|
||||
organizationCount?: number;
|
||||
organizationLogos?: string[];
|
||||
stats?: RegistryOgStat[];
|
||||
};
|
||||
@@ -28,14 +29,27 @@ const PUBLISHER_GRADIENT_FADE = "#6C1B2B";
|
||||
const PUBLISHER_TEXT_WEIGHT = 700;
|
||||
const PUBLISHER_LABEL_SIZE = 24;
|
||||
const PUBLISHER_VALUE_SIZE = 44;
|
||||
const GRAPHEME_SEGMENTER =
|
||||
typeof Intl.Segmenter === "function"
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: null;
|
||||
const FULL_WIDTH_GLYPH_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\u3000-\u303f\uff00-\uffef]/u;
|
||||
|
||||
function textSegments(value: string) {
|
||||
return GRAPHEME_SEGMENTER
|
||||
? Array.from(GRAPHEME_SEGMENTER.segment(value), (part) => part.segment)
|
||||
: Array.from(value);
|
||||
}
|
||||
|
||||
function estimateTextWidth(value: string, fontSize: number) {
|
||||
return [...value].reduce((width, char) => {
|
||||
return textSegments(value).reduce((width, char) => {
|
||||
if (char === " ") return width + fontSize * 0.28;
|
||||
if (/[ilI.,:;|!'"`]/.test(char)) return width + fontSize * 0.28;
|
||||
if (/[mwMW@%&]/.test(char)) return width + fontSize * 0.9;
|
||||
if (/[A-Z]/.test(char)) return width + fontSize * 0.68;
|
||||
if (/[0-9]/.test(char)) return width + fontSize * 0.6;
|
||||
if (FULL_WIDTH_GLYPH_RE.test(char)) return width + fontSize;
|
||||
return width + fontSize * 0.56;
|
||||
}, 0);
|
||||
}
|
||||
@@ -97,7 +111,7 @@ function wrapTextWithoutEllipsis(value: string, maxWidth: number, fontSize: numb
|
||||
if (estimateTextWidth(word, fontSize) <= maxWidth) return [word];
|
||||
const parts: string[] = [];
|
||||
let chunk = "";
|
||||
for (const char of word) {
|
||||
for (const char of textSegments(word)) {
|
||||
const next = chunk + char;
|
||||
if (chunk && estimateTextWidth(next, fontSize) > maxWidth) {
|
||||
parts.push(chunk);
|
||||
@@ -181,7 +195,7 @@ function truncateWithDots(value: string, maxWidth: number, fontSize: number) {
|
||||
if (estimateTextWidth(value, fontSize) <= maxWidth) return value;
|
||||
const dots = "...";
|
||||
const dotsWidth = estimateTextWidth(dots, fontSize);
|
||||
const chars = [...value];
|
||||
const chars = textSegments(value);
|
||||
while (chars.length > 0 && estimateTextWidth(chars.join(""), fontSize) + dotsWidth > maxWidth) {
|
||||
chars.pop();
|
||||
}
|
||||
@@ -217,24 +231,30 @@ function statColumn(
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function orgLogoTiles(logos: string[], fallbackLogoDataUrl: string, x: number, yOffset: number) {
|
||||
function orgLogoTiles(
|
||||
logos: string[],
|
||||
organizationCount: number,
|
||||
fallbackLogoDataUrl: string,
|
||||
x: number,
|
||||
yOffset: number,
|
||||
) {
|
||||
const visibleLogos = logos.slice(0, 5);
|
||||
if (visibleLogos.length === 0) return "";
|
||||
const visibleCount = Math.min(Math.max(organizationCount, visibleLogos.length), 5);
|
||||
if (visibleCount === 0) return "";
|
||||
const y = 459 + yOffset;
|
||||
const size = 48;
|
||||
const gap = 10;
|
||||
const tiles = visibleLogos
|
||||
.map((logo, index) => {
|
||||
const tileX = x + index * (size + gap);
|
||||
const clipId = `orgLogoClip${index}`;
|
||||
return `<g>
|
||||
const tiles = Array.from({ length: visibleCount }, (_, index) => {
|
||||
const logo = visibleLogos[index] || fallbackLogoDataUrl;
|
||||
const tileX = x + index * (size + gap);
|
||||
const clipId = `orgLogoClip${index}`;
|
||||
return `<g>
|
||||
<clipPath id="${clipId}">
|
||||
<rect x="${tileX}" y="${y}" width="${size}" height="${size}" rx="8"/>
|
||||
</clipPath>
|
||||
<image href="${logo || fallbackLogoDataUrl}" x="${tileX}" y="${y}" width="${size}" height="${size}" clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>
|
||||
<image href="${logo}" x="${tileX}" y="${y}" width="${size}" height="${size}" clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>
|
||||
</g>`;
|
||||
})
|
||||
.join("");
|
||||
}).join("");
|
||||
return `<g>
|
||||
<text x="${x}" y="${438 + yOffset}"
|
||||
fill="${PUBLISHER_RED}"
|
||||
@@ -250,7 +270,11 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
|
||||
const avatar = params.avatarDataUrl || params.clawHubLogoDataUrl;
|
||||
const avatarShape = params.avatarShape ?? "circle";
|
||||
const organizationLogos = params.organizationLogos?.filter(Boolean) ?? [];
|
||||
const hasOrganizations = organizationLogos.length > 0;
|
||||
const organizationCount = Math.min(
|
||||
Math.max(params.organizationCount ?? 0, organizationLogos.length),
|
||||
5,
|
||||
);
|
||||
const hasOrganizations = organizationCount > 0;
|
||||
const normalLayout = hasOrganizations
|
||||
? {
|
||||
titleX: 509,
|
||||
@@ -410,7 +434,13 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
|
||||
font-family="${FONT_SANS}, sans-serif">on ClawHub</text>
|
||||
|
||||
${statsMarkup}
|
||||
${orgLogoTiles(organizationLogos, params.clawHubLogoDataUrl, orgLogosX, organizationExtraGap)}
|
||||
${orgLogoTiles(
|
||||
organizationLogos,
|
||||
organizationCount,
|
||||
params.clawHubLogoDataUrl,
|
||||
orgLogosX,
|
||||
organizationExtraGap,
|
||||
)}
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ vi.mock("h3", () => ({
|
||||
setHeader: (...args: unknown[]) => setHeaderMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/fetchSkillOgMeta", () => ({
|
||||
vi.mock("./fetchSkillOgMeta", () => ({
|
||||
fetchSkillOgMeta: (...args: unknown[]) => fetchSkillOgMetaMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/ogAssets", () => ({
|
||||
vi.mock("./ogAssets", () => ({
|
||||
FONT_MONO: "IBM Plex Mono",
|
||||
FONT_SANS: "Bricolage Grotesque",
|
||||
getMarkDataUrl: (...args: unknown[]) => getMarkDataUrlMock(...args),
|
||||
@@ -49,11 +49,11 @@ vi.mock("../../og/ogAssets", () => ({
|
||||
getFontBuffers: (...args: unknown[]) => getFontBuffersMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/fetchImageDataUrl", () => ({
|
||||
vi.mock("./fetchImageDataUrl", () => ({
|
||||
fetchImageDataUrl: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../og/skillOgSvg", () => ({
|
||||
vi.mock("./skillOgSvg", () => ({
|
||||
buildSkillOgSvg: (...args: unknown[]) => buildSkillOgSvgMock(...args),
|
||||
}));
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("skill og route", () => {
|
||||
it("returns plain text when slug is missing", async () => {
|
||||
getQueryMock.mockReturnValue({});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
const handler = (await import("../routes/og/skill.png")).default;
|
||||
await expect(handler({} as never)).resolves.toBe("Missing `slug` query param.");
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, "Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -111,7 +111,7 @@ describe("skill og route", () => {
|
||||
downloads: "0",
|
||||
});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
const handler = (await import("../routes/og/skill.png")).default;
|
||||
const response = (await handler({} as never)) as Response;
|
||||
await expect(response.arrayBuffer()).resolves.toEqual(new Uint8Array([7, 8, 9]).buffer);
|
||||
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
@@ -161,7 +161,7 @@ describe("skill og route", () => {
|
||||
moderation: { verdict: "clean", isSuspicious: false, isMalwareBlocked: false },
|
||||
});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
const handler = (await import("../routes/og/skill.png")).default;
|
||||
const response = (await handler({} as never)) as Response;
|
||||
|
||||
expect(fetchSkillOgMetaMock).toHaveBeenCalledWith(
|
||||
@@ -195,7 +195,7 @@ describe("skill og route", () => {
|
||||
installs: "9.9k",
|
||||
});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
const handler = (await import("../routes/og/skill.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(fetchSkillOgMetaMock).not.toHaveBeenCalled();
|
||||
@@ -219,7 +219,7 @@ describe("skill og route", () => {
|
||||
downloads: "43456",
|
||||
});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
const handler = (await import("../routes/og/skill.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(buildSkillOgSvgMock).toHaveBeenCalledWith(
|
||||
@@ -52,6 +52,11 @@ function readOrganizationImagesQuery(value: unknown) {
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function readOrganizationStateQuery(value: unknown) {
|
||||
const raw = cleanString(value).toLowerCase();
|
||||
return raw !== "" && raw !== "0" && raw !== "false" && raw !== "none";
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event) as OgQuery;
|
||||
const handle = cleanString(query.handle).replace(/^@+/, "");
|
||||
@@ -65,26 +70,40 @@ export default defineEventHandler(async (event) => {
|
||||
const avatarFromQuery = cleanString(query.avatar);
|
||||
const organizationImagesFromQuery = readOrganizationImagesQuery(query.orgImages);
|
||||
const convexUrl = getConvexUrl();
|
||||
const requestsTrustedPublisherState =
|
||||
readBooleanQuery(query.official) ||
|
||||
readOrganizationStateQuery(query.orgState) ||
|
||||
organizationImagesFromQuery.length > 0;
|
||||
const needFetch =
|
||||
!titleFromQuery ||
|
||||
!readOgDownloadsQuery(query) ||
|
||||
!avatarFromQuery ||
|
||||
!cleanString(query.official) ||
|
||||
!cleanString(query.orgState) ||
|
||||
!cleanString(query.orgImages);
|
||||
requestsTrustedPublisherState;
|
||||
const meta = needFetch && convexUrl ? await fetchPublisherOgMeta(handle, convexUrl) : null;
|
||||
const fetchedTrustedPublisherState =
|
||||
Boolean(meta?.official) || (meta?.affiliations.length ?? 0) > 0;
|
||||
const useVerifiedPublisherState =
|
||||
meta !== null && (requestsTrustedPublisherState || fetchedTrustedPublisherState);
|
||||
const verifiedMeta = useVerifiedPublisherState ? meta : null;
|
||||
const handleLabel = `@${meta?.handle || handle}`;
|
||||
const title = titleFromQuery || meta?.displayName || handleLabel;
|
||||
const title = verifiedMeta
|
||||
? verifiedMeta.displayName || handleLabel
|
||||
: titleFromQuery || meta?.displayName || handleLabel;
|
||||
const avatarUrl = verifiedMeta ? verifiedMeta.image : avatarFromQuery || meta?.image;
|
||||
const avatarKind = verifiedMeta ? verifiedMeta.kind : kindFromQuery || meta?.kind;
|
||||
const statsQuery = useVerifiedPublisherState ? {} : query;
|
||||
|
||||
const [clawHubLogoDataUrl, fontBuffers] = await Promise.all([
|
||||
getClawHubLogoDataUrl(),
|
||||
ensureResvgWasm().then(() => getPublisherFontBuffers()),
|
||||
]);
|
||||
const avatarDataUrl = await fetchPublisherProfileImageDataUrl(avatarFromQuery || meta?.image);
|
||||
const avatarDataUrl = await fetchPublisherProfileImageDataUrl(avatarUrl);
|
||||
const organizationImageUrls =
|
||||
organizationImagesFromQuery.length > 0
|
||||
? organizationImagesFromQuery
|
||||
: (meta?.affiliations.map((affiliation) => affiliation.image).filter(Boolean) ?? []);
|
||||
verifiedMeta?.affiliations
|
||||
.map((affiliation) => affiliation.image)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5) ?? [];
|
||||
const organizationCount = Math.min(verifiedMeta?.affiliations.length ?? 0, 5);
|
||||
const organizationLogoDataUrls = (
|
||||
await Promise.all(
|
||||
organizationImageUrls.map(async (imageUrl) => {
|
||||
@@ -100,12 +119,13 @@ export default defineEventHandler(async (event) => {
|
||||
const svg = buildPublisherOgSvg({
|
||||
clawHubLogoDataUrl,
|
||||
avatarDataUrl,
|
||||
avatarShape: kindFromQuery === "org" || meta?.kind === "org" ? "rounded" : "circle",
|
||||
official: cleanString(query.official) ? readBooleanQuery(query.official) : meta?.official,
|
||||
avatarShape: avatarKind === "org" ? "rounded" : "circle",
|
||||
official: verifiedMeta?.official ?? false,
|
||||
title,
|
||||
handleLabel,
|
||||
organizationCount,
|
||||
organizationLogos: organizationLogoDataUrls,
|
||||
stats: [buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads))],
|
||||
stats: [buildOgDownloadsStat(resolveOgDownloadsDisplay(statsQuery, meta?.stats.downloads))],
|
||||
});
|
||||
|
||||
const resvg = new Resvg(svg, {
|
||||
|
||||
@@ -16,6 +16,9 @@ async function loadRoute() {
|
||||
return (await import("../routes/$slug")).Route as unknown as {
|
||||
__config: {
|
||||
loader: (args: { params: { slug: string } }) => Promise<unknown>;
|
||||
head: (args: { params: { slug: string }; loaderData?: unknown }) => {
|
||||
meta?: Array<{ property?: string; name?: string; content: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -58,6 +61,42 @@ describe("top-level slug route loader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes official and affiliation metadata in canonical publisher OG images", async () => {
|
||||
const route = await loadRoute();
|
||||
const head = route.__config.head({
|
||||
params: { slug: "nvidia" },
|
||||
loaderData: {
|
||||
publisher: {
|
||||
_id: "publishers:nvidia",
|
||||
handle: "nvidia",
|
||||
displayName: "NVIDIA",
|
||||
bio: "Official NVIDIA publisher.",
|
||||
image: "https://example.com/nvidia.png",
|
||||
kind: "org",
|
||||
official: true,
|
||||
affiliations: [
|
||||
{
|
||||
publisher: {
|
||||
displayName: "OpenClaw",
|
||||
image: "https://example.com/openclaw.png",
|
||||
},
|
||||
role: "publisher",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
downloads: 1200,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const image = head.meta?.find((item) => item.property === "og:image")?.content ?? "";
|
||||
expect(image).toContain("/og/profile?");
|
||||
expect(image).toContain("official=1");
|
||||
expect(image).toContain("orgState=1");
|
||||
expect(image).toContain("orgImages=https%3A%2F%2Fexample.com%2Fopenclaw.png");
|
||||
});
|
||||
|
||||
it("returns not found for unknown slugs", async () => {
|
||||
resolveTopLevelSlugRouteMock.mockResolvedValue(null);
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ async function loadRoute() {
|
||||
return (await import("../routes/user/$handle")).Route as unknown as {
|
||||
__config: {
|
||||
loader?: (args: { params: { handle: string } }) => Promise<unknown>;
|
||||
head?: (args: { params: { handle: string }; loaderData?: unknown }) => {
|
||||
meta?: Array<{ property?: string; name?: string; content: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -57,4 +60,40 @@ describe("user profile route loader", () => {
|
||||
publisher: { _id: "publishers:active", handle: "active" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes official and affiliation metadata in legacy publisher OG images", async () => {
|
||||
const route = await loadRoute();
|
||||
const head = route.__config.head?.({
|
||||
params: { handle: "teoslayer" },
|
||||
loaderData: {
|
||||
publisher: {
|
||||
_id: "publishers:teoslayer",
|
||||
handle: "teoslayer",
|
||||
displayName: "Calin Teodor",
|
||||
bio: "Publisher @teoslayer on ClawHub.",
|
||||
image: "https://example.com/avatar.png",
|
||||
kind: "user",
|
||||
official: true,
|
||||
affiliations: [
|
||||
{
|
||||
publisher: {
|
||||
displayName: "OpenClaw",
|
||||
image: "https://example.com/openclaw.png",
|
||||
},
|
||||
role: "publisher",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
downloads: 73878,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const image = head?.meta?.find((item) => item.property === "og:image")?.content ?? "";
|
||||
expect(image).toContain("/og/profile?");
|
||||
expect(image).toContain("official=1");
|
||||
expect(image).toContain("orgState=1");
|
||||
expect(image).toContain("orgImages=https%3A%2F%2Fexample.com%2Fopenclaw.png");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ describe("og helpers", () => {
|
||||
expect(meta.image).toContain("v=8");
|
||||
expect(meta.image).toContain("handle=byungkyu");
|
||||
expect(meta.image).toContain("title=byungkyu");
|
||||
expect(meta.image).toContain("description=maton.ai");
|
||||
expect(meta.image).not.toContain("description=");
|
||||
expect(meta.image).toContain("kind=org");
|
||||
expect(meta.image).toContain("official=1");
|
||||
expect(meta.image).toContain("orgState=1");
|
||||
|
||||
+2
-3
@@ -34,8 +34,8 @@ type PublisherMetaSource = {
|
||||
bio?: string | null;
|
||||
image?: string | null;
|
||||
kind?: "user" | "org";
|
||||
official?: boolean | null;
|
||||
affiliations?: Array<{
|
||||
official: boolean | null;
|
||||
affiliations: Array<{
|
||||
publisher?: {
|
||||
displayName?: string | null;
|
||||
image?: string | null;
|
||||
@@ -146,7 +146,6 @@ export function buildPublisherMeta(source: PublisherMetaSource): BasicMeta {
|
||||
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");
|
||||
imageParams.set("official", source.official ? "1" : "0");
|
||||
const organizationCount = source.affiliations?.length ?? 0;
|
||||
|
||||
@@ -21,6 +21,8 @@ export const Route = createFileRoute("/$slug")({
|
||||
bio: publisher.bio,
|
||||
image: publisher.image,
|
||||
kind: publisher.kind,
|
||||
official: publisher.official ?? null,
|
||||
affiliations: publisher.affiliations ?? null,
|
||||
downloads: publisher.stats.downloads,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -104,8 +104,8 @@ export const Route = createFileRoute("/user/$handle")({
|
||||
bio: publisher?.bio,
|
||||
image: publisher?.image,
|
||||
kind: publisher?.kind,
|
||||
official: publisher?.official,
|
||||
affiliations: publisher?.affiliations,
|
||||
official: publisher?.official ?? null,
|
||||
affiliations: publisher?.affiliations ?? null,
|
||||
downloads: publisher?.stats.downloads,
|
||||
});
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user