Skip to content

fix(web): close story OG card layout and image boundary gaps - #192

Merged
duyet merged 4 commits into
masterfrom
fix/og-story-image-hardening-20260925
Sep 25, 2026
Merged

duyet merged 4 commits into
masterfrom
fix/og-story-image-hardening-20260925

Conversation

@duyet

@duyet duyet commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Process note: PR #175 was already merged into master as a9d6701 (2026-09-25T14:41:01Z, by duyetbot) before this follow-up started, so it could not be left as a draft or amended. This branch is based on current master and carries the fixes as a new draft PR. No merge, deploy, settings change, or migration was performed.

Round 3: redirect: "error" is invalid in workerd (production blocker)

Review caught that the boundary passed a redirect mode workerd rejects, so every deployed story OG fetch returned the branded fallback. Fixed, and now covered by a workerd-level smoke so the Node-vs-workerd class of divergence fails before deploy.

Root cause. workerd accepts only "follow" and "manual". Given "error" it throws before any request leaves the edge:

TypeError: Invalid redirect value, must be one of "follow" or "manual"
("error" won't be implemented since it does not make sense at the edge;
 use "manual" and check the response status code)

The boundary caught that and returned null, so every deployed thumbnail silently fell back while the Node unit tests passed — undici accepts "error", so nothing caught it. Measured in real workerd:

redirect mode workerd Node (undici)
"error" throws TypeError, 0 outbound requests accepted
"manual" accepted accepted
"follow" accepted accepted

That is the exact production symptom: {"ok":false,"reason":"threw-TypeError"} with an empty outbound request log.

Fix — redirect: "manual", matching the repo's existing convention:

file mode
worker/clerk-proxy.ts (3 call sites) manual
worker/enrich.ts manual
worker/__tests__/fetch-boundary.test.ts asserts manual
lib/story-og.tsx (was the only "error") manual

A 3xx is never followed, and because a redirect response is never ok, the existing non-OK check turns it into a clean miss without ever reading Location. The byte, pixel, timeout, credential, referrer, MIME, and SSRF boundaries are untouched.

Tests

  • the assertion that pinned redirect: "error" now pins "manual"
  • every 3xx resolves to null — 301/302/303/307/308 all miss, each with exactly one request to the original URL
  • no leak — the Location target, the header itself, and the source URL never reach the rendered fallback
  • a guard that the mode sent is one workerd accepts, so "error" can never be reintroduced

New workerd smoke

apps/web/scripts/story-og-workerd-smoke.ts drives the real boundary inside Miniflare, modelled on the existing clerk-proxy-workerd-smoke.ts (outbound fetcher, no network). 8 checks, all passing:

ok   workerd rejects redirect:error
ok   workerd accepts redirect:manual
ok   workerd accepts redirect:follow
ok   valid image resolves in workerd
ok   3xx resolves to the branded fallback
ok   blocked hosts never reach the network
ok   only the two public image URLs were requested, each once
ok   redirect:error issued no outbound request at all

The 3xx upstream's Location deliberately points at the good image URL, so a followed redirect would show photo.png twice in the outbound log — it appears once.

Proof it catches the blocker. Reintroducing redirect: "error" makes the smoke exit non-zero with the production symptom:

FAIL valid image resolves in workerd -> {"ok":false,"reason":"threw-TypeError"}
FAIL only the two public image URLs were requested, each once -> boundarySeen: []
EXIT with blocker = 1     EXIT with fix = 0

Run with pnpm --filter @aidr/web test:workerd:story-og. The existing test:workerd:clerk-proxy still passes.

Follow-up worth approving separately: neither workerd smoke runs in CI — .github/workflows/ci.yml only runs lint, test, check-types, which is exactly why this shipped. Adding - run: pnpm --filter @aidr/web test:workerd:story-og (and the clerk one) after the Test step would close the gap. I did not touch the shared workflow from this PR; the reviewer may prefer a single CI change covering both.


