Files
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

507 lines
17 KiB
PHP

<?php
declare(strict_types=1);
final class BlueskyThreadsExtension extends Minz_Extension {
private const API_BASE = 'https://public.api.bsky.app/xrpc';
private const POST_URL_PATTERN = '~bsky\.app/profile/([^/]+)/post/([^/?#\s]+)~';
// How long a cached thread is considered fresh, based on the post's age.
// Posts older than FREEZE_AGE are never re-fetched.
private const FREEZE_AGE = 7 * 86400; // 7 days
private const REFRESH_AGES = [
3600 => 600, // post < 1 h old → refresh cache after 10 min
86400 => 3600, // post < 24 h old → refresh cache after 1 h
self::FREEZE_AGE => 43200, // post < 7 d old → refresh cache after 12 h
];
#[\Override]
public function init(): void {
parent::init();
// EntryBeforeInsert stores the initial thread in the DB so API sync
// clients (Fever, GReader, etc.) receive enriched content immediately.
$this->registerHook('entry_before_insert', [$this, 'fetchThread']);
// EntryBeforeDisplay re-checks staleness on every web render and writes
// refreshed content back to the DB so API clients also get updates.
$this->registerHook('entry_before_display', [$this, 'refreshThread']);
}
#[\Override]
public function handleConfigureAction(): void {
if (Minz_Request::isPost()) {
$depth = max(1, min(1000, (int) Minz_Request::paramString('depth') ?: 10));
$this->setUserConfigurationValue('depth', $depth);
}
}
// -------------------------------------------------------------------------
// Hook callbacks
// -------------------------------------------------------------------------
/**
* EntryBeforeInsert — runs once when a new entry is first saved to the DB.
* Fetches the thread and stores enriched HTML so API sync clients get it.
*/
public function fetchThread(FreshRSS_Entry $entry): FreshRSS_Entry {
$url = $entry->link();
if (!preg_match(self::POST_URL_PATTERN, $url, $m)) {
return $entry;
}
[, $handle, $rkey] = $m;
$html = $this->loadThread($handle, $rkey, $url);
if ($html !== null) {
$entry->_content($html);
}
return $entry;
}
/**
* EntryBeforeDisplay — runs on every web render.
* Re-fetches if the cached thread is stale and writes the result back to
* the DB so API sync clients also receive the updated content.
*/
public function refreshThread(FreshRSS_Entry $entry): FreshRSS_Entry {
$url = $entry->link();
if (!preg_match(self::POST_URL_PATTERN, $url, $m)) {
return $entry;
}
[, $handle, $rkey] = $m;
$cache = $this->readCache($url);
$publishedAt = (int) $entry->date(true);
if (!$this->needsRefetch($publishedAt, $cache['fetched_at'] ?? null)) {
// Cache is fresh — serve without an API call.
if ($cache !== null) {
$entry->_content($cache['html']);
}
return $entry;
}
$html = $this->loadThread($handle, $rkey, $url, $cache['html'] ?? null);
if ($html !== null) {
$entry->_content($html);
// Write back to DB so API sync clients receive the refreshed thread.
try {
FreshRSS_Factory::createEntryDao()->updateEntry($entry);
} catch (Throwable) {
// Non-fatal: the web UI still shows fresh content; DB update
// will be retried on the next stale render.
}
}
return $entry;
}
// -------------------------------------------------------------------------
// Shared fetch logic
// -------------------------------------------------------------------------
/**
* Resolves the handle, calls the API, renders the thread, updates the cache,
* and returns the HTML. Returns $fallback (null by default) on any failure.
*/
private function loadThread(string $handle, string $rkey, string $url, ?string $fallback = null): ?string {
$did = str_starts_with($handle, 'did:') ? $handle : $this->resolveHandle($handle);
if ($did === null) {
return $fallback;
}
$atUri = "at://{$did}/app.bsky.feed.post/{$rkey}";
$depth = (int) ($this->getUserConfigurationValue('depth') ?: 10);
$data = $this->apiGet('app.bsky.feed.getPostThread', [
'uri' => $atUri,
'depth' => $depth,
'parentHeight' => 0,
]);
if ($data === null || !isset($data['thread'])) {
return $fallback;
}
$rootAuthorDid = $data['thread']['post']['author']['did'] ?? null;
$html = $this->renderThread($data['thread'], true, $rootAuthorDid);
$this->writeCache($url, $html);
return $html;
}
// -------------------------------------------------------------------------
// Cache helpers
// -------------------------------------------------------------------------
private function cacheDir(): string {
$base = defined('DATA_PATH') ? DATA_PATH : sys_get_temp_dir();
return rtrim((string) $base, '/') . '/BlueskyThreads';
}
private function cacheFile(string $url): string {
return $this->cacheDir() . '/' . md5($url) . '.json';
}
/** @return array{html:string,fetched_at:int}|null */
private function readCache(string $url): ?array {
$file = $this->cacheFile($url);
if (!is_file($file)) {
return null;
}
$raw = file_get_contents($file);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) && isset($data['html'], $data['fetched_at']) ? $data : null;
}
private function writeCache(string $url, string $html): void {
$dir = $this->cacheDir();
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents(
$this->cacheFile($url),
json_encode(['html' => $html, 'fetched_at' => time()], JSON_UNESCAPED_UNICODE)
);
}
/**
* Returns true if we should call the API again.
*
* @param int $publishedAt Unix timestamp of the post.
* @param int|null $fetchedAt Unix timestamp of the last cache write, or null if never cached.
*/
private function needsRefetch(int $publishedAt, ?int $fetchedAt): bool {
if ($fetchedAt === null) {
return true;
}
$postAge = time() - $publishedAt;
$cacheAge = time() - $fetchedAt;
if ($postAge >= self::FREEZE_AGE) {
return false; // Thread old enough to be considered complete.
}
foreach (self::REFRESH_AGES as $maxPostAge => $minCacheAge) {
if ($postAge < $maxPostAge) {
return $cacheAge >= $minCacheAge;
}
}
return false;
}
// -------------------------------------------------------------------------
// Bluesky API helpers
// -------------------------------------------------------------------------
private function resolveHandle(string $handle): ?string {
$data = $this->apiGet('com.atproto.identity.resolveHandle', ['handle' => $handle]);
return isset($data['did']) ? (string) $data['did'] : null;
}
/**
* Performs a GET against the Bluesky XRPC API and decodes the JSON body.
*
* Uses cURL rather than file_get_contents(): since FreshRSS 1.30.0 every
* stream wrapper except file:// and php:// is unregistered at boot as an
* SSRF mitigation (lib_rss.php, unregister_unsafe_protocols()), so the
* https:// wrapper is simply not available to extensions any more.
*
* Returns null on any failure, logging why — a silent null here means
* posts quietly render without their thread, which is hard to spot.
*/
private function apiGet(string $method, array $params): ?array {
$url = self::API_BASE . '/' . $method . '?' . http_build_query($params);
$ch = curl_init($url);
if ($ch === false) {
$this->logWarning("{$method}: could not initialise cURL");
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_USERAGENT => 'FreshRSS-BlueskyThreads/0.1',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
if (!is_string($body)) {
$this->logWarning("{$method}: request failed ({$error})");
return null;
}
if ($status < 200 || $status >= 300) {
$this->logWarning("{$method}: HTTP {$status}");
return null;
}
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
$this->logWarning("{$method}: response was not valid JSON");
return null;
}
return $decoded;
}
/** Records a non-fatal API problem in the FreshRSS log, when available. */
private function logWarning(string $message): void {
if (class_exists('Minz_Log')) {
Minz_Log::warning('[BlueskyThreads] ' . $message);
}
}
// -------------------------------------------------------------------------
// Thread rendering
// -------------------------------------------------------------------------
private function renderThread(array $node, bool $isRoot, ?string $rootAuthorDid = null): string {
if (!isset($node['post'])) {
return '';
}
$html = $this->renderPost($node['post'], $isRoot);
if (!empty($node['replies'])) {
$repliesHtml = '';
foreach ($node['replies'] as $reply) {
if (!isset($reply['post'])) {
continue;
}
if ($rootAuthorDid !== null && ($reply['post']['author']['did'] ?? null) !== $rootAuthorDid) {
continue;
}
$repliesHtml .= $this->renderThread($reply, false, $rootAuthorDid);
}
if ($repliesHtml !== '') {
$html .= '<div class="bsky-replies" style="border-left:2px solid #e1e8ed;margin-left:24px;padding-left:12px;">';
$html .= $repliesHtml;
$html .= '</div>';
}
}
return $html;
}
private function renderPost(array $post, bool $isRoot): string {
$author = $post['author'] ?? [];
$record = $post['record'] ?? [];
$embed = $post['embed'] ?? null;
$handle = $author['handle'] ?? '';
$text = $record['text'] ?? '';
$facets = $record['facets'] ?? [];
$textHtml = nl2br($this->applyFacets($text, $facets));
$embedHtml = $embed !== null ? $this->renderEmbed($embed) : '';
$rootStyle = 'border:1px solid #cfd9de;border-radius:12px;padding:12px 16px;margin-bottom:12px;';
$replyStyle = 'padding:10px 12px;margin:8px 0;';
$style = $isRoot ? $rootStyle : $replyStyle;
return <<<HTML
<div class="bsky-post" style="{$style}">
<div class="bsky-text" style="white-space:pre-wrap;word-break:break-word;">{$textHtml}</div>
{$embedHtml}
</div>
HTML;
}
// -------------------------------------------------------------------------
// Embed rendering
// -------------------------------------------------------------------------
private function renderEmbed(array $embed): string {
$type = preg_replace('/#view$/', '', $embed['$type'] ?? '');
switch ($type) {
case 'app.bsky.embed.images':
return $this->renderImages($embed['images'] ?? []);
case 'app.bsky.embed.external':
return $this->renderExternal($embed['external'] ?? []);
case 'app.bsky.embed.record':
return $this->renderQuotedRecord($embed['record'] ?? []);
case 'app.bsky.embed.recordWithMedia':
return $this->renderEmbed($embed['media'] ?? [])
. $this->renderQuotedRecord($embed['record']['record'] ?? []);
case 'app.bsky.embed.video':
return $this->renderVideo($embed);
default:
return '';
}
}
private function renderImages(array $images): string {
if (empty($images)) {
return '';
}
$cols = count($images) === 1 ? '1fr' : '1fr 1fr';
$inner = '';
foreach ($images as $img) {
$thumb = $this->e($img['thumb'] ?? '');
$full = $this->e($img['fullsize'] ?? $img['thumb'] ?? '');
$alt = $this->e($img['alt'] ?? '');
if ($thumb === '') {
continue;
}
$inner .= "<a href=\"{$full}\" target=\"_blank\" rel=\"noopener\">"
. "<img src=\"{$thumb}\" alt=\"{$alt}\" style=\"width:100%;height:180px;object-fit:cover;border-radius:8px;display:block;\">"
. '</a>';
}
return "<div style=\"display:grid;grid-template-columns:{$cols};gap:4px;margin-top:8px;\">{$inner}</div>";
}
private function renderExternal(array $ext): string {
$uri = $this->e($ext['uri'] ?? '');
$title = $this->e($ext['title'] ?? $ext['uri'] ?? '');
$desc = $this->e($ext['description'] ?? '');
$thumb = $this->e($ext['thumb'] ?? '');
if ($uri === '') {
return '';
}
$thumbHtml = $thumb !== ''
? "<img src=\"{$thumb}\" alt=\"\" style=\"width:100%;height:140px;object-fit:cover;border-radius:8px 8px 0 0;display:block;\">"
: '';
return <<<HTML
<div style="border:1px solid #cfd9de;border-radius:12px;overflow:hidden;margin-top:8px;">
{$thumbHtml}
<div style="padding:10px 12px;">
<div style="font-size:0.8em;color:#536471;margin-bottom:2px;">{$uri}</div>
<a href="{$uri}" target="_blank" rel="noopener" style="font-weight:600;text-decoration:none;">{$title}</a>
<div style="font-size:0.85em;color:#536471;margin-top:2px;">{$desc}</div>
</div>
</div>
HTML;
}
private function renderQuotedRecord(array $record): string {
if (empty($record)) {
return '';
}
if (!empty($record['notFound'])) {
return '<div style="border:1px solid #cfd9de;border-radius:12px;padding:10px 12px;margin-top:8px;color:#536471;">[Post not found]</div>';
}
if (!empty($record['blocked'])) {
return '<div style="border:1px solid #cfd9de;border-radius:12px;padding:10px 12px;margin-top:8px;color:#536471;">[Post from blocked account]</div>';
}
$author = $record['author'] ?? [];
$value = $record['value'] ?? [];
$embeds = $record['embeds'] ?? [];
$handle = $author['handle'] ?? '';
$displayName = $this->e($author['displayName'] ?? $handle);
$text = nl2br($this->applyFacets($value['text'] ?? '', $value['facets'] ?? []));
$rkey = basename(rtrim($record['uri'] ?? '', '/'));
$postUrl = 'https://bsky.app/profile/' . rawurlencode($handle) . '/post/' . $rkey;
$embedsHtml = '';
foreach ($embeds as $embed) {
$embedsHtml .= $this->renderEmbed($embed);
}
return <<<HTML
<blockquote style="border:1px solid #0085ff;border-radius:12px;padding:10px 12px;margin:8px 0 0 0;">
<div style="font-size:0.85em;margin-bottom:6px;">
<strong>{$displayName}</strong>
<a href="{$this->e($postUrl)}" style="color:#536471;margin-left:4px;text-decoration:none;">@{$this->e($handle)}</a>
</div>
<div style="white-space:pre-wrap;word-break:break-word;">{$text}</div>
{$embedsHtml}
</blockquote>
HTML;
}
private function renderVideo(array $embed): string {
$thumb = $this->e($embed['thumbnail'] ?? '');
$playlist = $this->e($embed['playlist'] ?? '');
$alt = $this->e($embed['alt'] ?? 'Video');
$thumbHtml = $thumb !== ''
? "<img src=\"{$thumb}\" alt=\"{$alt}\" style=\"max-width:100%;border-radius:8px;\">"
: '';
$playlistHtml = $playlist !== ''
? "<div style=\"margin-top:4px;font-size:0.8em;\"><a href=\"{$playlist}\" target=\"_blank\" rel=\"noopener\">▶ Watch video</a></div>"
: '';
return "<div style=\"margin-top:8px;\">{$thumbHtml}{$playlistHtml}</div>";
}
// -------------------------------------------------------------------------
// Rich text: apply facets (mentions, links, hashtags)
// -------------------------------------------------------------------------
/**
* Converts a Bluesky post's plain text + facets into HTML with working links.
* Facets use UTF-8 byte offsets, so we work on the raw bytes.
*/
private function applyFacets(string $text, array $facets): string {
if (empty($facets)) {
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
usort($facets, static fn($a, $b) => ($a['index']['byteStart'] ?? 0) <=> ($b['index']['byteStart'] ?? 0));
$result = '';
$bytePos = 0;
foreach ($facets as $facet) {
$start = (int) ($facet['index']['byteStart'] ?? 0);
$end = (int) ($facet['index']['byteEnd'] ?? 0);
if ($start < $bytePos || $end <= $start) {
continue;
}
$result .= htmlspecialchars(substr($text, $bytePos, $start - $bytePos), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$slice = substr($text, $start, $end - $start);
$feature = $facet['features'][0] ?? [];
$ftype = $feature['$type'] ?? '';
$escaped = htmlspecialchars($slice, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
if ($ftype === 'app.bsky.richtext.facet#link') {
$href = $this->e($feature['uri'] ?? '#');
$result .= "<a href=\"{$href}\" target=\"_blank\" rel=\"noopener\">{$escaped}</a>";
} elseif ($ftype === 'app.bsky.richtext.facet#mention') {
$did = $this->e($feature['did'] ?? '');
$result .= "<a href=\"https://bsky.app/profile/{$did}\">{$escaped}</a>";
} elseif ($ftype === 'app.bsky.richtext.facet#tag') {
$tag = $this->e($feature['tag'] ?? ltrim($slice, '#'));
$result .= "<a href=\"https://bsky.app/hashtag/{$tag}\">{$escaped}</a>";
} else {
$result .= $escaped;
}
$bytePos = $end;
}
$result .= htmlspecialchars(substr($text, $bytePos), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
return $result;
}
// -------------------------------------------------------------------------
// Utility
// -------------------------------------------------------------------------
private function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}