CI / test (pull_request) Successful in 40s
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]>
51 lines
4.1 KiB
Markdown
51 lines
4.1 KiB
Markdown
# 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 config** — `depth` (int, 1–1000, 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 cURL** — `apiGet()` 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 swallowed** — `apiGet()` 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
|
||
|
||
- Thread endpoint: `app.bsky.feed.getPostThread` — params: `uri` (AT-URI), `depth` (0–1000), `parentHeight` (0–1000)
|
||
- Handle resolution: `com.atproto.identity.resolveHandle` — param: `handle`
|
||
- Docs: https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread
|
||
|
||
## FreshRSS extension docs
|
||
|
||
https://freshrss.github.io/FreshRSS/en/developers/03_Backend/05_Extensions.html
|