Skip to content

fix(opml): stop dynamic OPML categories accumulating redirected feeds - #1

Merged
andrewshell merged 2 commits into
mainfrom
fix/dynamic-opml-redirect-duplicates
Aug 3, 2026
Merged

fix(opml): stop dynamic OPML categories accumulating redirected feeds#1
andrewshell merged 2 commits into
mainfrom
fix/dynamic-opml-redirect-duplicates

Conversation

@andrewshell

@andrewshell andrewshell commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The bug

Seen in production: a dynamic OPML category gradually accumulating duplicates of a feed that appears once in the OPML. Six copies so far, all sharing the URL https://www.almaren.ch/feed/, while the OPML lists https://www.almaren.ch/feed.

That URL 301s to the trailing-slash form (verified). FreshRSS and OPML then disagree about what identifies a feed:

  • FreshRSS_Feed::load() treats a feed as the document it resolves to, and rewrites the stored URL on HTTP 301 (Feed.php:677).
  • FreshRSS_Category::refreshDynamicOpml() treats a feed as the exact xmlUrl string in the list, which does not change (Category.php:236-254).

One 301 is enough to make them disagree forever. Every later refresh reads the entry as new and inserts it again, then mutes the drifted copy for having disappeared from the list. Nothing catches the collision: _feed.url has no unique index (pgsql explicitly drops _feed_url_key), and FeedDAO::updateFeed() does not check for one. Copies accumulate at one per refresh — unbounded here, because rssCloud refreshes on notification rather than on a timer.

This is a core defect that affects every dynamic OPML user; rssCloud just makes it fire at ping rate instead of once per cache duration. Worth reporting upstream separately.

The fix

FeedBeforeInsert settles it at the import step, before core does its matching. A feed whose URL is not already subscribed is resolved to wherever it permanently moved; if that is a feed we hold, the import is addressed to it instead. Core then recognises it as existing, so it is neither inserted nor muted, and FeedDAO::addFeedObject() unmutes it if an earlier refresh muted it.

New RssCloud_Redirects resolves and caches that, one file per URL under data/rssCloud/redirects/:

  • HEAD, not GET — a large first import costs one cheap request per entry rather than downloading every feed twice.
  • 301/308 only — a temporary redirect deliberately says the resource has not moved, so following it would merge two feeds the publisher considers distinct.
  • Hops walked by hand with CURLOPT_FOLLOWLOCATION => false, so every hop is re-checked through FreshRSS_http_Util::getCurlResolveInfo() — a redirect cannot walk into the private network. Protocols pinned to http/https, loops detected.
  • Answers cached 30 days; a resolution that failed, as opposed to one that found no move, is retried after 6 hours.

A feed that moved onto nothing we hold is left exactly as the list gives it, and core canonicalises it on first fetch as usual.

The hook fires on every import path, so this also covers refreshes driven by cron or the CLI, and keeps a manual subscription from duplicating a feed already held under its post-redirect URL.

Testing

The FreshRSS classes were stubbed and the real RssCloud_Redirects exercised against live hosts (no data/config.php in the dev checkout, so FreshRSS cannot boot):

Case Expected Result
almaren.ch/feed moves to /feed/
almaren.ch/feed/ unchanged, no ping-pong
301, 308 followed
302, 307 not followed
404, dead host not a move
second call served from cache, no network

That caught a real bug: a 302 and a dead host both stored target=null, so "it definitively did not move" was getting the short retry TTL meant for "we could not tell". Now separated by an explicit failed flag.

php -l, phpstan, phpcs and markdownlint all pass locally.

Reviewer notes

  • At FeedBeforeInsert the feed has no category yet, so the check is "does this redirect to any feed we hold", not "one in this category". If the target sits in a different category, addFeedObject() finds it, unmutes it, and does not insert or move it — no duplicate either way, just less precise than you might expect.
  • Existing duplicates are not cleaned up. On the next refresh the drifted OPML entry matches one surviving row and unmutes it; the rest stay muted for manual deletion.
  • Not addressed here: the notification cooldown in Callback.php:83 is a non-atomic read-check-write, so concurrent notifications can both pass it and race the non-atomic searchByUrlINSERT. Separate issue.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added caching for permanently redirected feed URLs, including retry handling for failed lookups.
    • Improved dynamic OPML imports by resolving permanent redirects and reusing existing feed records when appropriate.
    • Added support for pinning dynamic OPML feed URLs after initialization.
    • Added a feed-import hook for validating and reconciling redirected feeds.
    • Added documentation covering redirect handling, caching, and OPML import behavior.

