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
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:
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user