Real-time updates over rssCloud, for both feeds and dynamic OPML subscription lists.
Where WebSub pushes the changed document to the subscriber, an rssCloud notification carries nothing
but url=<resource>. That makes the protocol content-agnostic, which is what lets this extension
cover dynamic OPML — something core's WebSub support cannot do, because p/api/pshb.php parses every
push as a feed.
This runs alongside core's WebSub support and shares nothing with it. A feed advertising both a hub and a cloud will be subscribed through both.
| Resource | Advertisement | Read from |
|---|---|---|
| Feed | <source:cloud>https://rpc.rsscloud.io/pleaseNotify</source:cloud> |
<channel>, namespace https://source.scripting.com/ |
| Feed | <cloud domain="…" port="…" path="…" registerProcedure="" protocol="…"/> |
<channel>, RSS 2.0 (no namespace) |
| Dynamic OPML | <source:cloud>…</source:cloud> |
<head> |
<source:cloud> wins when both are present, since it carries an absolute URL and needs no guessing.
The RSS 2.0 <cloud> element predates ubiquitous TLS and has no scheme attribute, so the scheme is
inferred: protocol="https-post" or port="443" means HTTPS, anything else means HTTP.
SimplePie has no accessor for either element, so both are read via get_channel_tags(). OPML is
parsed with SimpleXML rather than LibOpml, which discards namespaced elements in <head>.
The public endpoint rides on p/api/misc.php, the entry point core provides so extensions can own
an unauthenticated URL:
https://rss.example.net/p/api/misc.php/rssCloud/<token>/
The PATH_INFO form is used rather than ?ext=rssCloud, because rssCloud appends its own
?url=…&challenge=… and a path that already carries a query string breaks that concatenation on
some servers.
It answers both protocol verbs:
GET …?url=<resource>&challenge=<token>— the registration handshake, performed synchronously by the cloud server while answeringpleaseNotify. The challenge is echoed verbatim.POST url=<resource>— the change notification. Feeds go throughFreshRSS_feed_Controller::actualizeFeedsAndCommit(); OPML lists go throughFreshRSS_Category::refreshDynamicOpml().
Because misc.php initialises no user, the callback walks per-resource subscriber markers and
initialises each user in turn — exactly like p/api/pshb.php does.
rssCloud has no equivalent of WebSub's hub.secret, so the callback is an unauthenticated "go and
fetch this URL" trigger. Three things bound that:
- Only URLs already present in the registry are accepted.
- A shared token in the path (generated at install, regenerable from the configuration screen).
- A per-resource cooldown, default 60s.
The cooldown matters more than it does for WebSub: refreshDynamicOpml() re-imports the whole list
and mutes feeds that have disappeared from it, which is far heavier and more destructive than a
feed poll.
Deliberately mirrors core's WebSub layout under PSHB_PATH:
data/rssCloud/resources/<sha1(resourceUrl)>/!cloud.json subscription state
data/rssCloud/resources/<sha1(resourceUrl)>/<username>.txt one marker per interested user
Subscriptions are instance-wide (the cloud server knows one callback), but resources are per-user,
so the markers act as a reference count. There is no per-subscription key, because rssCloud
registers one fixed path and identifies the resource by the url parameter.
Feeds additionally carry an rssCloud attribute holding the resource URL they are keyed under. This
is what makes the polling decision possible before a feed is fetched — FreshRSS_Feed::selfUrl()
is only populated during the SimplePie parse and is not persisted.
Logs go to data/users/_/log_rsscloud.txt.
| Hook | Purpose |
|---|---|
ApiMisc |
serve the callback |
SimplepieAfterInit |
discover a feed's cloud, subscribe, persist the resource attribute |
FeedsListBeforeActualize |
renew feed subscriptions (capped per cycle) |
FeedBeforeActualize |
skip polling a feed with a healthy subscription |
FreshrssUserMaintenance |
discover and renew dynamic OPML subscriptions |
Renewal has to happen in FeedsListBeforeActualize rather than at discovery time, because a feed
whose polling is skipped never reaches SimplepieAfterInit and would otherwise never renew.
These are deliberate scaffold-level limitations, not oversights:
- Lease duration is guessed. The protocol does not negotiate one and the documentation does not state one. Default is to renew after 23h, matching the policy core applies to WebSub leases. Adjust in the configuration screen if your cloud server expires sooner.
- Targeted-refresh detection is a heuristic.
FeedBeforeActualizecannot see whether the caller asked for one feed or the whole instance (core's own WebSub check reads$feed_iddirectly atfeedController.php:505, which the hook has no access to), so batch size is used as a proxy. Consequence: a one-feed instance always polls. The staleness bound is the backstop. - No unsubscribe. rssCloud REST has no unsubscribe verb; subscriptions expire by not being renewed. Deleting a feed leaves a stale marker until the next notification finds no subscriber and self-heals.
- REST only.
xml-rpcandsoapclouds are discovered and ignored. - OPML discovery is one cycle behind. It reads the OPML from the cache file that
refreshDynamicOpml()writes rather than fetching again, so a newly added list is subscribed on the following maintenance pass. registerProcedureis passed through but unused, as it is meaningless forhttp-post.
- Copy this directory into
extensions/. - Enable it in Administration → Extensions. It is a system extension, so it applies to all
users, and
p/api/misc.phpwill only route to it if it is enabled system-wide. - Ensure the API is enabled (
api_enabled) —misc.phpreturns 503 otherwise. - Check the callback URL shown on the configuration screen is publicly reachable.
# The handshake, as a cloud server would perform it
curl -i 'https://rss.example.net/p/api/misc.php/rssCloud/<token>/?url=<feed>&challenge=abc123'
# expect: 200, body exactly `abc123`
# A notification
curl -i -d 'url=<feed>' 'https://rss.example.net/p/api/misc.php/rssCloud/<token>/'
# expect: 200, body `Done: N`
tail -f data/users/_/log_rsscloud.txtThis extension has no dependencies of its own. Its checks run against FreshRSS core, because every
class it touches lives there — so clone it into a FreshRSS checkout at
FreshRSS/extensions/xExtension-RssCloud, then, from the FreshRSS root:
composer install
# phpstan, using core's ruleset scoped to this extension
( cd extensions/xExtension-RssCloud && ../../vendor/bin/phpstan analyse -c phpstan.neon )
# phpcs — core's ruleset excludes extensions/, so drop that one line
sed '/(?-i:extensions)/d' phpcs.xml > phpcs-extensions.xml
vendor/bin/phpcs --standard=phpcs-extensions.xml extensions/xExtension-RssCloud -sCI runs exactly these against FreshRSS edge on every push and pull request.
Releases are automated with Release Please, which
derives the version and CHANGELOG from
Conventional Commit subjects on main. Merging its release
PR bumps both version.txt and the version field in metadata.json, tags, and attaches an
installable zip to the GitHub release.
A subject that does not conform is not an error anywhere — it is just silently dropped from the next release. Enable the hook that catches it, once per clone:
git config core.hooksPath .githooksAGPL-3.0, matching FreshRSS itself.