diff --git a/README.md b/README.md index 9a9a870..bacb3f7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ This repo is intended to be publishable as an open-core app. | --- | --- | --- | --- | | File-backed local mode | Editor, preview, templates, local publishing flows | Nothing beyond Node and pnpm | Fast local development and simple self-hosting | | Database-backed mode | Durable workspaces, accounts, account-owned resumes, full developer API | Postgres | Serious self-hosting and production deployments | -| Browser-backed jobs | Chromium-measured publish fit and async PDF jobs | `TINYCV_BROWSER_WS_ENDPOINT` or `TINYCV_CHROME_EXECUTABLE_PATH` | Production PDF/export parity | +| Browser-backed jobs | Chromium-measured publish fit, async PDF jobs, and per-resume social cards | `TINYCV_BROWSER_WS_ENDPOINT` or `TINYCV_CHROME_EXECUTABLE_PATH` | Production PDF/export parity | | Billing and machine payments | Stripe checkout, premium entitlements, x402/MPP paid endpoints | Extra provider credentials | Optional commercial add-ons | ## Quick Start @@ -192,7 +192,7 @@ High-level deployment requirements: - Postgres for durable production storage - explicit secrets for editor, platform, auth, and worker flows -- Chromium access for browser-measured publish fit and PDF jobs +- Chromium access for browser-measured publish fit, PDF jobs, and social cards - a scheduled job or worker invocation for `/api/v1/jobs/process` ## Verification diff --git a/docs/production-launch-checklist.md b/docs/production-launch-checklist.md index 8d07b9d..f63a9d1 100644 --- a/docs/production-launch-checklist.md +++ b/docs/production-launch-checklist.md @@ -7,7 +7,7 @@ This guide is the public production checklist for self-hosting Tiny CV with dura Tiny CV works best when you treat the deployment as two layers: - core app: Next.js server that handles the editor, API, auth, and public resumes -- background/browser layer: a worker or scheduled caller plus Chromium access for publish-fit measurement and PDF jobs +- background/browser layer: a worker or scheduled caller plus Chromium access for publish-fit measurement, PDF jobs, and social cards You can run both on one machine for small deployments or split them across services later. @@ -64,7 +64,7 @@ pnpm check:prod ### 3. Browser-Backed Jobs -API publish-fit measurement and async PDF jobs need Chromium access. +API publish-fit measurement, async PDF jobs, and the per-resume social cards on published pages need Chromium access. Without it, published pages fall back to the generic Tiny CV card. Set one of: diff --git a/src/app/[slug]/social-card/route.ts b/src/app/[slug]/social-card/route.ts new file mode 100644 index 0000000..b61fa8d --- /dev/null +++ b/src/app/[slug]/social-card/route.ts @@ -0,0 +1,76 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { + ApiRateLimitError, + ApiRateLimitUnavailableError, + assertApiRateLimit, +} from "@/app/_lib/api-rate-limit"; +import { BrowserRendererUnavailableError } from "@/app/_lib/browser-renderer"; +import { + getPublishedResumeBySlug, + HostedResumeStoreUnavailableError, +} from "@/app/_lib/hosted-resume-store"; +import { + buildResumeSocialCardRenderUrl, + renderResumeSocialCard, + SOCIAL_CARD_CONTENT_TYPE, +} from "@/app/_lib/resume-social-card"; +import { TINYCV_SOCIAL_CARD_PATH } from "@/app/_lib/site-metadata"; + +export const maxDuration = 60; + +const CARD_CACHE_CONTROL = "public, max-age=3600, s-maxage=604800, stale-while-revalidate=604800"; +const FALLBACK_CACHE_CONTROL = "public, max-age=60, s-maxage=300"; + +export async function GET( + request: NextRequest, + context: RouteContext<"/[slug]/social-card">, +) { + const { slug } = await context.params; + + try { + await assertApiRateLimit({ action: "social:card", request }); + + const resume = await getPublishedResumeBySlug(slug); + + if (!resume) { + return buildFallbackCardResponse(request); + } + + const card = await renderResumeSocialCard( + buildResumeSocialCardRenderUrl(request.nextUrl.origin, resume.slug), + ); + + return new Response(card as BodyInit, { + headers: { + "Cache-Control": CARD_CACHE_CONTROL, + "Content-Type": SOCIAL_CARD_CONTENT_TYPE, + }, + }); + } catch (error) { + if (isRecoverableCardError(error)) { + return buildFallbackCardResponse(request); + } + + throw error; + } +} + +/** + * A link preview should never look broken, so anything that stops us rendering + * the resume itself falls back to the generic Tiny CV card. + */ +function buildFallbackCardResponse(request: NextRequest) { + return NextResponse.redirect(new URL(TINYCV_SOCIAL_CARD_PATH, request.nextUrl.origin), { + headers: { "Cache-Control": FALLBACK_CACHE_CONTROL }, + status: 307, + }); +} + +function isRecoverableCardError(error: unknown) { + return ( + error instanceof ApiRateLimitError || + error instanceof ApiRateLimitUnavailableError || + error instanceof BrowserRendererUnavailableError || + error instanceof HostedResumeStoreUnavailableError + ); +} diff --git a/src/app/_components/public-resume-renderer.tsx b/src/app/_components/public-resume-renderer.tsx index 61d3921..c11295d 100644 --- a/src/app/_components/public-resume-renderer.tsx +++ b/src/app/_components/public-resume-renderer.tsx @@ -6,7 +6,11 @@ import { getPageMetrics } from "@/app/_lib/cv-fit"; import { parseCvMarkdown } from "@/app/_lib/cv-markdown"; import { canRemoveBrandingForResume } from "@/app/_lib/entitlements"; import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types"; -import { getTinyCvSocialCardImage } from "@/app/_lib/site-metadata"; +import { + buildResumeSocialCardPath, + SOCIAL_CARD_CONTENT_TYPE, + SOCIAL_CARD_IMAGE_SIZE, +} from "@/app/_lib/resume-social-card"; export async function PublicResumeRenderer({ autoPrint = false, @@ -60,7 +64,13 @@ export async function buildPublicResumeMetadata( : `${document.name}'s resume, published with Tiny CV.`; const title = `${document.name} | Resume`; const canonicalPath = options?.canonicalPath ?? `/${resume.slug}`; - const socialCardImage = getTinyCvSocialCardImage(); + const socialCardImage = { + alt: `The top of ${document.name}'s resume`, + height: SOCIAL_CARD_IMAGE_SIZE.height, + type: SOCIAL_CARD_CONTENT_TYPE, + url: buildResumeSocialCardPath(resume), + width: SOCIAL_CARD_IMAGE_SIZE.width, + } as const; return { alternates: { diff --git a/src/app/_lib/api-rate-limit.ts b/src/app/_lib/api-rate-limit.ts index 4b50992..f6e96d8 100644 --- a/src/app/_lib/api-rate-limit.ts +++ b/src/app/_lib/api-rate-limit.ts @@ -15,6 +15,7 @@ export type ApiRateLimitAction = | "api:resume_publish" | "api:resume_update" | "api:validate" + | "social:card" | "workspace:attach" | "workspace:bootstrap" | "workspace:create" @@ -44,6 +45,7 @@ const DEFAULT_POLICIES: Record = { "api:resume_publish": { max: 30, windowSeconds: 60 }, "api:resume_update": { max: 120, windowSeconds: 60 }, "api:validate": { max: 120, windowSeconds: 60 }, + "social:card": { max: 120, windowSeconds: 60 }, "workspace:attach": { max: 30, windowSeconds: 60 }, "workspace:bootstrap": { max: 20, windowSeconds: 60 * 60 }, "workspace:create": { max: 30, windowSeconds: 60 }, diff --git a/src/app/_lib/resume-social-card.test.ts b/src/app/_lib/resume-social-card.test.ts new file mode 100644 index 0000000..4c9dc0e --- /dev/null +++ b/src/app/_lib/resume-social-card.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types"; +import { + buildResumeSocialCardPath, + buildResumeSocialCardRenderUrl, +} from "@/app/_lib/resume-social-card"; + +function buildResume(overrides: Partial = {}): HostedResumePublicRecord { + return { + createdAt: "2026-05-01T00:00:00.000Z", + fitScale: 1, + id: "resume_123", + isPublished: true, + markdown: "# Steady Blue Heron", + publishedAt: "2026-06-01T00:00:00.000Z", + slug: "SteadyBlueHeron", + templateKey: "engineer", + title: "Steady Blue Heron", + updatedAt: "2026-06-02T00:00:00.000Z", + ...overrides, + }; +} + +describe("resume-social-card", () => { + it("versions the card path by publish time so edits are not served from cache", () => { + const publishedAt = buildResumeSocialCardPath(buildResume()); + const republished = buildResumeSocialCardPath( + buildResume({ publishedAt: "2026-06-03T00:00:00.000Z" }), + ); + + expect(publishedAt).toBe( + `/SteadyBlueHeron/social-card?v=${Date.parse("2026-06-01T00:00:00.000Z").toString(36)}`, + ); + expect(republished).not.toBe(publishedAt); + }); + + it("falls back to the update time when a resume has no publish time", () => { + expect(buildResumeSocialCardPath(buildResume({ publishedAt: null }))).toBe( + `/SteadyBlueHeron/social-card?v=${Date.parse("2026-06-02T00:00:00.000Z").toString(36)}`, + ); + }); + + it("escapes slugs in the card path", () => { + expect(buildResumeSocialCardPath(buildResume({ slug: "steady blue/heron" }))).toContain( + "/steady%20blue%2Fheron/social-card", + ); + }); + + it("builds render URLs against the requesting origin", () => { + expect(buildResumeSocialCardRenderUrl("https://tiny.cv", "SteadyBlueHeron")).toBe( + "https://tiny.cv/internal/social-card/SteadyBlueHeron", + ); + expect(buildResumeSocialCardRenderUrl("http://localhost:3000/", "SteadyBlueHeron")).toBe( + "http://localhost:3000/internal/social-card/SteadyBlueHeron", + ); + }); +}); diff --git a/src/app/_lib/resume-social-card.ts b/src/app/_lib/resume-social-card.ts new file mode 100644 index 0000000..ea96f9d --- /dev/null +++ b/src/app/_lib/resume-social-card.ts @@ -0,0 +1,62 @@ +import "server-only"; + +import { getBrowserTimeoutMs, withBrowserPage } from "@/app/_lib/browser-renderer"; +import type { HostedResumePublicRecord } from "@/app/_lib/hosted-resume-types"; + +export const SOCIAL_CARD_CONTENT_TYPE = "image/png"; + +/** Open Graph's 1.91:1 landscape card, rendered at 2x for high density displays. */ +export const SOCIAL_CARD_LAYOUT = { + deviceScaleFactor: 2, + height: 630, + width: 1200, +} as const; + +export const SOCIAL_CARD_IMAGE_SIZE = { + height: SOCIAL_CARD_LAYOUT.height * SOCIAL_CARD_LAYOUT.deviceScaleFactor, + width: SOCIAL_CARD_LAYOUT.width * SOCIAL_CARD_LAYOUT.deviceScaleFactor, +} as const; + +/** + * How far the 8.5in page is scaled up on the card. It leaves a ~110px gutter on + * each side, and the page runs off the bottom edge so the card shows the top of + * the resume at a readable size rather than a shrunken whole page. + */ +export const SOCIAL_CARD_PAGE_SCALE = 1.2; + +export function buildResumeSocialCardPath(resume: HostedResumePublicRecord) { + return `/${encodeURIComponent(resume.slug)}/social-card?v=${buildSocialCardVersion(resume)}`; +} + +export function buildResumeSocialCardRenderUrl(origin: string, slug: string) { + return new URL( + `/internal/social-card/${encodeURIComponent(slug)}`, + origin.replace(/\/+$/, ""), + ).toString(); +} + +export async function renderResumeSocialCard(renderUrl: string) { + return await withBrowserPage(async (page) => { + await page.setViewport({ + deviceScaleFactor: SOCIAL_CARD_LAYOUT.deviceScaleFactor, + height: SOCIAL_CARD_LAYOUT.height, + width: SOCIAL_CARD_LAYOUT.width, + }); + await page.goto(renderUrl, { + timeout: getBrowserTimeoutMs(), + waitUntil: "networkidle0", + }); + await page.evaluate(() => window.document.fonts.ready.then(() => undefined)); + + return await page.screenshot({ + captureBeyondViewport: false, + type: "png", + }); + }); +} + +function buildSocialCardVersion(resume: HostedResumePublicRecord) { + const timestamp = Date.parse(resume.publishedAt ?? resume.updatedAt); + + return Number.isFinite(timestamp) ? timestamp.toString(36) : "1"; +} diff --git a/src/app/globals.css b/src/app/globals.css index fa0198f..facf663 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -150,6 +150,31 @@ textarea { } } +/* + * Screenshot surface for a resume's social card. The sheet is anchored to the + * top and runs off the bottom edge, so the card shows the head of the resume at + * a readable size instead of a shrunken full page. + */ +.social-card-stage { + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 50% -24%, rgba(15, 118, 110, 0.09), transparent 62%), + linear-gradient(180deg, #fbf8f2 0%, #f1ece3 100%); +} + +.social-card-paper { + position: absolute; + left: 50%; + top: 44px; + width: calc(816px * var(--cv-stage-scale)); + transform: translateX(-50%); +} + +.social-card-paper .cv-sheet { + border-radius: 0.55rem 0.55rem 0 0; +} + .cv-document { height: 100%; position: relative; diff --git a/src/app/internal/social-card/[slug]/page.tsx b/src/app/internal/social-card/[slug]/page.tsx new file mode 100644 index 0000000..083a4c6 --- /dev/null +++ b/src/app/internal/social-card/[slug]/page.tsx @@ -0,0 +1,63 @@ +import type { CSSProperties } from "react"; +import { notFound } from "next/navigation"; +import { ResumeDesktopSheet } from "@/app/_components/resume-live-document"; +import { parseCvMarkdown } from "@/app/_lib/cv-markdown"; +import { + getPublishedResumeBySlug, + HostedResumeStoreUnavailableError, +} from "@/app/_lib/hosted-resume-store"; +import { SOCIAL_CARD_LAYOUT, SOCIAL_CARD_PAGE_SCALE } from "@/app/_lib/resume-social-card"; +import { TINYCV_NOINDEX_METADATA } from "@/app/_lib/site-metadata"; + +export const dynamic = "force-dynamic"; +export const metadata = TINYCV_NOINDEX_METADATA; + +/** + * The surface the social card screenshot is taken from. It renders the same + * sheet the published page and the PDF use, so a card can never drift from the + * resume it links to. + */ +export default async function ResumeSocialCardPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + let resume = null; + + try { + resume = await getPublishedResumeBySlug(slug); + } catch (error) { + if (error instanceof HostedResumeStoreUnavailableError) { + notFound(); + } + + throw error; + } + + if (!resume) { + notFound(); + } + + const document = parseCvMarkdown(resume.markdown); + + return ( +
+
+ +
+ + {/* The screenshot must hold the card and nothing else, including the dev overlay. */} + +
+ ); +}