You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Sign uploaded blog cover addresses and keep draft covers private - #127
Addresses the two review comments Copilot left on #126 just before it merged. Both are about /api/notion-cover, the route that serves a blog post's cover when it was uploaded into Notion rather than taken from the stock library. No published post uses an uploaded cover today, so nothing on livepeer.org changes visibly.
Changes
Signed cover addresses. The route used to take any well-formed page ID and ask Notion about it, so random IDs or made-up versions could use up the Notion rate limit that every Notion-backed page shares. Cover addresses now end in <version>.<signature>, an HMAC over the page ID and version (lib/media-signature.ts), and the route checks it before calling Notion. This is what /api/notion-media already does. The signature goes in the path rather than a query string because next/image refuses a local image address with a query string unless images.localPatterns allows it. An out-of-date version still redirects to the current, signed address.
No draft covers on livepeer.org. The route served a draft post's uploaded cover to anyone who had its page ID. In production it now refuses drafts, as the blog does (isPublished in lib/blog.ts). Previews and local dev still show drafts for review.
Testing
Called the route directly with a counter on requests to Notion:
Request
Result
Notion calls
Unsigned
404
0
Forged signature
404
0
Correctly signed
Reaches Notion (404 here because that post's cover is from the stock library)
1
/blog, a post page and its share image still render. Typecheck and lint pass.
/api/notion-cover took any well-formed page id and asked Notion about it,
so random ids or invented versions could spend the Notion rate limit every
Notion-backed page shares. The address now carries a signature over the
page id and version, in the path as <version>.<signature> (next/image
refuses a local query string without localPatterns), checked before Notion
is called, as /api/notion-media already does.
It also served a draft post's uploaded cover to anyone holding its id. In
production a draft's cover is now refused, as the draft is; previews and
dev still show drafts for review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The route split its last segment on dots and ignored anything after the
second, so one signed address could be padded into endless distinct ones,
each a cache miss and a Notion call. It now requires the whole segment to
be <12 hex>.<24 hex>.
The production check refused only an explicit Draft, so an empty or
unexpected Status, which the blog reader rejects, still served the cover.
It now serves only an explicit Published.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A signed address could still be respelled into many: both routes accepted
uppercase and dashed ids, which normalise to the same signature, and
ignored extra query parameters, so each variant was a cache miss and a
Notion call. Both now accept only the address the site writes: the id as
32 lowercase hex, and for media a query of s=<24 hex> alone, for covers no
query at all. Media links are written in that form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Enforce canonical raw pathname to prevent cache-key aliases
app/api/notion-media/[id]/route.ts:46
This route has the same remaining cache-key alias through its path: route params are decoded, so a valid ID with one or more characters percent-encoded still passes BLOCK_ID and the signature while using a distinct URL that can trigger another Notion request. Reject requests whose raw pathname is not the single canonical path emitted by uploadedMediaPath.
The canonical checks only inspect decoded route params. Percent-encoded path aliases such as encoding any hex character (or the dot in the signed segment) decode to the same id/segment, pass the signature, but remain distinct request URLs and cache keys, so one valid cover URL can still be expanded into many Notion calls. Compare the raw URL pathname with the exact generated path before calling Notion, just as the query string is compared exactly.
Next decodes the path before a route sees it, so a percent-encoded
character (%61 for a, %2E for the dot) passed every check on the decoded
params as a distinct address and another Notion call. Both routes now
rebuild the address they expect and compare it with the raw request path
before anything else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the two review comments Copilot left on #126 just before it merged. Both are about
/api/notion-cover, the route that serves a blog post's cover when it was uploaded into Notion rather than taken from the stock library. No published post uses an uploaded cover today, so nothing on livepeer.org changes visibly.Changes
<version>.<signature>, an HMAC over the page ID and version (lib/media-signature.ts), and the route checks it before calling Notion. This is what/api/notion-mediaalready does. The signature goes in the path rather than a query string because next/image refuses a local image address with a query string unlessimages.localPatternsallows it. An out-of-date version still redirects to the current, signed address.isPublishedinlib/blog.ts). Previews and local dev still show drafts for review.Testing
Called the route directly with a counter on requests to Notion:
/blog, a post page and its share image still render. Typecheck and lint pass.🤖 Generated with Claude Code