Files
xExtension-BlueskyThreads/CLAUDE.md
T
KydoimosandClaude Opus 5 583c489074
CI / test (pull_request) Successful in 40s
Use cURL for Bluesky API calls instead of file_get_contents
FreshRSS 1.30.0 added unregister_unsafe_protocols() to lib/lib_rss.php,
which unregisters every PHP stream wrapper except file:// and php:// at
boot as an SSRF mitigation. That removed the https:// wrapper, so the
stream-context-based fetch in apiGet() stopped working entirely — every
API call failed with "Unable to find the wrapper https".

Because the call site used @file_get_contents(), the failure was silent:
apiGet() returned null, loadThread() fell through to its null fallback,
and entries were saved with their unenriched RSS content. The visible
symptom was Bluesky posts rendering with no thread and no embeds, with
nothing in the logs to explain it.

Switch apiGet() to cURL, which does not go through stream wrappers (and
is how FreshRSS fetches feeds itself, which is why feed retrieval kept
working throughout). Connect timeout added and redirect-following
explicitly disabled — a public XRPC endpoint has no reason to redirect,
and following redirects would reopen an SSRF vector.

Also stop swallowing failures: transport errors, non-2xx responses and
malformed JSON are now reported via Minz_Log::warning() so this class of
breakage is visible next time.

Tests: add testApiGetWorksWithoutHttpsStreamWrapper, which unregisters
the https wrapper before calling the API and so fails against the old
implementation, plus testApiGetLogsWarningOnFailure. Add a Minz_Log stub
and curl to the CI extension list.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 10:32:55 -03:00

4.1 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

A FreshRSS extension that enriches Bluesky posts in RSS feeds by fetching the full reply thread and embedded post content via the Bluesky public API, collapsing everything into a single article.

Extension structure

FreshRSS expects the extension directory name to start with x. Required files:

File Purpose
metadata.json Extension metadata (name, entrypoint are required)
extension.php Main class — must be named {entrypoint}Extension extends Minz_Extension
configure.phtml Optional settings form; submitted values handled by handleConfigureAction()

The extension is installed by dropping this directory into FreshRSS's extensions/ folder.

How it works

Two hooks work in tandem so both the web UI and API sync clients (Fever, GReader, etc.) receive enriched content:

  1. EntryBeforeInsert (fetchThread) — fires once when a new entry is first saved to the DB. Fetches the thread immediately and stores it, so API clients get enriched content from the very first sync.
  2. EntryBeforeDisplay (refreshThread) — fires on every web render. Checks the file cache for staleness; if stale, re-fetches and calls FreshRSS_Factory::createEntryDao()->updateEntry($entry) to write the refreshed HTML back to the DB, keeping API clients up to date.
  3. Staleness rules (needsRefetch): posts ≥ 7 days old are frozen. Fresher posts use progressively tighter refresh windows — 10 min (< 1 h old), 1 h (< 24 h old), 12 h (< 7 d old).
  4. Cache: JSON files in DATA_PATH/BlueskyThreads/{md5(url)}.json, each containing {html, fetched_at}. On API failure, the stale cache is served as a fallback.
  5. Handle → DID: GET https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={handle} (skipped if the handle is already a did: URI).
  6. Thread fetch: GET https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://{did}/app.bsky.feed.post/{rkey}&depth={depth}&parentHeight=0
  7. Rendering: renderThread() walks the recursive threadViewPost structure. Root post uses a bordered card style; replies are indented under a left border. Each post renders its richtext facets (links, mentions, hashtags) and embeds (images, external links, quoted posts, video).

Key implementation details

  • Facets — Bluesky richtext uses UTF-8 byte offsets (byteStart/byteEnd). applyFacets() walks the raw PHP byte string directly rather than converting to characters first.
  • Embed type normalisation — the API returns $type values like app.bsky.embed.images#view; the #view suffix is stripped before the switch statement.
  • User configdepth (int, 11000, default 10) is stored via setUserConfigurationValue/getUserConfigurationValue and read in handleConfigureAction() on POST.
  • No auth required — all requests go to public.api.bsky.app and need no credentials.
  • HTTP transport must be cURLapiGet() uses curl_*, not file_get_contents(). FreshRSS ≥ 1.30.0 calls unregister_unsafe_protocols() in lib/lib_rss.php at boot, which unregisters every stream wrapper except file:// and php:// as an SSRF mitigation. Any remote file_get_contents()/fopen() in an extension therefore fails with "Unable to find the wrapper https". testApiGetWorksWithoutHttpsStreamWrapper guards this by unregistering the wrapper before calling the API.
  • Failures are logged, not swallowedapiGet() reports non-2xx responses, transport errors and bad JSON via Minz_Log::warning() (prefixed [BlueskyThreads]). Silent nulls make the extension look like it simply renders posts without threads, which is very hard to notice.

Bluesky API reference

FreshRSS extension docs

https://freshrss.github.io/FreshRSS/en/developers/03_Backend/05_Extensions.html