Files
FreshRSS/p/api/query.php
T
SamCyber001 12b5be6afc Gate greader/json share formats behind Share by OPML (#9248)
* Gate greader/json share formats behind Share by OPML

Follow-up to #9246, per @Alkarex's suggestion on GHSA-rpmm-h4hx-5p6p.

The greader and json share formats expose origin.feedUrl (the feed's subscription
URL). Sharing feed URLs is what the "Share by OPML" option explicitly means, whereas
"Share by RSS" is meant to share article content. This moves greader and json in
p/api/query.php from the shareRss gate to the shareOpml gate, so feed URLs are only
served when the owner opted into OPML sharing.

Note: moved json alongside greader too, since both emit feedUrl (same toGReader path).

* sharedUrlGreader: gate on shareOpml

* Move greader share link under Share by OPML group
2026-08-31 14:43:55 +02:00

232 lines
8.0 KiB
PHP

<?php
declare(strict_types=1);
header('X-Content-Type-Options: nosniff');
require dirname(__DIR__, 2) . '/constants.php';
require LIB_PATH . '/lib_rss.php'; //Includes class autoloader
Minz_Request::init();
$token = Minz_Request::paramString('t', plaintext: true);
if (!ctype_alnum($token)) {
header('HTTP/1.1 422 Unprocessable Entity');
header('Content-Type: text/plain; charset=UTF-8');
die('Invalid token `t`!' . $token);
}
$format = Minz_Request::paramString('f', plaintext: true);
if (!in_array($format, ['atom', 'greader', 'html', 'json', 'opml', 'rss'], true)) {
header('HTTP/1.1 422 Unprocessable Entity');
header('Content-Type: text/plain; charset=UTF-8');
die('Invalid format `f`!');
}
$user = Minz_Request::paramString('user', plaintext: true);
if (!FreshRSS_user_Controller::checkUsername($user)) {
header('HTTP/1.1 422 Unprocessable Entity');
header('Content-Type: text/plain; charset=UTF-8');
die('Invalid user!');
}
Minz_Session::init('FreshRSS', true);
FreshRSS_Context::initSystem();
if (!FreshRSS_Context::hasSystemConf() || !FreshRSS_Context::systemConf()->api_enabled) {
header('HTTP/1.1 503 Service Unavailable');
header('Content-Type: text/plain; charset=UTF-8');
die('Service Unavailable!');
}
if (($_SERVER['PATH_INFO'] ?? $_SERVER['ORIG_PATH_INFO'] ?? '') !== '') {
// Do not allow trailing slashes
header('HTTP/1.1 400 Bad Request');
die('Invalid path!');
}
FreshRSS_Context::initUser($user);
if (!FreshRSS_Context::hasUserConf() || !FreshRSS_Context::userConf()->enabled) {
usleep(rand(100, 10000)); //Primitive mitigation of scanning for users
header('HTTP/1.1 404 Not Found');
header('Content-Type: text/plain; charset=UTF-8');
die('User not found!');
} else {
usleep(rand(20, 200));
}
require LIB_PATH . '/http-conditional.php';
$dateLastModification = max(
FreshRSS_UserDAO::ctime($user),
FreshRSS_UserDAO::mtime($user),
@filemtime(DATA_PATH . '/config.php') ?: 0
);
// TODO: Consider taking advantage of $feedMode, only for monotonous queries {all, categories, feeds} and not dynamic ones {read/unread, favourites, user labels}
if (!file_exists(DATA_PATH . '/no-cache.txt') && httpConditional($dateLastModification ?: time(), 0, 0, false, PHP_COMPRESSION, false)) {
exit(); //No need to send anything
}
Minz_Translate::init(FreshRSS_Context::userConf()->language);
Minz_ExtensionManager::init();
Minz_ExtensionManager::enableByList(FreshRSS_Context::userConf()->extensions_enabled, 'user');
$query = null;
$userSearch = null;
foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
if (!empty($raw_query['token']) && hash_equals($raw_query['token'], $token)) {
switch ($format) {
case 'atom':
case 'html':
case 'rss':
if (empty($raw_query['shareRss'])) {
continue 2;
}
break;
case 'greader':
case 'json':
case 'opml':
if (empty($raw_query['shareOpml'])) {
continue 2;
}
break;
default:
continue 2;
}
$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
Minz_Request::_param('get', $query->getGet());
if (Minz_Request::paramString('order', plaintext: true) === '') {
Minz_Request::_param('order', $query->getOrder());
}
Minz_Request::_param('state', (string)$query->getState());
$search = $query->getSearch()->toString();
// Note: we disallow references to user queries in public user search to avoid sniffing internal user queries
$userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true), 0, 'AND', allowUserQueries: false);
if ($userSearch->toString() !== '') {
if ($search === '') {
$search = $userSearch->toString();
} else {
$search .= ' (' . $userSearch->toString() . ')';
}
}
Minz_Request::_param('search', $search);
break;
}
}
if ($query === null || $userSearch === null) {
usleep(rand(100, 10000));
header('HTTP/1.1 404 Not Found');
header('Content-Type: text/plain; charset=UTF-8');
die('User query not found!');
}
$view = new FreshRSS_View();
try {
FreshRSS_Context::updateUsingRequest(false);
$view->entries = FreshRSS_index_Controller::listEntriesByContext();
if (!$view->entries->valid()) { // Init the generator to consume the aggregated search and catch potential exceptions
$view->entries = new EmptyIterator();
}
Minz_Request::_param('search', $userSearch->toString()); // Restore user search for display and exports
FreshRSS_Context::$search = $userSearch; // Restore user search for display and exports
} catch (Minz_Exception) {
Minz_Error::error(400, 'Bad user query!');
die();
}
$get = FreshRSS_Context::currentGet(true);
$type = (string)$get[0];
$id = (int)$get[1];
switch ($type) {
case 'c': // Category
$cat = FreshRSS_Context::categories()[$id] ?? null;
if ($cat === null) {
Minz_Error::error(404, "Category {$id} not found!");
die();
}
$view->categories = [$cat->id() => $cat];
break;
case 'f': // Feed
$feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id);
if ($feed === null) {
Minz_Error::error(404, "Feed {$id} not found!");
die();
}
$view->feeds = [$id => $feed];
$view->categories = [];
break;
default:
$view->categories = FreshRSS_Context::categories();
break;
}
$view->disable_aside = true;
$view->excludeMutedFeeds = true;
$view->internal_rendering = true;
$view->userQuery = $query;
$view->html_url = $query->sharedUrlHtml();
$view->rss_url = $query->sharedUrlRss();
$view->rss_title = $query->getName();
$view->image_url = $query->getImageUrl();
$view->description = $query->getDescription() ?: _t('index.feed.rss_of', $view->rss_title);
$view->includeUserLabels = $query->includeUserLabels();
$view->excludeArticleTags = $query->excludeArticleTags();
$view->userLabelPrefix = $query->userLabelPrefix();
$view->entryIdsTagNames = [];
if ($view->includeUserLabels && in_array($format, ['rss', 'atom'], true)) {
$entries = iterator_to_array($view->entries, preserve_keys: false); // TODO: Optimise: avoid iterator_to_array if possible
$view->entries = $entries;
if (!empty($entries)) {
$tagDAO = FreshRSS_Factory::createTagDao();
$view->entryIdsTagNames = $tagDAO->getEntryIdsTagNames($entries);
}
}
if ($query->getName() != '') {
FreshRSS_View::_title($query->getName());
}
FreshRSS_Context::systemConf()->allow_anonymous = true;
header('Access-Control-Allow-Methods: GET');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Max-Age: 600');
header('Cache-Control: public, max-age=60');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
header('HTTP/1.1 204 No Content');
exit();
}
if (in_array($format, ['rss', 'atom'], true)) {
header('Content-Type: application/rss+xml; charset=utf-8');
header("Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors " .
(FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
$view->_layout(null);
$view->_path('index/rss.phtml');
} elseif (in_array($format, ['greader', 'json'], true)) {
header('Content-Type: application/json; charset=utf-8');
header("Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors " .
(FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
$view->_layout(null);
$view->type = 'query/' . $token;
$view->list_title = $query->getName();
$view->entryIdsTagNames = []; // Do not export user labels for privacy
$view->_path('helpers/export/articles.phtml');
} elseif ($format === 'opml') {
if (!$query->safeForOpml()) {
Minz_Error::error(404, 'OPML not allowed for this user query!');
die();
}
header('Content-Type: application/xml; charset=utf-8');
header("Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors " .
(FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
$view->_layout(null);
$view->_path('index/opml.phtml');
} else {
header("Content-Security-Policy: default-src 'self'; frame-src *; img-src * data:; media-src *; frame-ancestors " .
(FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'"));
$view->_layout('layout');
$view->_path('index/html.phtml');
}
$view->build();