From 8243740a04edf6f810dd17afbc6cbcef0582e835 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 18:18:04 -0400 Subject: [PATCH] fix(subscribestar): inject 18_plus_agreement_generic age cookie to bypass server gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-flagged 2026-05-27: subscribestar source check aborted with `AbortExtraction: HTTP redirect to .../age_confirmation_warning`. The captured `_personalization_id` cookie in the browser-stored file had expired (annual rotation), and the user could not realistically refresh it: SubscribeStar's frontend JS uses localStorage to suppress the age-confirmation popup once dismissed, so a logged-in revisit doesn't re-show the popup and the server-side cookie is never re-issued. gallery-dl's own login flow (which FC doesn't exercise — cookies come from the extension instead) sidesteps this by manually setting `18_plus_agreement_generic=true` on `.subscribestar.adult`. The server accepts that as the age-confirmation marker. `credential_service._augment_cookies(platform, netscape)` mirrors that behavior: when the materialized cookies file is for subscribestar and the age cookie isn't already present, append a synthetic line for `.subscribestar.adult` with name=`18_plus_agreement_generic` value=`true` and a far-future expiry. No-op for other platforms; no-op if the cookie is already present (idempotent for manual pastes / extension captures that happen to include it). Three new tests pin: (a) injection fires for subscribestar, preserves existing cookies; (b) idempotent when already present (no double injection); (c) does NOT fire for non-subscribestar platforms (Patreon etc. don't get a foreign-domain cookie). Not a curator handling bug per se — the extension faithfully captured what the browser had. This is mirroring a documented gallery-dl workaround so the cookies-via-extension auth path doesn't degrade as the server-side cookie expires. --- backend/app/services/credential_service.py | 40 +++++++++++++++++ tests/test_credential_service.py | 51 ++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/backend/app/services/credential_service.py b/backend/app/services/credential_service.py index a2cd324..e50026a 100644 --- a/backend/app/services/credential_service.py +++ b/backend/app/services/credential_service.py @@ -148,6 +148,7 @@ class CredentialService: return None plaintext = self.crypto.decrypt(row.encrypted_blob) netscape = _to_netscape(plaintext) + netscape = _augment_cookies(platform, netscape) self.cookies_dir.mkdir(parents=True, exist_ok=True) out = self.cookies_dir / f"{platform}_cookies.txt" out.write_text(netscape) @@ -163,6 +164,45 @@ class CredentialService: return self.crypto.decrypt(row.encrypted_blob) +def _augment_cookies(platform: str, netscape: str) -> str: + """Inject platform-specific synthetic cookies needed to bypass server + gates that the user can't realistically re-trigger in their browser. + + subscribestar.adult: the server gates artist pages behind the + `_personalization_id` age-confirmation cookie. The site's frontend JS + uses localStorage to suppress the age popup once dismissed, so after + the cookie's annual expiry the user can't easily get a fresh one — + visiting the site in a logged-in session doesn't re-show the popup + and doesn't re-issue the cookie. gallery-dl's own login flow (which + FC doesn't use; we capture cookies via the extension instead) + sidesteps this by manually setting `18_plus_agreement_generic=true` + on `.subscribestar.adult` — the server accepts that as the + age-confirmation marker. Mirror that behavior here so cookies-only + auth works long-term. + + The injection is a no-op if the cookie is already present (operator + might have it from an earlier login or a manual cookies.txt paste). + + Operator-flagged 2026-05-27 after a subscribestar source check + aborted with `HTTP redirect to .../age_confirmation_warning`. + """ + if platform != "subscribestar": + return netscape + if "18_plus_agreement_generic" in netscape: + return netscape + # Far-future expiry — 10 years out. The server only checks presence/value; + # gallery-dl's own login flow sets this with no explicit expiry too. + expiry = 4102444800 # 2100-01-01 UTC, opaque "far future" + line = "\t".join([ + ".subscribestar.adult", "TRUE", "/", "TRUE", + str(expiry), "18_plus_agreement_generic", "true", + ]) + body = netscape.rstrip("\n") + if not body: + body = "# Netscape HTTP Cookie File" + return body + "\n" + line + "\n" + + def _to_netscape(plaintext: str) -> str: """Accept either Netscape-format text (the extension's output) or a JSON array of cookie dicts (a manual-paste edge case); produce diff --git a/tests/test_credential_service.py b/tests/test_credential_service.py index 4d85f41..32ee71f 100644 --- a/tests/test_credential_service.py +++ b/tests/test_credential_service.py @@ -110,6 +110,57 @@ async def test_get_cookies_path_none_for_token_kind(db, crypto, tmp_path): assert await svc.get_cookies_path("discord") is None +@pytest.mark.asyncio +async def test_get_cookies_path_subscribestar_injects_age_cookie(db, crypto, tmp_path): + """SubscribeStar's server gates artist pages behind a _personalization_id + cookie; the browser-stored cookie expires annually and can't be easily + refreshed (the JS age popup is suppressed by localStorage). Mirror + gallery-dl's own login-flow workaround by injecting + `18_plus_agreement_generic=true` on `.subscribestar.adult` whenever + cookies for subscribestar are materialized.""" + netscape_in = ( + "# Netscape HTTP Cookie File\n" + ".subscribestar.adult\tTRUE\t/\tTRUE\t1700000000\tsession_id\txyz\n" + ) + svc = CredentialService(db, crypto, cookies_dir=tmp_path / "cookies") + await svc.upsert(platform="subscribestar", credential_type="cookies", data=netscape_in) + path = await svc.get_cookies_path("subscribestar") + contents = path.read_text() + assert "18_plus_agreement_generic\ttrue" in contents + assert ".subscribestar.adult" in contents + # Original session_id cookie preserved. + assert "session_id\txyz" in contents + + +@pytest.mark.asyncio +async def test_get_cookies_path_subscribestar_idempotent_when_present(db, crypto, tmp_path): + """If the operator's captured cookies ALREADY contain the age cookie + (e.g. a manual paste, or a re-login), don't double-inject.""" + netscape_in = ( + "# Netscape HTTP Cookie File\n" + ".subscribestar.adult\tTRUE\t/\tTRUE\t1700000000\t18_plus_agreement_generic\ttrue\n" + ".subscribestar.adult\tTRUE\t/\tTRUE\t1700000000\tsession_id\txyz\n" + ) + svc = CredentialService(db, crypto, cookies_dir=tmp_path / "cookies") + await svc.upsert(platform="subscribestar", credential_type="cookies", data=netscape_in) + path = await svc.get_cookies_path("subscribestar") + contents = path.read_text() + assert contents.count("18_plus_agreement_generic") == 1 + + +@pytest.mark.asyncio +async def test_get_cookies_path_non_subscribestar_unchanged(db, crypto, tmp_path): + """The age-cookie injection MUST NOT fire for non-subscribestar + platforms — Patreon/HF/etc. don't need it and shouldn't carry a + foreign-domain cookie in their cookies.txt.""" + svc = CredentialService(db, crypto, cookies_dir=tmp_path / "cookies") + await svc.upsert(platform="patreon", credential_type="cookies", data=_NETSCAPE) + path = await svc.get_cookies_path("patreon") + contents = path.read_text() + assert "18_plus_agreement_generic" not in contents + assert "subscribestar" not in contents + + @pytest.mark.asyncio async def test_get_token_decrypts(db, crypto): svc = CredentialService(db, crypto)