FreshRSS and OPML disagree about what identifies a feed. FreshRSS_Feed::load()
treats a feed as the document it resolves to and rewrites the stored URL when
the feed answers HTTP 301; FreshRSS_Category::refreshDynamicOpml() treats it as
the exact xmlUrl in the list, which does not change.

One 301 is enough to make them disagree forever. Every later refresh then reads
the entry as new and inserts it again, and mutes the drifted copy for having
disappeared from the list. Nothing catches the collision: `_feed`.url has no
unique index and FeedDAO::updateFeed() does not check for one. Copies accumulate
at one per refresh -- unbounded here, since rssCloud refreshes on notification
rather than on a timer.

Settle it at the import step, before core does its matching. A feed whose URL is
not already subscribed is resolved to wherever it permanently moved; if that is
a feed we hold, the import is addressed to it instead. Core then recognises it
as existing, so it is neither inserted nor muted, and addFeedObject() unmutes it
if an earlier refresh muted it.

Resolution is a HEAD, so a large first import costs one cheap request per entry
rather than downloading every feed twice, and answers are cached on disk. Hops
are walked by hand with CURLOPT_FOLLOWLOCATION off so that every one is
re-checked against the IP allowlist, which keeps a redirect from reaching the
private network. Only 301 and 308 are followed: a temporary redirect says the
resource has not moved, so following it would merge two feeds the publisher
considers distinct.

FeedBeforeInsert fires on every import path, so this also covers refreshes
driven by cron or the CLI, and keeps a manual subscription from duplicating a
feed already held under its post-redirect URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@andrewshell, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 55ea7414-59b8-4b06-b264-855c23463739

📥 Commits

Reviewing files that changed from the base of the PR and between cb968e7 and 51f132e.

📒 Files selected for processing (3)
  • README.md
  • RssCloud/Redirects.php
  • extension.php
📝 Walkthrough

Walkthrough

The extension adds RssCloud_Redirects for cached permanent-redirect resolution. It follows validated HTTP 301/308 chains and reconciles redirected feeds during OPML imports by reusing existing feed records.

Changes

Redirect reconciliation

Layer / File(s) Summary
Cached redirect resolver
RssCloud/Redirects.php, README.md
RssCloud_Redirects stores successful and failed URL resolutions with separate TTLs. It follows up to four validated 301/308 hops and uses restricted HEAD requests.
Feed import reconciliation
extension.php, README.md
The extension creates redirect storage, registers FeedBeforeInsert, and rewrites imported URLs when a permanent redirect matches an existing feed. Invalid or unresolved redirects leave the import unchanged.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OPMLImport
  participant FeedBeforeInsert
  participant RssCloud_Redirects
  participant ExistingFeedStore
  OPMLImport->>FeedBeforeInsert: submit imported feed
  FeedBeforeInsert->>RssCloud_Redirects: resolve feed URL
  RssCloud_Redirects-->>FeedBeforeInsert: return redirect target
  FeedBeforeInsert->>ExistingFeedStore: check target URL
  ExistingFeedStore-->>FeedBeforeInsert: return existing feed or no match
  FeedBeforeInsert-->>OPMLImport: return reconciled or unchanged feed
Loading

Poem

