Files
FreshRSS/app/Models/UserQuery.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

411 lines
12 KiB
PHP

<?php
declare(strict_types=1);
/**
* Contains the description of a user query
*
* It allows to extract the meaningful bits of the query to be manipulated in an
* easy way.
*/
class FreshRSS_UserQuery {
private bool $deprecated = false;
private string $get = '';
private string $get_name = '';
private string $get_type = '';
/** XML-encoded name */
private string $name = '';
private string $order = '';
private readonly FreshRSS_BooleanSearch $search;
private int $state = 0;
private string $url = '';
private string $token = '';
private bool $shareRss = false;
private bool $shareOpml = false;
private bool $includeUserLabels = false;
private bool $excludeArticleTags = false;
private string $userLabelPrefix = '';
/** @var array<int,FreshRSS_Category> $categories where the key is the category ID */
private array $categories;
/** @var array<int,FreshRSS_Tag> $labels where the key is the label ID */
private array $labels;
/** XML-encoded description */
private string $description = '';
private string $imageUrl = '';
public static function generateToken(string $salt): string {
if (!FreshRSS_Context::hasSystemConf()) {
return '';
}
$hash = md5(FreshRSS_Context::systemConf()->salt . $salt . random_bytes(16));
if (function_exists('gmp_init')) {
// Shorten the hash if possible by converting from base 16 to base 62
$hash = gmp_strval(gmp_init($hash, 16), 62);
}
return $hash;
}
/**
* @param array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
* shareRss?:bool,shareOpml?:bool,includeUserLabels?:bool,excludeArticleTags?:bool,userLabelPrefix?:string,
* publishLabelsInsteadOfTags?:bool,description?:string,imageUrl?:string} $query
* @param array<FreshRSS_Category> $categories
* @param array<FreshRSS_Tag> $labels
*/
public function __construct(array $query, array $categories, array $labels) {
$this->categories = [];
foreach ($categories as $category) {
$this->categories[$category->id()] = $category;
}
$this->labels = [];
foreach ($labels as $label) {
$this->labels[$label->id()] = $label;
}
if (isset($query['get'])) {
$this->parseGet($query['get']);
} else {
$this->get_type = 'all';
}
if (isset($query['name'])) {
$this->name = trim($query['name']);
}
if (isset($query['order'])) {
$this->order = $query['order'];
}
if (empty($query['url'])) {
if (!empty($query)) {
$link = $query;
unset($link['description']);
unset($link['imageUrl']);
unset($link['name']);
unset($link['shareOpml']);
unset($link['shareRss']);
unset($link['includeUserLabels']);
unset($link['excludeArticleTags']);
unset($link['userLabelPrefix']);
unset($link['publishLabelsInsteadOfTags']);
$this->url = Minz_Url::display(['params' => $link]);
}
} else {
$this->url = $query['url'];
}
if (!isset($query['search'])) {
$query['search'] = '';
}
if (!empty($query['token'])) {
$this->token = $query['token'];
}
if (isset($query['shareRss'])) {
$this->shareRss = $query['shareRss'];
}
if (isset($query['shareOpml'])) {
$this->shareOpml = $query['shareOpml'];
}
if (isset($query['publishLabelsInsteadOfTags'])) { // Legacy
$this->includeUserLabels = (bool)$query['publishLabelsInsteadOfTags'];
$this->excludeArticleTags = (bool)$query['publishLabelsInsteadOfTags'];
if ($this->includeUserLabels) {
$this->userLabelPrefix = '';
}
}
if (isset($query['includeUserLabels'])) {
$this->includeUserLabels = (bool)$query['includeUserLabels'];
}
if (isset($query['excludeArticleTags'])) {
$this->excludeArticleTags = (bool)$query['excludeArticleTags'];
}
if (isset($query['userLabelPrefix'])) {
$this->userLabelPrefix = (string)$query['userLabelPrefix'];
}
if (isset($query['description'])) {
$this->description = $query['description'];
}
if (isset($query['imageUrl'])) {
$this->imageUrl = $query['imageUrl'];
}
// linked too deeply with the search object, need to use dependency injection
$this->search = new FreshRSS_BooleanSearch($query['search'], 0, 'AND', allowUserQueries: true);
if (!empty($query['state'])) {
$this->state = intval($query['state']);
}
}
/**
* Convert the current object to an array.
*
* @return array{get?:string,name?:string,order?:string,search?:string,
* state?:int,url?:string,token?:string,shareRss?:bool,shareOpml?:bool,
* includeUserLabels?:bool,excludeArticleTags?:bool,userLabelPrefix?:string,description?:string,imageUrl?:string}
*/
public function toArray(): array {
return array_filter([
'get' => $this->get,
'name' => $this->name,
'order' => $this->order,
'search' => $this->search->toString(expandUserQueries: false),
'state' => $this->state,
'url' => $this->url,
'token' => $this->token,
'shareRss' => $this->shareRss,
'shareOpml' => $this->shareOpml,
'includeUserLabels' => $this->includeUserLabels,
'excludeArticleTags' => $this->excludeArticleTags,
'userLabelPrefix' => $this->userLabelPrefix,
'description' => $this->description,
'imageUrl' => $this->imageUrl,
], fn($v): bool => $v !== '' && $v !== 0 && $v !== false);
}
/**
* Parse the get parameter in the query string to extract its name and type
*/
private function parseGet(string $get): void {
$this->get = $get;
if ($this->get === '') {
$this->get_type = 'all';
} elseif (preg_match('/(?P<type>[aAcfistTZ])(_(?P<id>\d+))?/', $get, $matches)) {
$id = intval($matches['id'] ?? '0');
switch ($matches['type']) {
case 'a': // All PRIORITY_MAIN_STREAM
$this->get_type = 'all';
break;
case 'A': // All except PRIORITY_HIDDEN
$this->get_type = 'A';
break;
case 'Z': // All including PRIORITY_HIDDEN
$this->get_type = 'Z';
break;
case 'c': // Category
$this->get_type = 'category';
$c = $this->categories[$id] ?? null;
$this->get_name = $c === null ? '' : $c->name();
break;
case 'f': // Feed
$this->get_type = 'feed';
$f = FreshRSS_Category::findFeed($this->categories, $id);
$this->get_name = $f === null ? '' : $f->name();
break;
case 'i': // Priority important feeds
$this->get_type = 'important';
break;
case 's': // Starred. Deprecated: use $state instead
$this->get_type = 'favorite';
break;
case 't': // Tag (label)
$this->get_type = 'label';
$l = $this->labels[$id] ?? null;
$this->get_name = $l === null ? '' : $l->name();
break;
case 'T': // Any tag (label)
$this->get_type = 'all_labels';
break;
}
if ($this->get_name === '' && in_array($matches['type'], ['c', 'f', 't'], true)) {
$this->deprecated = true;
}
}
}
/**
* Check if the current user query is deprecated.
* It is deprecated if the category or the feed used in the query are
* not existing.
*/
public function isDeprecated(): bool {
return $this->deprecated;
}
/**
* Check if the user query has parameters.
*/
public function hasParameters(): bool {
if ($this->get_type !== 'all') {
return true;
}
if ($this->hasSearch()) {
return true;
}
if (!in_array($this->state, [
0,
FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ,
FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE
], true)) {
return true;
}
if ($this->order !== '' && $this->order !== FreshRSS_Context::userConf()->sort_order) {
return true;
}
return false;
}
/**
* Check if there is a search in the search object
*/
public function hasSearch(): bool {
return $this->search->toString() !== '';
}
public function getGet(): string {
return $this->get;
}
public function getGetName(): string {
return $this->get_name;
}
public function getGetType(): string {
return $this->get_type;
}
public function getName(): string {
return $this->name;
}
public function getOrder(): string {
return $this->order ?: FreshRSS_Context::userConf()->sort_order;
}
public function getSearch(): FreshRSS_BooleanSearch {
return $this->search;
}
public function getState(): int {
$state = $this->state;
if (!($state & FreshRSS_Entry::STATE_READ) && !($state & FreshRSS_Entry::STATE_NOT_READ)) {
$state |= FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ;
}
if (!($state & FreshRSS_Entry::STATE_FAVORITE) && !($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
$state |= FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE;
}
return $state;
}
public function getUrl(): string {
return $this->url;
}
public function getToken(): string {
return $this->token;
}
public function setToken(string $token): void {
$this->token = $token;
}
public function setShareRss(bool $shareRss): void {
$this->shareRss = $shareRss;
}
public function shareRss(): bool {
return $this->shareRss;
}
public function setShareOpml(bool $shareOpml): void {
$this->shareOpml = $shareOpml;
}
public function shareOpml(): bool {
return $this->shareOpml;
}
public function setIncludeUserLabels(bool $includeUserLabels): void {
$this->includeUserLabels = $includeUserLabels;
}
public function includeUserLabels(): bool {
return $this->includeUserLabels;
}
public function setExcludeArticleTags(bool $excludeArticleTags): void {
$this->excludeArticleTags = $excludeArticleTags;
}
public function excludeArticleTags(): bool {
return $this->excludeArticleTags;
}
public function setUserLabelPrefix(string $userLabelPrefix): void {
$this->userLabelPrefix = $userLabelPrefix;
}
public function userLabelPrefix(): string {
return $this->userLabelPrefix;
}
protected function sharedUrl(bool $xmlEscaped = true): string {
$currentUser = Minz_User::name() ?? '';
return Minz_Url::display("/api/query.php?user={$currentUser}&t={$this->token}", $xmlEscaped ? 'html' : '', true);
}
public function sharedUrlRss(bool $xmlEscaped = true): string {
if ($this->shareRss && $this->token !== '') {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=rss';
}
return '';
}
public function sharedUrlGreader(bool $xmlEscaped = true): string {
if ($this->shareOpml && $this->token !== '') {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=greader';
}
return '';
}
public function sharedUrlHtml(bool $xmlEscaped = true): string {
if ($this->shareRss && $this->token !== '') {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=html';
}
return '';
}
/**
* OPML is only safe for some query types, otherwise it risks leaking unwanted feed information.
*/
public function safeForOpml(): bool {
return in_array($this->get_type, ['all', 'category', 'feed'], true);
}
public function sharedUrlOpml(bool $xmlEscaped = true): string {
if ($this->shareOpml && $this->token !== '' && $this->safeForOpml()) {
return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=opml';
}
return '';
}
public function getDescription(): string {
return $this->description;
}
public function setDescription(string $description): void {
$this->description = $description;
}
public function getImageUrl(): string {
return $this->imageUrl;
}
public function setImageUrl(string $imageUrl): void {
$this->imageUrl = $imageUrl;
}
/**
* Remove queries where $get is appearing.
* @param string $get the get attribute which should be removed.
* @param array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
* shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> $queries an array of queries.
* @return array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
* shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> without queries where $get is appearing.
*/
public static function remove_query_by_get(string $get, array $queries): array {
$final_queries = [];
foreach ($queries as $query) {
if (empty($query['get']) || $query['get'] !== $get) {
$final_queries[] = $query;
}
}
return $final_queries;
}
}