fix(social-feed): capas do Instagram voltam a carregar e legendas longas não esticam o card

As mídias da Graph API passaram a vir de *.fbcdn.net
(instagram.<pop>.fna.fbcdn.net), host fora do images.remotePatterns — o
otimizador respondia 400 e toda capa do Instagram quebrava. O pattern em
new URL() também pina search:'' e rejeitaria as URLs assinadas mesmo com o
host certo; agora são objetos planos (cdninstagram + fbcdn).

O título do card ganha line-clamp-3 (subtítulo, line-clamp-2) para a
legenda do Instagram não esticar o bento.

Testes: guard unitário do allowlist com URLs assinadas reais casadas pelo
matcher do próprio Next + seam parser→allowlist; e2e com legenda longa no
stub e asserção de -webkit-line-clamp; manifest de e2e afetado passa a
mapear o board de conteúdos para o spec frontend.
This commit is contained in:
Francisco Solla 2026-09-11 16:52:45 -03:00
parent e481819d15
commit a83b6dd50e
5 changed files with 93 additions and 5 deletions

View file

@ -14,8 +14,15 @@ const nextConfig = {
new URL('https://i.ytimg.com/**'),
// Instagram media thumbnails (campaign home content board, S3) come from
// the Graph API's CDN (hosts vary per region) —
// see `src/utilities/socialFeed/instagramFeed.ts`.
new URL('https://*.cdninstagram.com/**'),
// see `src/utilities/socialFeed/instagramFeed.ts`. Plain objects (not
// `new URL`) on purpose: the URL form pins `search: ''`, and Graph API
// media URLs carry a signed query string — the optimizer would reject
// them with 400 even on an allowed host.
{ protocol: 'https', hostname: '*.cdninstagram.com', pathname: '/**' },
// Meta moved Instagram Graph API media to `*.fbcdn.net`
// (`instagram.<pop>.fna.fbcdn.net`, `scontent.<pop>.fna.fbcdn.net`) —
// without this the optimizer answers 400 and every IG cover breaks.
{ protocol: 'https', hostname: '**.fbcdn.net', pathname: '/**' },
// e2e-only: the YouTube/Instagram stubs (tests/e2e/*-stub.mjs) serve the
// fixture thumbnails locally so specs never touch the real network.
{ protocol: 'http', hostname: 'localhost', pathname: '/thumbs/**' },

View file

@ -56,14 +56,16 @@ export const CampaignContentCard = ({ card, featured = false }: CampaignContentC
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<h3
className={`leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
className={`line-clamp-3 leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
featured ? 'text-base' : 'text-sm'
}`}
>
{card.title}
</h3>
{featured && card.subtitle ? (
<p className="text-xs leading-relaxed text-(--campaign-muted)">{card.subtitle}</p>
<p className="line-clamp-2 text-xs leading-relaxed text-(--campaign-muted)">
{card.subtitle}
</p>
) : null}
<span className="mt-auto text-xs text-(--campaign-muted)">{card.meta}</span>
</div>

View file

@ -1263,6 +1263,15 @@ test.describe('Campaign home content section', () => {
// Instagram cards render the 1:1 cover of the approved draft.
await expect(featured.locator('.aspect-square').first()).toBeVisible()
// Long captions are clamped with an ellipsis so a single post cannot
// stretch the bento row (production captions run into hundreds of
// characters; the stub's reel caption is deliberately long).
const longCaptionTitle = section
.getByRole('link', { name: /E2e Reel da caravana/ })
.locator('h3')
await expect(longCaptionTitle).toHaveCSS('-webkit-line-clamp', '3')
await expect(longCaptionTitle).toHaveCSS('overflow', 'hidden')
await expect(
section.getByRole('link', { name: /E2e Artigo no mix IG/ }).first(),
).toBeVisible()

View file

@ -36,7 +36,13 @@ const POSTS = [
},
{
id: 'e2e-ig-reel-2',
caption: 'E2e Reel da caravana',
// Deliberately long: the card must clamp the caption (line-clamp) instead
// of stretching the bento row — production captions run into hundreds of
// characters.
caption:
'E2e Reel da caravana — legenda longa de propósito para o card não esticar. ' +
'A caminhada segue pelo interior da Bahia, com caravanas, encontros, ' +
'reuniões e muito trabalho de base em cada município visitado. '.repeat(4),
media_type: 'REEL',
minutesAgo: 90,
},

View file

@ -0,0 +1,64 @@
// @vitest-environment node
import { matchRemotePattern } from 'next/dist/shared/lib/match-remote-pattern'
import { describe, expect, it } from 'vitest'
import { parseInstagramMediaResponse } from '@/utilities/socialFeed/instagramFeed'
import nextConfig from '../../next.config.mjs'
/**
* Regression guard for the production incident of 2026-09-11: Instagram Graph
* API media URLs come from Meta's CDN on `*.fbcdn.net`
* (`instagram.<pop>.fna.fbcdn.net`, `scontent.<pop>.fna.fbcdn.net`), not only
* from `*.cdninstagram.com` as the S3 implementation assumed. A media URL
* outside `images.remotePatterns` makes the Next image optimizer answer
* `400 "url" parameter is not allowed` and every Instagram card renders with a
* broken cover. The e2e Instagram stub serves thumbnails from localhost (the
* sandbox never reaches the real CDN), so it can never catch a missing real
* host — this guard pins the allowlist against the hosts production serves.
*
* The fixtures carry the signed query string (`?stp=...&_nc_ohc=...&oe=...`)
* the Graph API actually returns: a pattern declared with `new URL()` pins
* `search: ''` and the matcher then rejects every signed URL even on an
* allowed host.
*/
const remotePatterns = nextConfig.images?.remotePatterns ?? []
const isAllowed = (url: string): boolean =>
remotePatterns.some((pattern) => matchRemotePattern(pattern, new URL(url)))
describe('next.config images.remotePatterns', () => {
it.each([
'https://instagram.fssa2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E',
'https://scontent.fsdu2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E',
'https://scontent.cdninstagram.com/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E',
'https://i.ytimg.com/vi/e2evideo001/hqdefault.jpg',
])('allows the real media CDN URL %s', (url) => {
expect(isAllowed(url)).toBe(true)
})
it('keeps unrelated hosts out of the optimizer allowlist', () => {
expect(isAllowed('https://evil.example.com/tracker.jpg')).toBe(false)
})
it('allows the thumbnail the Instagram parser emits from a real signed response', () => {
const signed =
'https://instagram.fssa2-1.fna.fbcdn.net/v/t51.82787-15/798590021_n.jpg?stp=dst-jpg_e35_tt6&_nc_ohc=GhQvEtCG8Bk&oe=6AAA3F4E'
const posts = parseInstagramMediaResponse({
data: [
{
id: 'seam-1',
caption: null,
media_type: 'IMAGE',
media_url: signed,
permalink: 'https://www.instagram.com/p/seam-1/',
timestamp: '2026-09-11T10:00:00+00:00',
},
],
})
const thumbnailUrl = posts[0]?.thumbnailUrl
expect(thumbnailUrl).toBe(signed)
expect(isAllowed(thumbnailUrl as string)).toBe(true)
})
})