From 913f049acb2a00dbf4efba226f9fd3323cc57e13 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Tue, 26 May 2026 12:02:05 -0500 Subject: [PATCH 1/2] Fix OG image 500s and stop install rate limits logging as errors OG image routes were 500ing (~5% of traffic) on relative logo URLs and Satori layout constraints. Normalize image src to absolute URLs, add explicit display:flex on multi-child nodes, and set Cache-Control plus a 1d revalidate so social crawlers hit cached PNGs. track-install now returns a non-throwing rate-limit result instead of throwing ActionError, so expected rate limiting no longer shows up as error-level logs. The client only increments the count on success and toasts on rate limit. Co-authored-by: Cursor --- apps/cursor/src/actions/track-install.ts | 10 ++++- .../src/app/c/[slug]/opengraph-image.tsx | 16 ++++++-- .../src/app/companies/opengraph-image.tsx | 1 + apps/cursor/src/app/login/opengraph-image.tsx | 1 + .../src/app/members/opengraph-image.tsx | 1 + apps/cursor/src/app/opengraph-image.tsx | 10 ++++- .../app/plugins/[slug]/opengraph-image.tsx | 28 +++++++++++--- .../src/app/plugins/new/opengraph-image.tsx | 1 + .../src/app/plugins/opengraph-image.tsx | 1 + .../src/app/u/[slug]/opengraph-image.tsx | 17 +++++++-- .../src/components/plugins/plugin-detail.tsx | 12 +++++- apps/cursor/src/lib/og.tsx | 38 +++++++++++++++++++ 12 files changed, 119 insertions(+), 17 deletions(-) diff --git a/apps/cursor/src/actions/track-install.ts b/apps/cursor/src/actions/track-install.ts index 9abf9223..1197578b 100644 --- a/apps/cursor/src/actions/track-install.ts +++ b/apps/cursor/src/actions/track-install.ts @@ -4,7 +4,11 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { installGlobalLimit, installPerPluginLimit } from "@/lib/rate-limit"; import { createClient as createAdminClient } from "@/utils/supabase/admin-client"; -import { ActionError, actionClient } from "./safe-action"; +import { actionClient } from "./safe-action"; + +export type TrackInstallResult = + | { tracked: true } + | { tracked: false; rateLimited: true }; export const trackInstallAction = actionClient .metadata({ @@ -23,7 +27,7 @@ export const trackInstallAction = actionClient ]); if (!global.success || !perPlugin.success) { - throw new ActionError("Rate limit exceeded. Please try again later."); + return { tracked: false, rateLimited: true } satisfies TrackInstallResult; } const admin = await createAdminClient(); @@ -34,4 +38,6 @@ export const trackInstallAction = actionClient revalidatePath("/"); revalidatePath(`/plugins/${slug}`); + + return { tracked: true } satisfies TrackInstallResult; }); diff --git a/apps/cursor/src/app/c/[slug]/opengraph-image.tsx b/apps/cursor/src/app/c/[slug]/opengraph-image.tsx index c950eb70..99b75c2c 100644 --- a/apps/cursor/src/app/c/[slug]/opengraph-image.tsx +++ b/apps/cursor/src/app/c/[slug]/opengraph-image.tsx @@ -1,9 +1,15 @@ import { getCompanyProfile } from "@/data/queries"; -import { createOGResponse, OG, OGLayout } from "@/lib/og"; +import { + createOGResponse, + OG, + OGLayout, + resolveOgImageUrl, +} from "@/lib/og"; export const alt = "Company Profile"; export const size = { width: OG.width, height: OG.height }; export const contentType = "image/png"; +export const revalidate = 86400; export default async function Image({ params, @@ -30,10 +36,12 @@ export default async function Image({ ); } + const logoUrl = resolveOgImageUrl(data.image); + return createOGResponse(
- {data.image && ( + {logoUrl && (
= {}; for (const c of components) { @@ -62,9 +66,9 @@ export default async function Image({ padding: 6, }} > - {data.logo ? ( + {logoUrl ? (
{data.author_name && ( -
+
by {data.author_name}
)} @@ -110,6 +122,7 @@ export default async function Image({ {data.description && (
- + {formatCount(data.install_count)}
{componentSummary && ( -
+
{componentSummary}
)} @@ -164,6 +179,7 @@ export default async function Image({
- {data.image && ( + {avatarUrl && (
{ + if (data?.tracked) { + setInstallCount((c) => c + 1); + } else if (data?.rateLimited) { + toast("Too many installs right now. Please try again in a bit."); + } + }, + }); const handleInstall = useCallback(() => { - setInstallCount((c) => c + 1); trackInstall({ pluginId: plugin.id, slug: plugin.slug }); }, [plugin.id, plugin.slug, trackInstall]); diff --git a/apps/cursor/src/lib/og.tsx b/apps/cursor/src/lib/og.tsx index 431e3b97..335ab13a 100644 --- a/apps/cursor/src/lib/og.tsx +++ b/apps/cursor/src/lib/og.tsx @@ -17,6 +17,39 @@ export const OG = { cardBg: "#1b1913", }; +const OG_CACHE_CONTROL = + "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800"; + +function ogSiteOrigin(): string { + const configured = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, ""); + if (configured) return configured; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + return "https://cursor.directory"; +} + +/** + * Satori requires absolute http(s) image URLs. Plugin logos are sometimes stored + * as site-relative paths (e.g. `assets/logo.png`). + */ +export function resolveOgImageUrl( + src: string | null | undefined, +): string | null { + const trimmed = src?.trim(); + if (!trimmed) return null; + + try { + const parsed = new URL(trimmed); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.toString(); + } + } catch { + // Fall through — treat as a path relative to the site origin. + } + + const path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return new URL(path, ogSiteOrigin()).toString(); +} + export async function loadFonts() { const [regular, bold] = await Promise.all([ readFile( @@ -121,6 +154,9 @@ export async function createOGResponse(element: ReactElement) { width: OG.width, height: OG.height, fonts, + headers: { + "Cache-Control": OG_CACHE_CONTROL, + }, }); } @@ -130,6 +166,7 @@ export async function createListingOG(title: string, subtitle: string) {
Date: Tue, 26 May 2026 12:15:38 -0500 Subject: [PATCH 2/2] Prevent duplicate company records Companies could be created repeatedly (e.g. 7x "Contentful") because the upsert only conflicted on a fresh client-side id and a BEFORE INSERT trigger silently uniquified slugs. Enforce uniqueness instead: - Add a normalized name_key column + case-insensitive unique index so duplicates are rejected at the DB level, even under concurrent submits. - Rewrite upsert-company to edit by id only when the row exists, and reuse the existing company on a name conflict instead of creating a duplicate. - Generate the company form id once per mount so it stops changing on every render. Co-authored-by: Cursor --- apps/cursor/src/actions/upsert-company.ts | 99 ++++++++++++++----- apps/cursor/src/components/forms/company.tsx | 5 +- .../20260526_companies_unique_name.sql | 11 +++ 3 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 supabase/migrations/20260526_companies_unique_name.sql diff --git a/apps/cursor/src/actions/upsert-company.ts b/apps/cursor/src/actions/upsert-company.ts index b7c63e66..005326c8 100644 --- a/apps/cursor/src/actions/upsert-company.ts +++ b/apps/cursor/src/actions/upsert-company.ts @@ -5,6 +5,10 @@ import { z } from "zod"; import { createClient } from "@/utils/supabase/server"; import { ActionError, authActionClient } from "./safe-action"; +// Postgres unique_violation. Raised when an insert/update collides with the +// case-insensitive company name index (companies_name_key_unique). +const UNIQUE_VIOLATION = "23505"; + export const upsertCompanyAction = authActionClient .metadata({ actionName: "upsert-company", @@ -27,7 +31,7 @@ export const upsertCompanyAction = authActionClient async ({ parsedInput: { id, - name, + name: rawName, image, slug, location, @@ -41,6 +45,9 @@ export const upsertCompanyAction = authActionClient }) => { const supabase = await createClient(); + const name = rawName.trim(); + const nameKey = name.toLowerCase(); + // Only treat the request as an edit when a row with the provided id // already exists. The form always generates a client-side nanoid for new // companies, so the presence of `id` alone does not imply an edit. @@ -51,36 +58,84 @@ export const upsertCompanyAction = authActionClient .eq("id", id) .maybeSingle(); - if (existing && existing.owner_id !== userId) { - throw new ActionError( - "You don't have permission to edit this company", - ); + if (existing) { + if (existing.owner_id !== userId) { + throw new ActionError( + "You don't have permission to edit this company", + ); + } + + const { data, error } = await supabase + .from("companies") + .update({ + name, + image, + location, + bio, + website, + social_x_link, + public: is_public, + }) + .eq("id", id) + .select("id, slug") + .single(); + + if (error) { + if (error.code === UNIQUE_VIOLATION) { + throw new ActionError("A company with this name already exists."); + } + throw new ActionError(error.message); + } + + if (shouldRedirect) { + redirect(`/c/${data?.slug}`); + } + + return data; } } + // New company. Insert directly so the case-insensitive unique index can + // reject duplicates even under concurrent/double submissions. The slug is + // assigned by the `generate_company_slug` trigger. const { data, error } = await supabase .from("companies") - .upsert( - { - id: id ?? undefined, - name, - image, - location, - slug: slug ?? undefined, - bio, - website, - social_x_link, - public: is_public, - owner_id: userId, - }, - { - onConflict: "id", - }, - ) + .insert({ + id: id ?? undefined, + name, + image, + location, + slug: slug ?? undefined, + bio, + website, + social_x_link, + public: is_public, + owner_id: userId, + }) .select("id, slug") .single(); if (error) { + // A company with this name already exists. Reuse it instead of creating + // a duplicate so retries/double-clicks resolve to the same record. + if (error.code === UNIQUE_VIOLATION) { + const { data: existing } = await supabase + .from("companies") + .select("id, slug") + .eq("name_key", nameKey) + .maybeSingle(); + + if (existing) { + if (shouldRedirect) { + redirect(`/c/${existing.slug}`); + } + + return existing; + } + + throw new ActionError("A company with this name already exists."); + } + throw new ActionError(error.message); } diff --git a/apps/cursor/src/components/forms/company.tsx b/apps/cursor/src/components/forms/company.tsx index 414d4c07..c268bb51 100644 --- a/apps/cursor/src/components/forms/company.tsx +++ b/apps/cursor/src/components/forms/company.tsx @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { nanoid } from "nanoid"; import { useAction } from "next-safe-action/hooks"; import { parseAsBoolean, useQueryState } from "nuqs"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { upsertCompanyAction } from "@/actions/upsert-company"; @@ -87,7 +88,9 @@ export function CompanyForm({ }, }); - const id = data?.id ?? nanoid(); + // Generate the id once per mount. Recomputing it on every render would change + // the submitted id (and logo upload path) and defeat duplicate prevention. + const [id] = useState(() => data?.id ?? nanoid()); const form = useForm>({ resolver: zodResolver(formSchema), diff --git a/supabase/migrations/20260526_companies_unique_name.sql b/supabase/migrations/20260526_companies_unique_name.sql new file mode 100644 index 00000000..ed4cb543 --- /dev/null +++ b/supabase/migrations/20260526_companies_unique_name.sql @@ -0,0 +1,11 @@ +-- Normalize existing names so surrounding whitespace can't create near-duplicates +UPDATE public.companies SET name = btrim(name) WHERE name <> btrim(name); + +-- Normalized key used to enforce case-insensitive uniqueness of company names +ALTER TABLE public.companies + ADD COLUMN IF NOT EXISTS name_key text + GENERATED ALWAYS AS (lower(btrim(name))) STORED; + +-- One company per normalized name (prevents the duplicate explosion at the DB level) +CREATE UNIQUE INDEX IF NOT EXISTS companies_name_key_unique + ON public.companies (name_key);