fix: a blurred Patreon preview embedded in a viewable post is not downloaded
CI and images / extension-version (push) Successful in 2s
CI and images / lint (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 22s
CI and images / backend-lint-and-test (push) Failing after 51s
CI and images / integration (push) Successful in 2m34s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped

A post the account can view can show media from a post it can't, such as a
public teaser carrying a paid post's image. Patreon serves that media blurred,
and the post-level gate (current_user_can_view) lets it through. The CDN URL
says so in its render options ({"b": 70, ...}), so extract_media now drops any
item whose URL asks for a blur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 22:18:12 -05:00
co-authored by Claude Opus 5.5
parent f2e4f94a96
commit 2e76ad89fd
2 changed files with 53 additions and 1 deletions
+29 -1
View File
@@ -38,6 +38,8 @@ FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
from __future__ import annotations from __future__ import annotations
import base64
import json
import logging import logging
import re import re
import time import time
@@ -45,7 +47,7 @@ from collections.abc import Iterator
from dataclasses import dataclass from dataclasses import dataclass
from html import unescape from html import unescape
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, unquote, urlsplit
import requests import requests
@@ -165,6 +167,27 @@ class MediaItem:
post_id: str post_id: str
# Patreon's CDN URLs carry a base64 JSON segment of render options, such as
# eyJ3Ijo2MjB9 for {"w":620}. A locked preview is rendered blurred and says so
# with a "b" (blur) option. A post the account can view can still embed media
# from a post it can't, such as a public teaser showing a paid post's image,
# and that media arrives blurred even though the post itself isn't gated.
_RENDER_OPTIONS_RE = re.compile(r"/(eyJ[A-Za-z0-9_\-]+(?:=|%3D)*)(?:/|$)")
def is_blurred_preview(url: str) -> bool:
"""True when a Patreon CDN URL is a blurred locked preview."""
m = _RENDER_OPTIONS_RE.search(urlsplit(url).path)
if not m:
return False
token = unquote(m.group(1))
try:
options = json.loads(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)))
except (ValueError, TypeError):
return False
return isinstance(options, dict) and bool(options.get("b"))
def _filehash(url: str) -> str | None: def _filehash(url: str) -> str | None:
# Delegate to the shared extractor (utils.paths) so capture-time persistence # Delegate to the shared extractor (utils.paths) so capture-time persistence
# and render-time inline-image matching use the EXACT same identity. # and render-time inline-image matching use the EXACT same identity.
@@ -479,6 +502,11 @@ class PatreonClient:
) )
) )
blurred = [i for i in items if is_blurred_preview(i.url)]
if blurred:
log.info("Patreon post %s: skipped %d blurred locked preview(s)",
post_id, len(blurred))
items = [i for i in items if not is_blurred_preview(i.url)]
return _dedup_by_filehash(items) return _dedup_by_filehash(items)
@staticmethod @staticmethod
+24
View File
@@ -19,6 +19,7 @@ from backend.app.services.patreon_client import (
PatreonAuthError, PatreonAuthError,
PatreonClient, PatreonClient,
PatreonDriftError, PatreonDriftError,
is_blurred_preview,
parse_cursor_from_url, parse_cursor_from_url,
) )
@@ -459,3 +460,26 @@ def test_post_is_gated_viewable_and_missing_are_not_gated():
{"attributes": {"current_user_can_view": None}} {"attributes": {"current_user_can_view": None}}
) is False ) is False
assert PatreonClient.post_is_gated({}) is False assert PatreonClient.post_is_gated({}) is False
# A locked preview's render options carry a blur ("b"); an ordinary one doesn't.
BLURRED = ("https://c10.patreonusercontent.com/4/patreon-media/p/post/174446666/"
"743caff4c51648c185e8347271e1a7b7/eyJiIjo3MCwidyI6MTA4MH0%3D/1.png?token-hash=x")
SHARP = ("https://c10.patreonusercontent.com/4/patreon-media/p/post/174446666/"
"843caff4c51648c185e8347271e1a7b7/eyJ3IjoxMDgwfQ%3D%3D/1.png?token-hash=x")
def test_a_blurred_locked_preview_is_recognised_by_its_render_options():
assert is_blurred_preview(BLURRED)
assert not is_blurred_preview(SHARP)
assert not is_blurred_preview("https://example.com/eyJnot-json/1.png")
assert not is_blurred_preview("https://example.com/plain/1.png")
def test_a_viewable_post_embedding_a_blurred_preview_skips_just_that_image(client):
post = {"id": "174448839", "type": "post", "attributes": {
"current_user_can_view": True,
"content": f'<p><img src="{BLURRED}"></p><p><img src="{SHARP}"></p>',
}, "relationships": {}}
items = client.extract_media(post, {})
assert [m.url for m in items] == [SHARP]