Files
NopeCharon bee3194ea0 [GridView] Fetch Open Graph images with the FreshRSS HTTP client (#559)
The optional Open Graph thumbnail fetching downloaded article pages with
file_get_contents() on an http(s) URL. Since FreshRSS 1.30.0, every stream
wrapper except file:// and php:// is unregistered at startup, so that call
always fails and no og:image / twitter:image is ever found, both when
entries are inserted and through the asynchronous AJAX endpoint.

Use FreshRSS_http_Util::httpGet() instead, like the core favicon lookup.
It goes through cURL and also applies the system proxy and cURL options,
the internal-host (SSRF) checks, Retry-After handling and the HTML cache.
The previous 5-second timeout is kept, and relative image URLs are now
resolved against the URL reached after redirects.

FreshRSS_http_Util::httpGet() exists since FreshRSS 1.28.0, so raise the
declared compatibility accordingly and bump the version to 1.1.2.
2026-10-27 08:26:41 +01:00

504 lines
16 KiB
PHP

<?php
final class GridViewExtension extends Minz_Extension {
/** @var int Default number of columns */
private const DEFAULT_COLUMNS = 3;
/** @var int Minimum columns allowed */
private const MIN_COLUMNS = 2;
/** @var int Maximum columns allowed */
private const MAX_COLUMNS = 4;
/** @var string Default sort field: publication date */
private const DEFAULT_SORT = 'date';
/** @var string Default sort direction: newest first */
private const DEFAULT_SORT_ORDER = 'DESC';
/**
* @throws Minz_PermissionDeniedException
*/
#[\Override]
public function init(): void {
parent::init();
// Load CSS and JS
Minz_View::appendStyle($this->getFileUrl('grid.css'));
Minz_View::appendScript($this->getFileUrl('grid.js'));
// Register translations
$this->registerTranslates();
// Set default sorting to Publication Date, newest first (if enabled)
if ($this->isSortByPublicationDateEnabled()) {
$this->applyDefaultSort();
}
// Inject column count and settings as JS variable
$this->registerHook('js_vars', $this->injectJsVars(...));
// Inject thumbnail URL into content so it's available in all view modes
// (Reading View doesn't render the .item.thumbnail element)
$this->registerHook('entry_before_display', $this->injectThumbnailMarker(...));
// Fetch OG image for entries that have no thumbnail (user-configurable)
if ($this->isOgImageFetchEnabled()) {
$this->registerHook('entry_before_insert', $this->fetchOgImage(...));
// Instead of blocking page render to fetch OG images, inject a
// marker that the JS picks up and fetches asynchronously.
$this->registerHook('entry_before_display', $this->markEntryForOgFetch(...));
}
}
/**
* Read a single user configuration value.
*
* Uses the array-based configuration API for compatibility with
* FreshRSS 1.28.x and earlier, which lack the typed getters.
*
* @return mixed
*/
private function cfgValue(string $key): mixed {
/** @phpstan-ignore method.deprecated */
return $this->getUserConfigurationValue($key);
}
/**
* Read the full user configuration array.
*
* @return array<string, mixed>
*/
private function cfgAll(): array {
/** @phpstan-ignore method.deprecated */
return $this->getUserConfiguration();
}
/**
* Persist the full user configuration array.
*
* @param array<string, mixed> $config
*/
private function cfgSave(array $config): void {
/** @phpstan-ignore method.deprecated */
$this->setUserConfiguration($config);
}
/**
* Apply default sort order: Publication Date, newest first (9->1).
* Only sets it once; the user's explicit choice via the FreshRSS UI
* is preserved by checking the extension's own 'sort_applied' flag.
*
* @throws Minz_PermissionDeniedException
*/
private function applyDefaultSort(): void {
if ($this->cfgValue('sort_applied') === true) {
return;
}
try {
$userConf = FreshRSS_Context::userConf();
$userConf->_attribute('sort', self::DEFAULT_SORT);
$userConf->_attribute('sort_order', self::DEFAULT_SORT_ORDER);
$userConf->save();
// Mark as applied so we don't override the user's future changes
$config = $this->cfgAll();
$config['sort_applied'] = true;
$this->cfgSave($config);
} catch (\Throwable $e) {
Minz_Log::warning('GridView: Failed to set default sort order: ' . $e->getMessage());
}
}
/**
* Inject JavaScript variables for grid configuration
* @param array<string, mixed> $vars
* @return array<string, mixed>
*/
public function injectJsVars(array $vars): array {
$columnsValue = $this->cfgValue('columns');
$columns = is_numeric($columnsValue) ? (int) $columnsValue : self::DEFAULT_COLUMNS;
if ($columns < self::MIN_COLUMNS || $columns > self::MAX_COLUMNS) {
$columns = self::DEFAULT_COLUMNS;
}
$vars['gridview'] = [
'columns' => $columns,
'placeholderUrl' => $this->getFileUrl('placeholder.jpg'),
'ogFetchUrl' => './?c=extension&a=configure&e=' . urlencode($this->getName()),
'showMobileMenuButton' => $this->isMobileMenuButtonEnabled(),
'stickyNavEnabled' => $this->isStickyNavEnabled(),
'shareMenuHtml' => $this->buildShareMenuHtml(),
];
return $vars;
}
/**
* Build the share menu HTML template with placeholders.
* Reader view (article.phtml) doesn't render entry_share_menu, so
* we generate it here and pass it to JS for use in grid cards.
* Placeholders: --entryId--, --link--, --titleText--
* @return string
*/
private function buildShareMenuHtml(): string {
if (!FreshRSS_Auth::hasAccess()) {
return '';
}
try {
$sharing = FreshRSS_Context::userConf()->sharing;
if (empty($sharing) || !is_array($sharing)) {
return '';
}
} catch (\Throwable $e) {
return '';
}
$html = '<ul class="dropdown-menu">';
$html .= '<li class="dropdown-header">' . _t('index.share') . '</li>';
foreach ($sharing as $shareOptions) {
$share = FreshRSS_Share::get($shareOptions['type'] ?? '');
if ($share === null) {
continue;
}
$shareOptions['id'] = '--entryId--';
$shareOptions['link'] = '--link--';
$shareOptions['title'] = '--titleText--';
$share->update($shareOptions);
if ('GET' === $share->method()) {
$html .= '<li class="item share">';
$html .= '<a target="_blank" rel="noreferrer" href="'
. htmlspecialchars($share->url(), ENT_QUOTES, 'UTF-8')
. '" data-type="' . htmlspecialchars($share->type(), ENT_QUOTES, 'UTF-8')
. '">' . htmlspecialchars($share->name() ?? '', ENT_QUOTES, 'UTF-8') . '</a>';
$html .= '</li>';
} else {
$html .= '<li class="item share">';
$html .= '<a href="POST">' . htmlspecialchars($share->name() ?? '', ENT_QUOTES, 'UTF-8') . '</a>';
$html .= '<form method="POST" action="'
. htmlspecialchars($share->url(), ENT_QUOTES, 'UTF-8')
. '" disabled="disabled"><input type="hidden" value="--link--" name="'
. htmlspecialchars($share->field() ?? '', ENT_QUOTES, 'UTF-8') . '"/></form>';
$html .= '</li>';
}
}
$html .= '</ul>';
return $html;
}
/**
* Handle configuration form submission
*/
#[\Override]
public function handleConfigureAction(): void {
// Handle AJAX OG image fetch request (returns JSON and exits)
if (Minz_Request::paramString('ajax') === 'og') {
$this->ajaxFetchOgImage();
return;
}
parent::init();
$this->registerTranslates();
if (Minz_Request::isPost()) {
$columns = Minz_Request::paramInt('columns');
// Validate column count
if ($columns < self::MIN_COLUMNS) {
$columns = self::MIN_COLUMNS;
} elseif ($columns > self::MAX_COLUMNS) {
$columns = self::MAX_COLUMNS;
}
$fetchOgImage = Minz_Request::paramBoolean('fetch_og_image');
$sortByDate = Minz_Request::paramBoolean('sort_by_date');
$mobileMenuButton = Minz_Request::paramBoolean('mobile_menu_button');
$stickyNav = Minz_Request::paramBoolean('sticky_nav');
// Reset sort_applied flag when sort_by_date is toggled so it
// re-applies (or stops applying) on the next page load
$sortApplied = $this->cfgValue('sort_applied') === true;
// setUserConfiguration expects an array of all config values
$this->cfgSave([
'columns' => $columns,
'fetch_og_image' => $fetchOgImage,
'sort_by_date' => $sortByDate,
'sort_applied' => $sortByDate ? $sortApplied : false,
'mobile_menu_button' => $mobileMenuButton,
'sticky_nav' => $stickyNav,
]);
}
}
/**
* Get current column configuration
* @return int
*/
public function getColumns(): int {
$value = $this->cfgValue('columns');
$columns = is_numeric($value) ? (int) $value : self::DEFAULT_COLUMNS;
if ($columns < self::MIN_COLUMNS || $columns > self::MAX_COLUMNS) {
return self::DEFAULT_COLUMNS;
}
return $columns;
}
/**
* Whether sort by publication date is enabled in user configuration.
* @return bool
*/
public function isSortByPublicationDateEnabled(): bool {
$value = $this->cfgValue('sort_by_date');
return $value === true || $value === 1 || $value === '1';
}
/**
* Whether the mobile menu button is enabled in user configuration.
* @return bool
*/
public function isMobileMenuButtonEnabled(): bool {
$value = $this->cfgValue('mobile_menu_button');
return $value === true || $value === 1 || $value === '1';
}
/**
* Whether the sticky navigation bar is enabled in user configuration.
* @return bool
*/
public function isStickyNavEnabled(): bool {
$value = $this->cfgValue('sticky_nav');
return $value === true || $value === 1 || $value === '1';
}
/**
* Whether OG image fetching is enabled in user configuration.
* @return bool
*/
public function isOgImageFetchEnabled(): bool {
// Default to false (opt-in) so existing users aren't surprised by slower page loads
$value = $this->cfgValue('fetch_og_image');
return $value === true || $value === 1 || $value === '1';
}
/**
* Inject a hidden thumbnail marker into the entry content.
* Reading View doesn't render the .item.thumbnail element, so the
* JS grid code can't find the thumbnail. This adds a hidden span
* with the URL so getThumbnail() can pick it up in any view.
*/
public function injectThumbnailMarker(FreshRSS_Entry $entry): FreshRSS_Entry {
$thumbnail = $entry->thumbnail(true);
if (empty($thumbnail['url'])) {
return $entry;
}
$url = htmlspecialchars($thumbnail['url'], ENT_QUOTES, 'UTF-8');
$marker = '<span class="gridview-thumbnail-marker" data-thumbnail="' . $url . '" style="display:none"></span>';
$entry->_content($marker . $entry->content(false));
return $entry;
}
/**
* Fetch Open Graph image for entries that have no thumbnail.
* Called when a new entry is being inserted into the database.
*
* @throws Minz_PermissionDeniedException
*/
public function fetchOgImage(FreshRSS_Entry $entry): FreshRSS_Entry {
return $this->ensureThumbnail($entry);
}
/**
* Mark entries that have no thumbnail so the JS can fetch OG images
* asynchronously. This replaces the synchronous fetchOgImageOnDisplay
* to avoid blocking page rendering.
*/
public function markEntryForOgFetch(FreshRSS_Entry $entry): FreshRSS_Entry {
$thumbnail = $entry->thumbnail(true);
if (!empty($thumbnail['url']) || $entry->attributeBoolean('og_image_checked')) {
return $entry;
}
// Don't mark if the content already has images (the JS card
// builder will extract them directly)
if (preg_match('/<img\s[^>]*src\s*=\s*["\']https?:\/\//i', $entry->content(false))) {
return $entry;
}
$link = $entry->link();
if (empty($link) || !str_starts_with($link, 'http')) {
return $entry;
}
$escapedLink = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
$escapedId = htmlspecialchars($entry->id(), ENT_QUOTES, 'UTF-8');
$marker = '<span class="gridview-og-fetch" '
. 'data-og-url="' . $escapedLink . '" '
. 'data-og-entry-id="' . $escapedId . '" '
. 'style="display:none"></span>';
$entry->_content($entry->content(false) . $marker);
return $entry;
}
/**
* AJAX endpoint: fetch the OG image for a given URL and return JSON.
* Called via ?c=extension&a=configure&e=GridView&ajax=og&url=...
*/
private function ajaxFetchOgImage(): void {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, max-age=86400');
if (!FreshRSS_Auth::hasAccess()) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$url = Minz_Request::paramString('url');
if (empty($url) || (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://'))) {
http_response_code(400);
echo json_encode(['error' => 'Invalid URL']);
exit;
}
$ogImage = $this->extractOgImage($url);
echo json_encode(['image' => $ogImage]);
exit;
}
/**
* Ensure the entry has a thumbnail; if not, try to fetch the OG image.
*
* @param FreshRSS_Entry $entry
* @param bool $persist Whether to persist changes to the database (for display-time fetches)
* @return FreshRSS_Entry
* @throws Minz_PermissionDeniedException
*/
private function ensureThumbnail(FreshRSS_Entry $entry, bool $persist = false): FreshRSS_Entry {
// Skip if entry already has a thumbnail
$thumbnail = $entry->thumbnail(true);
if (!empty($thumbnail['url'])) {
return $entry;
}
// Don't re-attempt if we already checked this entry
if ($entry->attributeBoolean('og_image_checked')) {
return $entry;
}
// Skip if content already has images
if (preg_match('/<img\s[^>]*src\s*=\s*["\']https?:\/\//i', $entry->content(false))) {
return $entry;
}
$link = htmlspecialchars_decode($entry->link(), ENT_QUOTES);
if (empty($link) || !str_starts_with($link, 'http')) {
return $entry;
}
$ogImage = $this->extractOgImage($link);
if ($ogImage !== null) {
$entry->_attribute('thumbnail', ['url' => $ogImage]);
} else {
$entry->_attribute('og_image_checked', true);
}
// Persist to database when called at display time so the image
// is available on subsequent page loads without re-fetching
if ($persist) {
try {
$entryDAO = FreshRSS_Factory::createEntryDao();
$entryDAO->updateEntry($entry->toArray());
} catch (\Throwable $e) {
Minz_Log::warning('GridView: Failed to persist OG image: ' . $e->getMessage());
}
}
return $entry;
}
/**
* Fetch a URL and extract the og:image meta tag.
*
* @param non-empty-string $url The article URL to fetch
* @return string|null The og:image URL or null if not found
*/
private function extractOgImage(string $url): ?string {
// Use the FreshRSS HTTP client, as FreshRSS 1.30+ disables the http(s) stream wrappers
['body' => $html, 'effective_url' => $effectiveUrl, 'fail' => $fail] = FreshRSS_http_Util::httpGet($url,
cachePath: CACHE_PATH . '/' . sha1($url) . '.html', type: 'html', attributes: ['timeout' => 5]);
if ($fail || $html === '') {
return null;
}
$pageUrl = $effectiveUrl ?: $url;
// Try og:image first, then twitter:image (both attribute orderings)
if (preg_match('/<meta\s[^>]*property=["\']og:image["\'][^>]*content=["\']([^"\']+)["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $pageUrl);
}
if (preg_match('/<meta\s[^>]*content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $pageUrl);
}
if (preg_match('/<meta\s[^>]*name=["\']twitter:image["\'][^>]*content=["\']([^"\']+)["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $pageUrl);
}
if (preg_match('/<meta\s[^>]*content=["\']([^"\']+)["\'][^>]*name=["\']twitter:image["\']/i', $html, $matches)) {
return $this->normalizeImageUrl($matches[1], $pageUrl);
}
return null;
}
/**
* Normalize a potentially relative image URL to absolute.
*
* @param string $imageUrl The image URL (may be relative)
* @param string $pageUrl The page URL for resolving relative paths
* @return string|null Absolute URL or null if invalid
*/
private function normalizeImageUrl(string $imageUrl, string $pageUrl): ?string {
$imageUrl = html_entity_decode($imageUrl, ENT_QUOTES, 'UTF-8');
$imageUrl = trim($imageUrl);
if (empty($imageUrl)) {
return null;
}
// Already absolute
if (str_starts_with($imageUrl, 'http://') || str_starts_with($imageUrl, 'https://')) {
return $imageUrl;
}
// Protocol-relative
if (str_starts_with($imageUrl, '//')) {
$scheme = parse_url($pageUrl, PHP_URL_SCHEME) ?: 'https';
return $scheme . ':' . $imageUrl;
}
// Relative — resolve against page URL
$parsed = parse_url($pageUrl);
if ($parsed === false || empty($parsed['host'])) {
return null;
}
$base = ($parsed['scheme'] ?? 'https') . '://' . $parsed['host'];
if (str_starts_with($imageUrl, '/')) {
return $base . $imageUrl;
}
$path = $parsed['path'] ?? '/';
$dir = substr($path, 0, (int)strrpos($path, '/') + 1);
return $base . $dir . $imageUrl;
}
}