Round 2: VP8L lossless WebP regression

Review caught that my own readWebp mis-decoded VP8L — the lossless WebP variant — so every simple-VP8L thumbnail was silently rejected.

Root cause. VP8L packs both dimensions into a single 28-bit little-endian run after the 0x2f signature byte: 14 bits of width - 1 at bits 0–13, then 14 bits of height - 1 at bits 14–27, then alpha_is_used and a 3-bit version. The two fields straddle the second and third header bytes. My code read width as a plain 24-bit value and mis-shifted height — with two faults: the 24-bit read folds the top of height into width, and the height expression bound + 1 tighter than |, summing three terms instead of OR-ing them.

real file header bytes 21–24 shipped decode correct
1x1 00 00 00 00 1x7169 1x1
16x16 0f c0 03 00 245776x7169 16x16
300x200 2b c1 31 00 3260716x7169 300x200
1200x630 af 44 9d 00 1918128x7171 1200x630
255x257 fe 00 40 00 255x7170 255x257

Every value blew past the ceiling — a 100 % rejection rate for lossless WebP. Fixed to read the two fields from the correct bit positions, keeping the bounded validation (4096/side, 4,000,000 px, 1 MB). The header is now also proven to lie inside the declared first sub-chunk.

Fixtures are real, not hand-rolled. Nine committed 38-byte simple-VP8L files from libwebp, each decoded back through libwebp before committing: 1x1 · 16x16 · 300x200 · 1200x630 · 1024x13 · 255x257 · 4096x1 (at ceiling) · 4097x1 (past ceiling, must be rejected) · 300x200-alpha (alpha_is_used at bit 28). webpLosslessBytes() reproduces all nine byte for byte, asserted by a test, so the encoder cannot drift from real output. The generator is committed and byte-stable. sharp reaches the workspace via the existing miniflare devDependency — no new dependency, and no test touches the network.

Reverting only the bit layout fails 4 of the new tests, including the real-file acceptance case.


Round 1 summary

Five confirmed defects, each reproduced against the real satori/resvg render pipeline and the real Worker boundary, each covered by a test that fails on the pre-fix behaviour.

  • Bounded long-title layout — the headline now has a real, enforced line clamp with an ellipsis
  • Pixel-dimension ceiling — container headers are parsed and oversized canvases rejected
  • Truncated / garbage-tailed payloads — incomplete containers fall back to the branded panel
  • Trailing-dot hostnames — normalized before the SSRF blocklist runs
  • Timeout clamping and api/og/$id route wiring — focused tests added

The detailed-story redesign, EN/VI copy and category labels, attribution metadata, the byte/time/redirect/credential/referrer/MIME protections, and the zero-source-fetch contract are all preserved.


1. Bounded long-title layout (satori)

The headline set display: "-webkit-box" + WebkitLineClamp: 4 but omitted textOverflow: "ellipsis". Satori's processTextOverflow (src/text/processor.ts) only honours the clamp when the ellipsis is present; without it, return [Infinity].

Measured, same 353-character title, real satori/resvg render:

lines headline ink bbox overlaps wordmark overlaps footer
before 11 x 56..686, y 55..619 yes yes
after 4 x 56..699, y 229..448 no no

Title band is y 119..522 (403 px). The old card painted from y=55 — inside the AI;DR wordmark — down to y=619, through the footer metadata and into the bottom rule. The new card uses 219 px of the band and terminates the 4th line with ….

Fix. Add textOverflow: "ellipsis" alongside the clamp, plus an explicit maxHeight: 233px structural backstop. The same ellipsis was added to the footer host row, where a 90-character host was previously hard-clipped under the engagement counters.

Proof. Reverting just the textOverflow/maxHeight lines fails:

AssertionError: expected 55 to be greater than or equal to 119   // ink over the wordmark
AssertionError: expected 564 to be less than or equal to 233     // ink past the band

