diff --git a/docs/changelog/2026-09-19-s19-imagem-do-cartao-no-origem-do-deploy.md b/docs/changelog/2026-09-19-s19-imagem-do-cartao-no-origem-do-deploy.md new file mode 100644 index 00000000..5eb13a94 --- /dev/null +++ b/docs/changelog/2026-09-19-s19-imagem-do-cartao-no-origem-do-deploy.md @@ -0,0 +1 @@ +**S19 — Cartão de compartilhamento: imagem no origem do deploy (2026-09-19):** o `resolveShareLinkOgImageUrl` passa a absolutizar a mídia (`/api/media/file/…`, o proxy servido pelo próprio app) contra `resolveDeploymentOrigin` — novo em `src/utilities/seo.ts`, com `NEXT_PUBLIC_SITE_URL` na frente (o build de produção o inlina, mesmo carrier do `isStagingSite`) e fallback no `siteUrl` do global `metadata` —, nunca contra o domínio canônico do global. Motivo: o global aponta para `https://jorgesolla.com.br`, hoje servido pelo WordPress antigo, que responde 404 no caminho da mídia; o crawler do WhatsApp lia título/descrição do HTML de `jorgesolla1313.com.br/plenaria-cultura` mas não encontrava a imagem, e o cartão saía só com texto (caso real da Plenária da Cultura de 19/09). Canonical e `og:url` seguem a cadeia global-first documentada em `docs/plans/build-resiliente-metadata-ausente.md`; só a URL do asset muda. Cobertura: unit do novo resolver (env vence, fallback, vazio) e int pinando o env sobre um `URL` de global estrangeiro. Sem migration, Consent ou access. diff --git a/src/utilities/seo.ts b/src/utilities/seo.ts index e5949944..8128818e 100644 --- a/src/utilities/seo.ts +++ b/src/utilities/seo.ts @@ -58,6 +58,23 @@ const resolveSiteUrl = (source: SiteMetadataSource): string | null => { return null } +/** + * Public origin of the running deployment — the only host guaranteed to serve + * this app's own asset paths (`/api/media/file/…`, the media proxy). Real + * deployments always carry `NEXT_PUBLIC_SITE_URL` (staging/production fail the + * deploy without it; worktrees and CI set their local origin; the production + * build inlines it, same carrier as `isStagingSite`), while the `metadata` + * global `URL` can be a canonical content domain served by another platform. + * Asset URLs (OG images) must use this origin; canonical URLs keep the + * global-first chain of `resolveSiteMetadata`. + */ +export const resolveDeploymentOrigin = (fallbackSiteUrl: string | null): string | null => { + const fromEnv = nonEmpty(process.env.NEXT_PUBLIC_SITE_URL) + if (fromEnv) return stripTrailingSlash(fromEnv) + + return fallbackSiteUrl +} + const flattenKeywords = (source: SiteMetadataSource): string[] => (source.keywords ?? []) .map((entry) => nonEmpty(typeof entry === 'string' ? entry : entry?.keyword)) diff --git a/src/utilities/shareLinkReads.ts b/src/utilities/shareLinkReads.ts index b6e7b519..d2203bc7 100644 --- a/src/utilities/shareLinkReads.ts +++ b/src/utilities/shareLinkReads.ts @@ -3,7 +3,7 @@ import 'server-only' import { getCachedDocumentById } from '@/utilities/documentReads' import { getCollectionListingTag } from '@/utilities/documents' import { getCachedGlobal } from '@/utilities/globalReads' -import { resolveSiteMetadata, toAbsoluteUrl } from '@/utilities/seo' +import { resolveDeploymentOrigin, resolveSiteMetadata, toAbsoluteUrl } from '@/utilities/seo' import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' import { getPayload } from 'payload' @@ -50,22 +50,28 @@ const mediaOf = (value: Media | number | null | undefined): Media | null => /** * The card image: the link's own upload first; without it the site default * image of the `metadata` global — never a relative URL the WhatsApp crawler - * cannot resolve. Same fallback contract as `/corte/[id]` and the root layout; - * `siteUrl` comes from `resolveSiteMetadata` (empty global never crashes). + * cannot resolve. Media lives behind this app's `/api/media/file/…` proxy, so + * the absolute URL is built on the deployment origin (`NEXT_PUBLIC_SITE_URL`), + * not on the `metadata` global `URL`: the global can be a canonical domain + * served by another platform (today the legacy WordPress), which 404s the + * proxy path and makes WhatsApp drop the thumbnail. The global `siteUrl` is + * the fallback when the env is missing. */ export const resolveShareLinkOgImageUrl = async (link: ShareLink): Promise => { const globalMetadata = await getCachedGlobal('metadata')() const { siteUrl } = resolveSiteMetadata(globalMetadata) + const origin = resolveDeploymentOrigin(siteUrl) + if (!origin) return null const configured = mediaOf(link.image)?.url - if (configured && siteUrl) return toAbsoluteUrl(configured, siteUrl) + if (configured) return toAbsoluteUrl(configured, origin) const fallback = typeof globalMetadata.image === 'number' ? await getCachedDocumentById('media', String(globalMetadata.image))() : globalMetadata.image const fallbackUrl = mediaOf(fallback)?.url - if (!fallbackUrl || !siteUrl) return null + if (!fallbackUrl) return null - return toAbsoluteUrl(fallbackUrl, siteUrl) + return toAbsoluteUrl(fallbackUrl, origin) } diff --git a/tests/int/shareLink.int.spec.ts b/tests/int/shareLink.int.spec.ts index 6d94b832..3914b041 100644 --- a/tests/int/shareLink.int.spec.ts +++ b/tests/int/shareLink.int.spec.ts @@ -255,7 +255,7 @@ describe('shareLink', () => { expect(revalidateTagMock).toHaveBeenCalledWith('shareLinks') }) - it('resolves the configured image and falls back to the site default', async () => { + it('absolutizes the image against the deployment origin and falls back to the site default', async () => { const media = await payload.create({ collection: 'media', data: { alt: 'Imagem do link' }, @@ -282,7 +282,10 @@ describe('shareLink', () => { id: withImage.id, depth: 1, }) - getCachedGlobalMock.mockReturnValue(async () => ({})) + // The global carries the canonical domain (served elsewhere, e.g. the legacy + // WordPress): the media proxy only exists on the deployment origin, so the + // image URL must come from `NEXT_PUBLIC_SITE_URL`, never from `URL`. + getCachedGlobalMock.mockReturnValue(async () => ({ URL: 'https://canonical.example' })) expect(await resolveShareLinkOgImageUrl(populated)).toBe(expectedUrl) const withoutImage = await createShareLink({ @@ -295,6 +298,7 @@ describe('shareLink', () => { expect(await resolveShareLinkOgImageUrl(withoutImage)).toBeNull() getCachedGlobalMock.mockReturnValue(async () => ({ + URL: 'https://canonical.example', image: { url: '/api/media/file/site-default.png' }, })) expect(await resolveShareLinkOgImageUrl(withoutImage)).toBe( diff --git a/tests/unit/siteMetadata.unit.spec.ts b/tests/unit/siteMetadata.unit.spec.ts index 9c2059f5..20248dcf 100644 --- a/tests/unit/siteMetadata.unit.spec.ts +++ b/tests/unit/siteMetadata.unit.spec.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it } from 'vitest' -import { SITE_METADATA_DEFAULTS, absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo' +import { + SITE_METADATA_DEFAULTS, + absoluteSitePath, + resolveDeploymentOrigin, + resolveSiteMetadata, +} from '@/utilities/seo' const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL @@ -101,3 +106,25 @@ describe('absoluteSitePath', () => { expect(absoluteSitePath('https://example.com', 'noticia')).toBe('https://example.com/noticia') }) }) + +describe('resolveDeploymentOrigin', () => { + it('prefers NEXT_PUBLIC_SITE_URL over the global fallback and strips trailing slashes', () => { + process.env.NEXT_PUBLIC_SITE_URL = 'https://deploy.example///' + + expect(resolveDeploymentOrigin('https://global.example')).toBe('https://deploy.example') + }) + + it('falls back to the global site URL when env is missing or blank', () => { + delete process.env.NEXT_PUBLIC_SITE_URL + expect(resolveDeploymentOrigin('https://global.example')).toBe('https://global.example') + + process.env.NEXT_PUBLIC_SITE_URL = ' ' + expect(resolveDeploymentOrigin('https://global.example')).toBe('https://global.example') + }) + + it('returns null when neither source has a usable URL', () => { + delete process.env.NEXT_PUBLIC_SITE_URL + + expect(resolveDeploymentOrigin(null)).toBeNull() + }) +})