diff --git a/backend/app/services/patreon_client.py b/backend/app/services/patreon_client.py index d968ae8..e732e90 100644 --- a/backend/app/services/patreon_client.py +++ b/backend/app/services/patreon_client.py @@ -38,6 +38,8 @@ FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API. from __future__ import annotations +import base64 +import json import logging import re import time @@ -45,7 +47,7 @@ from collections.abc import Iterator from dataclasses import dataclass from html import unescape from pathlib import Path -from urllib.parse import parse_qs, urlsplit +from urllib.parse import parse_qs, unquote, urlsplit import requests @@ -165,6 +167,27 @@ class MediaItem: 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: # Delegate to the shared extractor (utils.paths) so capture-time persistence # 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) @staticmethod diff --git a/tests/test_patreon_client.py b/tests/test_patreon_client.py index 2f4a990..379290d 100644 --- a/tests/test_patreon_client.py +++ b/tests/test_patreon_client.py @@ -19,6 +19,7 @@ from backend.app.services.patreon_client import ( PatreonAuthError, PatreonClient, PatreonDriftError, + is_blurred_preview, 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}} ) 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'

', + }, "relationships": {}} + items = client.extract_media(post, {}) + assert [m.url for m in items] == [SHARP]