The test drives the actual ImageResponse pipeline, decodes the PNG, and diffs against a blanked-title baseline to locate the ink — markup assertions could not catch this.

2. Pixel-dimension ceiling

Only the 1 MB byte cap and leading magic bytes were checked, so a 33-byte file declaring 30000x30000 was accepted. apps/web/src/lib/story-og-image.ts now parses real dimensions from PNG IHDR, JPEG SOF0–SOF3/5–7/9–11/13–15, GIF LSD, and WebP VP8 / VP8L / VP8X, rejecting anything past MAX_STORY_OG_IMAGE_SIDE = 4096 per side or MAX_STORY_OG_IMAGE_PIXELS = 4_000_000 total. The byte ceiling is unchanged.

3. Truncated / undecodable payloads

Magic bytes alone accepted header-valid files with a broken tail, which rendered as an empty gray photo panel. Now requires structurally complete containers: PNG IHDR + terminal IEND, JPEG terminal EOI and an SOF segment, GIF trailer 0x3B, and an exact WebP RIFF size. Validated against real repository images. No decoder and no memory-limit increase.

4. Trailing-dot hostnames

new URL("https://localhost./x").hostname is "localhost.", which slipped past host === "localhost" and the suffix checks. The blocklist was only safe by accident — the shared sanitizeImageUrl happened to catch it. normalizeHostname() now strips all trailing dots before any comparison. Tests cover localhost., LOCALHOST., metadata.google.internal., cdn.internal., foo.local., db.localhost., printer.local., localhost.., and public hosts that must still be allowed.

5. Timeout clamping and route wiring

  • Timeout clamp — a fake-timer test proves the abort fires at the 50 ms floor for timeoutMs: 1 and the 5 s ceiling for timeoutMs: 60_000. Reverting fails: expected null to be 'pending'.
  • api/og/$id — 14 tests covering ?lang=vi → Vietnamese card and Content-Language: vi, ?lang=en → English, unknown/repeated/empty locales defaulting to English without smuggling a second locale, Cache-Control stability per locale, .png suffix stripping, suffix-style slugs, 404/500 paths, exactly one bounded image fetch, and the card never containing the source URL, query string, or signature.

Previews

All four committed artifacts regenerate from a deterministic, licence-clean in-repo synthetic source, so no network is involved and the bytes are stable across runs:

pnpm exec tsx --tsconfig apps/web/tsconfig.json \
  apps/web/scripts/render-og-preview.tsx all docs/assets
artifact sha256
og-story-preview.png (EN + photo) 9f56ed690812707a029f4d340e6d6ae3f6f5a4c660894a1135109150aa577296
og-story-preview-vi.png a096350fd5ccd090c3cdf9d5ecea514264646b7bfc0f9663fd472a33f87ef9fe
og-story-preview-fallback.png 60d32e6d63886f4efe88081be92d97780896be600763bae5b7a85d728357b141
og-story-preview-long-title.png dd89ed435c5dd48856fa79f04480ea8a809c8dd4638b3688f6a0233c82322e91

Re-verified byte-identical after the VP8L and redirect-mode fixes.

Happy path:

EN card with photo

Vietnamese:

VI card

Branded fallback:

Fallback card

353-character headline, clamped to 4 lines:

Long title clamped

Verification

Exact head: dbe57f8d7603ffe22d9ae1adef046230d7e9d253

gate result
CI Lint, test, types on dbe57f8 pass
pnpm run test pass — 151 files / 1,546 tests (web) + 61 extension
pnpm run lint pass
pnpm run check-types pass
pnpm run build (client + SSR) pass
pnpm --filter @aidr/web test:workerd:story-og pass — 8/8, no network
pnpm --filter @aidr/web test:workerd:clerk-proxy pass (unchanged)
git diff --check origin/master...HEAD pass
git merge-base --is-ancestor origin/master HEAD pass
preview regeneration byte-identical across repeated runs
VP8L fixtures byte-identical when regenerated by the committed script
zero-network probe pass — a test replaces globalThis.fetch, asserts blocked hosts issue no request and that only the allowed host reaches fetch, then renders a card and asserts no further requests

OG coverage: 78 focused tests — 31 boundary/copy, 26 container parser, 7 render-level, 14 route wiring.

Remaining live verification

Not verifiable locally; needs a deployed environment:

  • real CDN thumbnails rendering in the deployed /api/og/{id}.png (the boundary is exercised against synthetic, in-repo, and libwebp-generated images here)
  • Content-Language / cache-header behaviour through the real Cloudflare edge
  • the Vietnamese glyph run in EB Garamond at production font weight

Refs #175

Follow-up to #175. Four confirmed defects in the merged story OG card,
each reproduced against the real satori/resvg render pipeline and each
covered by a regression test that fails on the old behaviour.

1. Bounded long-title layout. The headline set `-webkit-box` +
   `WebkitLineClamp: 4` but omitted `textOverflow: "ellipsis"`. Satori's
   `processTextOverflow` only honours the clamp when the ellipsis is
   present, so the box grew unbounded: a 353-character title rendered 11
   lines, painting over the `AI;DR` wordmark, over the footer metadata,
   and off the bottom of the card. Add the ellipsis plus an explicit
   `maxHeight` backstop, and add the same ellipsis to the footer host row
   so a 90-character host degrades instead of being hard-clipped.

2. Pixel-dimension ceiling. Only the 1 MB byte cap and magic bytes were
   checked, so a 33-byte file claiming 30000x30000 reached the renderer.
   Parse real dimensions from PNG IHDR, JPEG SOF, GIF LSD, and WebP
   VP8/VP8L/VP8X, and reject anything past 4096 per side or 4,000,000
   pixels total. The byte ceiling is unchanged.

3. Truncated and garbage-tailed payloads. A header-valid file with a
   broken tail passed and rendered as an empty gray photo panel. Require
   complete container structure (PNG IHDR + IEND, JPEG EOI, GIF trailer,
   exact WebP RIFF size) so those fall back to the branded panel.

4. Trailing-dot hostnames. `new URL("https://localhost./x").hostname` is
   `localhost.`, which slipped past the inline `host === "localhost"` and
   `.internal` suffix checks. Normalize trailing dots before the
   blocklist. `isSafeStoryImageUrl` was only safe because the shared
   `sanitizeImageUrl` happened to catch it; the boundary now stands alone.

Also: export the card's title-band geometry, add focused timeout-clamping
and `api/og/$id` route-wiring tests (locale, `Content-Language`, cache and
id-prefix behaviour), and regenerate the committed previews for the
English, Vietnamese, and fallback cases from a deterministic in-repo
synthetic source so no network is involved.

Preserved: the detailed-story redesign, EN/VI copy and category labels,
attribution metadata, the byte/time/redirect/credential/referrer/MIME
protections, and the zero-source-fetch contract.
Adds a reproducible 353-character headline render so the clamp fix is
visible in review rather than only asserted in a test.
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 52dbaed5-7573-41cf-87ff-e4fbd8ee7f06


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Sep 25, 2026

Copy link
Copy Markdown

Reviewer's Guide

This follow-up hardens the story OG card across rendering, image ingestion, and Worker routing: it clamps long text, validates raster dimensions and structural completeness before inlining, closes trailing-dot SSRF gaps, clamps fetch timeouts, expands route regression coverage, and adds deterministic preview artifacts.

Sequence diagram for bounded story OG image rendering

sequenceDiagram
    participant Client
    participant Route as api/og/$id
    participant Fetcher as fetchStoryOgImage
    participant Validator as readRasterContainer
    participant Renderer as ImageResponse

    Client->>Route: GET /api/og/$id.png?lang=en|vi
    Route->>Fetcher: fetchStoryOgImage(image_url)
    Fetcher->>Validator: readRasterContainer(bytes)
    alt valid bounded complete raster
        Validator-->>Fetcher: RasterContainer
        Fetcher-->>Route: StoryOgImage data URI
    else unsafe, oversized, truncated, or invalid
        Validator-->>Fetcher: null
        Fetcher-->>Route: null
    end
    Route->>Renderer: storyOgCard(story, image, language)
    Renderer-->>Client: PNG card with photo or branded fallback