I’m a rabbit with URLs to chase,
Through four redirect hops at a steady pace.
Cache the good, retry the bad,
Reuse old feeds so imports stay glad.
301 and 308 lead the way—
No duplicate burrows today!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the fix for duplicate redirected feeds in dynamic OPML categories.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dynamic-opml-redirect-duplicates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@extension.php`:
- Around line 389-390: Update the logging statement in the rssCloud redirect
handling to sanitize the `$target` value with
`\SimplePie\Misc::url_remove_credentials()` before including it in the
`Minz_Log::notice` message, while preserving the existing sanitization of `$url`
and the log behavior.
- Around line 82-85: Register the FeedBeforeInsert hook for onFeedBeforeInsert
independently of the isEnabledForOpml() condition in the extension
initialization flow, while keeping FreshrssUserMaintenance registration gated by
OPML support. Ensure manual subscriptions reconcile permanent redirects when
OPML discovery is disabled.

In `@RssCloud/Redirects.php`:
- Around line 197-202: Update the redirect-status handling in follow() so
retryable HTTP failures (408, 429, and 5xx) and 301/308 responses with a missing
or empty Location return false, preventing store() from caching them as
non-redirects. Preserve null for stable non-permanent responses, including
rejected HEAD requests, and continue returning valid Location values for
well-formed 301/308 responses.
- Around line 110-129: Update the permanent-redirect resolution flow around the
hop loop and `permanentLocation` so exactly `self::MAX_HOPS` redirects are
followed and the URL reached by the final hop is probed. After that final probe,
return the target when it has no further permanent redirect, and only
reject/cache failure when the probe reveals another permanent redirect; preserve
existing handling for unusable URLs, loops, and earlier failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: be03a810-8050-45e0-ab93-734da56da59a

📥 Commits

Reviewing files that changed from the base of the PR and between 8b0f0fe and cb968e7.

📒 Files selected for processing (3)
  • README.md
  • RssCloud/Redirects.php
  • extension.php

Comment thread extension.php
Comment thread extension.php Outdated
Comment thread RssCloud/Redirects.php Outdated
Comment thread RssCloud/Redirects.php Outdated
Four issues from review, all in the redirect reconciliation added by the parent
commit.

Register FeedBeforeInsert unconditionally. It was gated on opml_enabled, but
that switch governs whether rssCloud subscribes to a resource, not whether the
duplicates exist: the dynamic OPML refresh that creates them runs from cron and
the CLI regardless. Gating it also contradicted the documented behaviour for
manual subscriptions.

Do not cache a transient HTTP failure as "did not move". Any status that was not
301 or 308 -- including 5xx, 429 and 408 -- resolved to null, which follow()
read as a confirmed non-redirect and store() then trusted for 30 days. A
briefly failing origin could therefore resume creating duplicates for a month.
Those statuses, and a permanent redirect carrying no Location, now report a
failure instead, which is retried after 6 hours. A rejected HEAD stays a settled
answer, since it would answer the same way later.

Probe the target reached by the last allowed hop. The loop followed MAX_HOPS
redirects but exited before checking where the last one landed, so a chain of
exactly MAX_HOPS was reported unresolvable. Core counts the same way in
FreshRSS_http_Util::httpGet().

Scrub credentials from every logged URL. A redirect Location can carry them, and
the target and both Redirects.php messages were written unsanitised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andrewshell

Copy link
Copy Markdown
Contributor Author

Addressed all four findings in 51f132e. All were valid.

Register FeedBeforeInsert independently — agreed, and for a stronger reason than the one given. opml_enabled governs whether rssCloud subscribes to a resource; it does not govern whether dynamic OPML categories exist. Cron and the CLI refresh them either way, so gating the guard on that switch left those users still accumulating duplicates, just more slowly. Now registered unconditionally, with the reasoning in a comment and the README.

Scrub credentials from $target — correct, a Location can carry them. Also fixed the same leak in the two Redirects.php messages, which were writing an unsanitised $url that on later hops is a redirect target.

Allow exactly MAX_HOPS — confirmed off-by-one. The loop followed four redirects but exited before probing where the fourth landed, so a four-hop chain was reported unresolvable and cached as a failure. Core counts the same way I intended in httpGet() (allows four, then fetches the final URL). Fixed with a break rather than the suggested diff, which left the trailing warning duplicated and unreachable.

Do not cache transient failures as "no redirect" — the most consequential of the four. Any non-301/308 status returned null, which follow() read as a confirmed non-redirect and store() then trusted for 30 days, so a briefly failing origin could resume creating duplicates for a month. 5xx/429/408/0 and a permanent redirect with no Location now report failure (6-hour retry); a rejected HEAD stays a settled answer, since it would answer the same way later.

Verification

Extended the probe harness to cover exactly these cases, against live hosts:

--- hop limit (MAX_HOPS = 4) ---
PASS   chain of 3 permanent hops resolves
PASS   chain of 4 permanent hops resolves (was off-by-one)
PASS   chain of 5 permanent hops exceeds the limit, no move claimed

--- transient failures must not be cached as 'no redirect' ---
PASS   HTTP 500 / 503 / 429 / 408 cached as failed (retried in 6h, not 30d)

--- stable non-redirects stay trusted ---
PASS   HTTP 200 / 404 / 405 cached as a settled 'did not move'

--- credentials never reach the log ---
PASS   failed-probe log carries no password

The original cases still hold: almaren.ch/feed/feed/, /feed/ unchanged with no ping-pong, 301/308 followed, 302/307 not, dead host not a move, cache replay at 0ms.

One aside worth recording: the four-hop assertion failed on its first run and passed on re-run. That was httpbin returning a transient error — which the new code classified as a failure rather than caching it as "did not move", so the flake was the fix demonstrating itself.

php -l, phpstan, phpcs and markdownlint all pass.

@andrewshell
andrewshell merged commit fab26a6 into main Aug 3, 2026
3 checks passed
@andrewshell
andrewshell deleted the fix/dynamic-opml-redirect-duplicates branch August 3, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant