diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index f8fea43..25cb03a 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -35,9 +35,14 @@ class InvalidUrlError(Exception): # reviewers catch drift. _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ ("patreon", re.compile( + # Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/` + # (the "creator workspace" URL served once subscribed, see + # patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a + # creator's inner page still derives the slug. Nav pages stay excluded. r"^https?://(?:www\.)?patreon\.com/" - r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)" - r"(?P[^/?#]+)/?$", + r"(?:cw/|c/)?" + r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))" + r"(?P[^/?#]+)", re.IGNORECASE, )), ("subscribestar", re.compile( diff --git a/extension/lib/platforms.js b/extension/lib/platforms.js index 3a9ed2a..2f37d93 100644 --- a/extension/lib/platforms.js +++ b/extension/lib/platforms.js @@ -86,7 +86,16 @@ const PLATFORMS = { * script to decide whether to show the floating "Add as source" button. */ const PLATFORM_ARTIST_PATTERNS = { - patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i, + // Patreon serves the same creator under three URL shapes (see backend + // patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and + // `cw/` "creator workspace" — the last is the URL you land on once you're + // SUBSCRIBED, which is exactly when the button matters. Match all three, and + // drop the single-segment end-anchor so a creator's inner page + // (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages + // (home/search/…/posts permalink) stay excluded. Mirrors extension_service + // ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button + // vanished once subscribed because the old pattern only matched the bare root). + patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i, subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i, hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i, deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i, diff --git a/extension/manifest.json b/extension/manifest.json index fc930bc..c5f12c9 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "FabledCurator", - "version": "1.0.7", + "version": "1.0.8", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "browser_specific_settings": { diff --git a/extension/package.json b/extension/package.json index 08a4f53..0f96df1 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "fabledcurator-extension", - "version": "1.0.7", + "version": "1.0.8", "private": true, "description": "Firefox extension for FabledCurator", "scripts": { diff --git a/tests/test_extension_url_derive.py b/tests/test_extension_url_derive.py new file mode 100644 index 0000000..b1819be --- /dev/null +++ b/tests/test_extension_url_derive.py @@ -0,0 +1,65 @@ +"""Unit tests for ExtensionService._derive — the URL → (platform, slug) +parser that gates the browser extension's "Add as source" button and pulls +the creator slug on probe/add. + +Regression cover for #1485: Patreon serves the same creator under three URL +shapes — bare `patreon.com/Atole`, `c/`, and `cw/` (the "creator workspace" +URL you land on once SUBSCRIBED). The button used to vanish while subscribed +because the pattern only matched the bare root and excluded `c/`. + +_derive is pure URL parsing (no DB / no async), so a session-less instance is +fine to exercise directly. +""" + +import pytest + +from backend.app.services.extension_service import ( + ExtensionService, + InvalidUrlError, + UnknownPlatformError, +) + +_svc = ExtensionService(None) + + +@pytest.mark.parametrize( + "url, slug", + [ + # All three Patreon creator prefixes resolve to the same vanity slug. + ("https://www.patreon.com/Atole", "Atole"), + ("https://www.patreon.com/c/Atole", "Atole"), + ("https://www.patreon.com/cw/Atole", "Atole"), # subscribed-view URL + # A creator's inner page still derives the slug (trailing sub-path). + ("https://www.patreon.com/cw/Atole/posts", "Atole"), + ("https://www.patreon.com/Atole/membership", "Atole"), + ("https://patreon.com/c/Atole", "Atole"), # bare host, no www + ], +) +def test_derive_patreon_creator_urls(url, slug): + platform, got = _svc._derive(url) + assert platform == "patreon" + assert got == slug + + +@pytest.mark.parametrize( + "url", + [ + # Patreon's own nav pages must never read as a creator slug. + "https://www.patreon.com/home", + "https://www.patreon.com/settings", + "https://www.patreon.com/search", + "https://www.patreon.com/messages", + "https://www.patreon.com/library", + "https://www.patreon.com/notifications", + "https://www.patreon.com/posts/12345", # post permalink + "https://www.patreon.com/settings/billing", # nav sub-page + ], +) +def test_derive_patreon_nav_pages_rejected(url): + with pytest.raises(UnknownPlatformError): + _svc._derive(url) + + +def test_derive_rejects_missing_scheme(): + with pytest.raises(InvalidUrlError): + _svc._derive("patreon.com/Atole")