From 25f320e22c0fe06eb94100bcd54303e5ace77460 Mon Sep 17 00:00:00 2001 From: Electronic Mango <78230210+Electronic-Mango@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:52:44 +0200 Subject: [PATCH 1/2] [RedditBridge] Add OAuth2 authorization Add Reddit authorization via OAuth2 and legacy developer script "app". Username/password (or app ID/secret) are read from "config.ini.php", as configuration specific to RedditBridge. --- bridges/RedditBridge.php | 65 ++++++++++++++++++++++++++----- docs/10_Bridge_Specific/Reddit.md | 31 +++++++++++++++ tests/RedditBridgeTest.php | 16 ++++---- 3 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 docs/10_Bridge_Specific/Reddit.md diff --git a/bridges/RedditBridge.php b/bridges/RedditBridge.php index 807404217b9..e102ee702b0 100644 --- a/bridges/RedditBridge.php +++ b/bridges/RedditBridge.php @@ -3,7 +3,10 @@ /** * This bridge does NOT use reddit's official rss feeds. * - * This bridge uses reddit's json api: https://old.reddit.com/search.json?q= + * This bridge uses Reddit's JSON API via OAuth2: https://oauth.reddit.com/search.json?q= + * + * Requires a Reddit "script" app and its "ID" and "secret". + * `app_id` and `app_secret` parameters must be set in the configuration for this bridge. */ class RedditBridge extends BridgeAbstract { @@ -12,6 +15,17 @@ class RedditBridge extends BridgeAbstract const URI = 'https://old.reddit.com'; const CACHE_TIMEOUT = 60 * 60 * 2; // 2h const DESCRIPTION = 'Return hot submissions from Reddit'; + const VERSION = "v0.0.3"; + const USER_AGENT = "rss-bridge " . self::VERSION . " (https://github.com/RSS-Bridge/rss-bridge)"; + + const CONFIGURATION = [ + 'app_id' => [ + 'required' => false, + ], + 'app_secret' => [ + 'required' => false, + ], + ]; const PARAMETERS = [ 'global' => [ @@ -167,15 +181,18 @@ private function collectDataInternal(): void $search = $this->getInput('search'); $flareInput = $this->getInput('f'); + $min_score = $this->getInput('score'); + $min_comments = $this->getInput('min_comments'); + + $headers = [ + 'User-Agent: ' . self::USER_AGENT, + 'Authorization: Bearer ' . $this->getAccessToken(), + ]; foreach ($subreddits as $subreddit) { - $version = 'v0.0.2'; - $useragent = "rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)"; $url = self::createUrl($search, $flareInput, $subreddit, $user, $section, $time, $this->queriedContext); - $response = getContents($url, ['User-Agent: ' . $useragent], [], true); - - $json = $response->getBody(); + $json = getContents($url, $headers, []); $parsedJson = Json::decode($json, false); @@ -186,8 +203,6 @@ private function collectDataInternal(): void $data = $post->data; - $min_score = $this->getInput('score'); - $min_comments = $this->getInput('min_comments'); if ($min_score >= 0 && $min_comments >= 0) { if ($data->num_comments < $min_comments || $data->score < $min_score) { continue; @@ -299,6 +314,38 @@ private function collectDataInternal(): void }); } + private function getAccessToken(): string + { + $tokenCacheKey = 'reddit_oauth_token'; + $cachedToken = $this->cache->get($tokenCacheKey); + if ($cachedToken) { + return $cachedToken; + } + + $headers = [ + 'User-Agent: ' . self::USER_AGENT, + 'Authorization: Basic ' . base64_encode($this->getOption('app_id') . ':' . $this->getOption('app_secret')), + ]; + $data = [ + 'grant_type' => 'client_credentials', + 'scope' => 'read', + ]; + $curlopts = [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($data), + ]; + $response = getContents('https://www.reddit.com/api/v1/access_token', $headers, $curlopts); + + $data = Json::decode($response, false); + $token = $data->access_token; + if (!isset($token)) { + throw new \Exception('Failed to obtain Reddit OAuth access token: ' . $response); + } + $expiresIn = $data->expires_in ?? 3600; + $this->cache->set($tokenCacheKey, $token, $expiresIn - 60); + return $token; + } + public static function createUrl($search, $flareInput, $subreddit, bool $user, $section, $time, $queriedContext): string { $keywords = ''; @@ -322,7 +369,7 @@ public static function createUrl($search, $flareInput, $subreddit, bool $user, $ 'include_over_18' => 'on', 't' => $time ]; - return 'https://old.reddit.com/search.json?' . http_build_query($query); + return 'https://oauth.reddit.com/search.json?' . http_build_query($query); } public function getIcon() diff --git a/docs/10_Bridge_Specific/Reddit.md b/docs/10_Bridge_Specific/Reddit.md new file mode 100644 index 00000000000..447c1563a8e --- /dev/null +++ b/docs/10_Bridge_Specific/Reddit.md @@ -0,0 +1,31 @@ +RedditBridge +============ + +Since Reddit shut down unauthorized JSON API access: +https://www.reddit.com/r/modnews/comments/1tq9vxo/protecting_communities_from_scrapers_and_platform/ + +Unfortunately, Reddit also blocked "easy" setup for developer apps: +https://old.reddit.com/wiki/api#wiki_read_the_full_api_terms_and_sign_up_for_usage + +So currently, to get the bridge to work you need to either: + + - Submit a request for an app using Reddit's Data API **and somehow get it approved by Reddit**. + + - Already have an existing app - they still seem to work. + +In both cases you need to have a private "script" app and its "id" and "secret": +https://old.reddit.com/prefs/apps + +"Id" should be visible right in the base view; secret is available when selecting "edit". + +You'll also need a private RSS-Bridge instance to set up your own configuration. + +In `config.ini.php` add the following configuration: + +```ini +[RedditBridge] +app_id = "" +app_secret = "" +``` + +The bridge will handle OAuth, refresh bearer, etc. diff --git a/tests/RedditBridgeTest.php b/tests/RedditBridgeTest.php index 5455ca972ac..9ad48e81585 100644 --- a/tests/RedditBridgeTest.php +++ b/tests/RedditBridgeTest.php @@ -10,23 +10,23 @@ public function test() { $sut = new RedditBridge(new NullCache(), new NullLogger()); - // https://old.reddit.com/search.json?q=cats dogs hen subreddit:php&sort=hot&include_over_18=on - $expected = 'https://old.reddit.com/search.json?q=cats+dogs+hen+subreddit%3Aphp&sort=hot&include_over_18=on&t=all'; + // https://oauth.reddit.com/search.json?q=cats dogs hen subreddit:php&sort=hot&include_over_18=on + $expected = 'https://oauth.reddit.com/search.json?q=cats+dogs+hen+subreddit%3Aphp&sort=hot&include_over_18=on&t=all'; $actual = RedditBridge::createUrl('cats,dogs hen', '', 'php', false, 'hot', 'all', 'single'); $this->assertSame($expected, $actual); - // https://old.reddit.com/search.json?q=author:RavenousRandy&sort=hot&include_over_18=on - $expected = 'https://old.reddit.com/search.json?q=author%3ARavenousRandy&sort=hot&include_over_18=on&t=week'; + // https://oauth.reddit.com/search.json?q=author:RavenousRandy&sort=hot&include_over_18=on + $expected = 'https://oauth.reddit.com/search.json?q=author%3ARavenousRandy&sort=hot&include_over_18=on&t=week'; $actual = RedditBridge::createUrl('', '', 'RavenousRandy', true, 'hot', 'week', 'user'); $this->assertSame($expected, $actual); - // https://old.reddit.com/search.json?q=cats dogs hen flair:"Proxy" subreddit:php&sort=hot&include_over_18=on - $expected = 'https://old.reddit.com/search.json?q=cats+dogs+hen+flair%3A%22Proxy%22+subreddit%3Aphp&sort=hot&include_over_18=on&t=month'; + // https://oauth.reddit.com/search.json?q=cats dogs hen flair:"Proxy" subreddit:php&sort=hot&include_over_18=on + $expected = 'https://oauth.reddit.com/search.json?q=cats+dogs+hen+flair%3A%22Proxy%22+subreddit%3Aphp&sort=hot&include_over_18=on&t=month'; $actual = RedditBridge::createUrl('cats,dogs hen', 'Proxy', 'php', false, 'hot', 'month', 'single'); $this->assertSame($expected, $actual); - // https://old.reddit.com/search.json?q=cats dogs hen flair:"Proxy Linux Server" subreddit:php&sort=hot&include_over_18=on - $expected = 'https://old.reddit.com/search.json?q=cats+dogs+hen+flair%3A%22Proxy+Linux+Server%22+subreddit%3Aphp&sort=hot&include_over_18=on&t=day'; + // https://oauth.reddit.com/search.json?q=cats dogs hen flair:"Proxy Linux Server" subreddit:php&sort=hot&include_over_18=on + $expected = 'https://oauth.reddit.com/search.json?q=cats+dogs+hen+flair%3A%22Proxy+Linux+Server%22+subreddit%3Aphp&sort=hot&include_over_18=on&t=day'; $actual = RedditBridge::createUrl('cats,dogs hen', 'Proxy,Linux Server', 'php', false, 'hot', 'day', 'single'); $this->assertSame($expected, $actual); } From 0ca2a178ade19c44bdf0058df5012fb855c50318 Mon Sep 17 00:00:00 2001 From: Electronic Mango <78230210+Electronic-Mango@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:09:23 +0200 Subject: [PATCH 2/2] [RedditBridge] Refresh OAuth2 token once on reject On first 401/403 try to refresh the OAuth2 token and try to send the request one more time. --- bridges/RedditBridge.php | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/bridges/RedditBridge.php b/bridges/RedditBridge.php index e102ee702b0..119408115b2 100644 --- a/bridges/RedditBridge.php +++ b/bridges/RedditBridge.php @@ -15,8 +15,9 @@ class RedditBridge extends BridgeAbstract const URI = 'https://old.reddit.com'; const CACHE_TIMEOUT = 60 * 60 * 2; // 2h const DESCRIPTION = 'Return hot submissions from Reddit'; - const VERSION = "v0.0.3"; - const USER_AGENT = "rss-bridge " . self::VERSION . " (https://github.com/RSS-Bridge/rss-bridge)"; + const VERSION = 'v0.0.3'; + const USER_AGENT = 'rss-bridge ' . self::VERSION . ' (https://github.com/RSS-Bridge/rss-bridge)'; + const REDDIT_OAUTH_TOKEN_KEY = 'reddit_oauth_token'; const CONFIGURATION = [ 'app_id' => [ @@ -192,9 +193,22 @@ private function collectDataInternal(): void foreach ($subreddits as $subreddit) { $url = self::createUrl($search, $flareInput, $subreddit, $user, $section, $time, $this->queriedContext); - $json = getContents($url, $headers, []); + try { + $response = getContents($url, $headers, [], true); + } catch (HttpException $e) { + if ($e->getCode() === 401 || $e->getCode() === 403) { + // Token might've been rejected, or expired earlier than expected. + // Clear cached token and try one more time with a fresh one. + $this->cache->set(self::REDDIT_OAUTH_TOKEN_KEY, null, 0); + $accessToken = $this->getAccessToken(); + $headers[1] = 'Authorization: Bearer ' . $accessToken; + $response = getContents($url, $headers, [], true); + } else { + throw $e; + } + } - $parsedJson = Json::decode($json, false); + $parsedJson = Json::decode($response->getBody(), false); foreach ($parsedJson->data->children as $post) { if ($post->kind == 't1' && !$comments) { @@ -316,8 +330,7 @@ private function collectDataInternal(): void private function getAccessToken(): string { - $tokenCacheKey = 'reddit_oauth_token'; - $cachedToken = $this->cache->get($tokenCacheKey); + $cachedToken = $this->cache->get(self::REDDIT_OAUTH_TOKEN_KEY); if ($cachedToken) { return $cachedToken; } @@ -342,7 +355,7 @@ private function getAccessToken(): string throw new \Exception('Failed to obtain Reddit OAuth access token: ' . $response); } $expiresIn = $data->expires_in ?? 3600; - $this->cache->set($tokenCacheKey, $token, $expiresIn - 60); + $this->cache->set(self::REDDIT_OAUTH_TOKEN_KEY, $token, $expiresIn - 60); return $token; }