Loading

Flow diagram for story OG image safety boundary

flowchart TD
    A[Remote image URL] --> B{Safe normalized hostname?}
    B -- No --> F[Branded fallback]
    B -- Yes --> C[Bounded fetch with clamped timeout]
    C --> D{Within 1 MB byte limit?}
    D -- No --> F
    D -- Yes --> E[readRasterContainer]
    E --> G{Complete supported raster and valid dimensions?}
    G -- No --> F
    G -- Yes --> H[Inline as data URI]
    H --> I[Render story OG card]
    F --> I
Loading

Flow diagram for bounded long-title card layout

flowchart TD
    A[Story headline] --> B[Title column]
    B --> C[Four-line WebkitLineClamp]
    C --> D[Ellipsis via textOverflow]
    D --> E[maxHeight structural backstop]
    E --> F[Title band]
    F --> G[Footer and bottom rule remain unobstructed]
Loading

File-Level Changes

Change Details Files
Bound the OG card headline and footer text to prevent layout collisions in Satori renders.
  • Added ellipsis-aware four-line clamping and a max-height backstop to long headlines.
  • Added ellipsis handling for long footer hostnames.
  • Added render-level PNG geometry tests covering title bounds and deterministic output.
apps/web/src/lib/story-og.tsx
apps/web/src/lib/story-og.test.ts
apps/web/src/lib/story-og.render.test.tsx
Hardened remote raster validation before image data is embedded into OG cards.
  • Introduced format-specific PNG, JPEG, GIF, and WebP container parsing with dimension limits.
  • Rejected oversized canvases, truncated payloads, missing terminators, and garbage-tailed files while retaining the 1 MB byte cap.
  • Added reusable synthetic raster fixtures and focused parser/fetch boundary tests.
apps/web/src/lib/story-og-image.ts
apps/web/src/lib/story-og-image.test.ts
apps/web/src/lib/__fixtures__/raster.ts
apps/web/src/lib/story-og.ts
apps/web/src/lib/story-og.test.ts
Strengthened URL and fetch boundary behavior for story thumbnails.
  • Normalized trailing-dot hostnames before SSRF blocklist comparisons.
  • Exported and tested timeout clamping between 50 ms and 5 s.
  • Verified blocked hosts do not fetch and allowed payloads are fetched and inlined without source URL leakage.
apps/web/src/lib/story-og.ts
apps/web/src/lib/story-og.test.ts
apps/web/src/lib/story-og.render.test.tsx
Expanded OG route coverage and made preview generation reproducible.
  • Added route tests for locale handling, cache/content-language headers, ID parsing, errors, image-fetch wiring, and fallback rendering.
  • Added preview modes for fallback, long-title, synthetic source, and batch artifact generation with hashes.
  • Updated changelog copy to describe broken/oversized image and long-headline handling.
apps/web/src/routes/-api-og.test.ts
apps/web/scripts/render-og-preview.tsx
apps/web/src/routes/changelog.tsx
docs/assets/og-story-preview.png
docs/assets/og-story-preview-vi.png
docs/assets/og-story-preview-fallback.png
docs/assets/og-story-preview-long-title.png

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

The VP8L branch of `readWebp` read `width - 1` as a plain 24-bit
little-endian value and mis-shifted `height - 1`. VP8L packs both into a
single 28-bit little-endian run after the 0x2f signature byte: 14 bits of
`width - 1` at bits 0-13, 14 bits of `height - 1` at bits 14-27, then
`alpha_is_used` and the 3-bit version. The two 14-bit fields straddle the
second and third header bytes.

Reading 24 bits folded the top of height into width, so a real 1200x630
lossless file decoded as 1918128x7171 and was then rejected by the pixel
ceiling. Every simple-VP8L thumbnail silently fell back to the branded
panel. The height expression also bound `+ 1` tighter than `|`, summing
three terms instead of OR-ing them.

Now reads the two fields from the correct bit positions and keeps the
existing bounded validation on top: 4096 per side, 4,000,000 pixels total,
1 MB of bytes. Also proves the VP8L header actually lies inside the
declared first sub-chunk, and applies the same minimum-payload check to
the VP8 and VP8X branches.

Fixtures are real lossless WebP files from libwebp (`lossless: true`
through sharp), not hand-rolled: nine 38-byte simple-VP8L files covering
1x1, 16x16, 300x200, 1200x630, 1024x13, 255x257, 4096x1 (at the side
ceiling), 4097x1 (one past it, must be rejected), and an alpha variant.
Each was decoded back through libwebp before being committed, and
`webpLosslessBytes()` reproduces all nine byte for byte, asserted by a
test, so the in-code encoder cannot drift from real output. The generator
is committed and re-runs byte-stable; `sharp` reaches the workspace via the
existing `miniflare` devDependency, so nothing new is required and no test
touches the network.

Reverting only the bit layout fails 4 of the new tests, including the
real-file acceptance case. PNG, JPEG, GIF, VP8, VP8X, truncation,
pixel/byte/time, SSRF, and title-clamp behaviour are unchanged.
workerd accepts only "follow" and "manual" for `redirect`. Given "error"
it throws a TypeError before any request leaves the edge:

  Invalid redirect value, must be one of "follow" or "manual" ("error"
  won't be implemented since it does not make sense at the edge; use
  "manual" and check the response status code)

The boundary caught that and returned null, so every deployed story OG
thumbnail silently fell back to the branded panel while the Node unit
tests passed: undici accepts "error", so the divergence was invisible
until a real workerd run.

Switch to `redirect: "manual"`, matching `worker/clerk-proxy.ts` and
`worker/enrich.ts`, which already use it. A 3xx is never followed, and
because a redirect response is never `ok` the existing non-OK check turns
it into a clean miss without ever reading Location. The byte, pixel,
timeout, credential, referrer, MIME, and SSRF boundaries are untouched.

Tests: the assertion that pinned `redirect: "error"` now pins
`"manual"`, plus three regressions covering 301/302/303/307/308 resolving
to null, the redirect target and Location never being read or leaked into
the rendered fallback, and a guard that the mode sent is one workerd
accepts.

New `scripts/story-og-workerd-smoke.ts` drives the real boundary inside
Miniflare so this class of Node-versus-workerd divergence fails before
deploy. It asserts workerd rejects "error", a valid image resolves in
workerd, a 3xx is a clean miss that is not followed (the Location points
at the good URL, so a follow would show up in the outbound log), and
blocked hosts never reach the network. Reintroducing "error" makes it
fail with `threw-TypeError` and zero outbound requests, which is exactly
the production symptom. Run with
`pnpm --filter @aidr/web test:workerd:story-og`.

VP8L/VP8/VP8X, title clamp, pixel/byte/time, SSRF, locale, and
determinism behaviour is unchanged; the four preview artifacts and the
nine libwebp fixtures still regenerate byte-identically.
@duyet
duyet marked this pull request as ready for review September 25, 2026 17:28

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @duyet, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 4 days and 15 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@duyet
duyet merged commit 7a71163 into master Sep 25, 2026
5 checks passed
duyet added a commit that referenced this pull request Sep 30, 2026
#297)

The homepage uses og-home.jpg since #192 and story links carry ?lang=,
so drive homepage failed on a healthy site.


Claude-Session: https://claude.ai/code/session_01LUvnDNBM63UC4pk8VMMz72

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@duyet
duyet deleted the fix/og-story-image-hardening-20260925 branch October 4, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant