Releases: advision-development/trending-now-plugin
Release list
Trending Now 1.4.0
Added
-
A site can be told to fetch now.
POST /wp-json/advtn/v1/syncmakes this site
re-read its curated feed immediately instead of waiting up to six hours. It calls the
samefetch( true )the Fetch now button does, down the same path, through the same
parser — the push carries no links, only the instruction to go and get them.It has its own credential,
advtn_sync_key, sent in theX-ADVTN-Sync-Keyheader
and verified in constant time. Deliberately notingest_secret, which also authorizes
/ingestand/status: a central store of those would be able to trigger ingests
across the whole network and read every site's source configuration. What a leaked sync
key buys is one site re-reading the feed it re-reads every six hours anyway.Nothing is distributed. This site invents the key the first time it fetches and
sends it on the request it was already making, so the feed learns it by being called.
Replacing it is a button on Curated links → Feed subscription, beside the row that
says whether a key is in place and when it was last presented; the old value keeps
working until the feed sees the new one, because otherwise replacing a key you suspected
had leaked would mean waiting six hours for it to take effect. -
The answer says which feed actually arrived, read out of the payload rather than
echoed from the request. A site that has moved to a different feed can then be told
apart from one that refreshed, which an echoed value could never do. -
A fetch says why it happened.
&trigger=syncgoes out with a pushed fetch, from a
closed list of names. It is a claim this site makes about itself and it is a label on a
log row, not an authorization.
Changed
-
A feed fetch that changed nothing no longer rebuilds the selection. It used to,
every time, andbuild_and_commit()is not a read: it stampstimes_shownand sets the
write-oncefirst_shown_aton every selected row. So a fetch nobody looked at started
each item's exposure window at a moment no visitor saw it, and on a quiet site the
guaranteed window could expire before a crawler ever arrived — the one failure the floor
exists to prevent. This affects the six-hourly timer as well as a push; both go through
the same call site. An unchanged fetch now skips the rebuild and the two cache purges,
and "unchanged" means the list a visitor would be served, not the response bytes. -
The feed request follows no redirects. It carries the shared feed token and this
site's sync key, and WordPress re-sends the whole header set on every hop including a
cross-host one — so a feed URL that lapses, or points at a shortener or a reconfigured
proxy, would hand a third party a token that reads every feed in the network. A 3xx is
now reported as a configuration error naming the target host, and the stored links are
left untouched. If a site's feed URL legitimately redirects —http://against a host
that upgrades tohttps://is the likely one — put the final address in the field. -
The Feed subscription tab shows when the push credential was last presented, and
counts refusals. There was previously no way to tell "the feed holds a stale key" from
"the feed has never pushed this site" from "somebody is probing the route".
Security
-
sync_keywas not inADVTN_Logger's scrub list. Nothing logged it, but the next
diagnostic somebody added would have written it in plaintext intoadvtn_log, which
renders on the Diagnostics tab and is served byGET /status. The list now matches
_key. -
The second key comparison in
ADVTN_Sync_Key::matches()was being skipped. It sat
behindis_wellformed( $previous ) &&, and&&short-circuits, so on every site that
has never replaced its key only one of the two comparisons ran. Both now run
unconditionally. Note that constant-time comparison here is held by review and not by
any test — timing is not unit-testable, and the docblock now says so rather than
claiming otherwise.
Notes for anybody debugging this later
-
The one retry is scheduled, not slept through. When a pushed fetch arrives behind
the version the pusher expected — the serving cache on the other end is 60 seconds with
no invalidation available — this site queues one more fetch a minute out and answers
immediately. Sleeping inside the request would put the response past a default nginx
fastcgi_read_timeoutof 60 seconds, and the pusher would read that504as a failed
push and drop this site from its list for being slow.It is queued through
ADVTN_Schedulerrather thanwp_schedule_single_event(), so it
shows up in the Diagnostics queue panel and instatus_payload()['pending_queue'], and
the response carriesretry_at. A timestamp in the past means the retry is overdue —
which is what a host with a blocked loopback looks like, and previously that state
answered identically to a healthy one and promised a repair forever.
Trending Now 1.3.0
Security
-
The updater installed whatever URL the release response named.
resolve_package()
tookbrowser_download_urlout of GitHub's JSON and handed it to WordPress, which
downloads it, unzips it over the plugin directory and runs it on the next request — and
when no asset matched it fell back tozipball_url, also verbatim. Nothing checked the
host, the owner or the repository.The download URL is now checked against a prefix compiled into the plugin,
https://github.com/advision-development/trending-now-plugin/releases/download/, and
refused if it contains... The second check is not belt-and-braces: HTTP clients
resolve dot segments out of a path before sending the request (RFC 3986), so
…/releases/download/../../../../someone/their-repo/…starts with that prefix, passes,
and downloads from another account — still ongithub.com, still answered200, and not
this plugin. The asset's name has to matchtrending-now-*.zipas well, so a release
carrying several files cannot have one of the others installed as the plugin.With a GitHub token the asset still goes through
api.github.com, because the storage
redirect rejects anAuthorizationheader. That URL is built here from the same pinned
prefix and an integer id, so it is safe by construction and deliberately not
prefix-checked afterwards — a guard no input can reach is a guard no test can hold in
place.
Added
-
The plugin keeps itself updated.
auto_update_pluginis answered for this plugin
only, so a release installs itself on WordPress's next update run instead of waiting for
somebody to open each site's Plugins screen. A network running several versions of this
plugin serves several different widgets, which is the reason.The way out is a filter,
advtn_auto_update, so a site that must not take unattended
updates can refuse from its own mu-plugin. The Keep this plugin updated setting is the
site-level switch and turns off checking entirely.Worth stating plainly: every other check here assumes the danger is a tampered answer, and
none of them help if a release is genuinely published from the pinned repository by
somebody who should not have been able to publish it. Unattended updates turn that from
"every site whose operator clicked" into "every site". The mitigation is the release
account, not this code. -
The Plugins screen says what the last check knew. WordPress's auto-update toggle is
replaced with a sentence, the state of the last check, and a Check for a new release now
link. The toggle is replaced rather than left in place because it could be switched off
and change nothing — a control that looks like it works and does not. -
A site now says which site it is when it fetches the feed. Two optional query
parameters go out with the request the plugin already makes every few hours:GET <feed url>&site=<home_url()>&v=<plugin version>There is no new endpoint and no handshake. A feed that does not know these parameters
serves exactly what it served before, so this needs no coordinated deploy — and the
central console can finally tell one subscriber from another. Until now the only thing
distinguishing them was an IP address, and an IP is not a site: measured across the
network on 2026-09-01, 144 hostnames resolve to 150 addresses with one shared server
holding seven of them.siteishome_url()and never a field somebody types. A typed field is a field
filled in wrong, and the far end turns this value into an address it will later contact —
so a mistake there is a request aimed at somebody else's site.home_url()is the value
WordPress already uses to build every link it prints.Empty values are omitted rather than sent blank: a parameter present and empty is a claim
that this site has no address, where absent is the truthful "this plugin did not say".
Fixed
-
A failed check is now remembered for an hour. Every failure returned before the
transient was written, so a site whose check failed asked GitHub again on the next check.
GitHub allows 60 unauthenticated requests an hour per IP and a hosting provider's
sites share one, which is how one rate-limited site is what keeps it rate-limited. The
reason is stored with it and printed, because a check that silently found nothing is
indistinguishable from one that never ran. -
Diagnostics no longer fetches while rendering. The Latest release row called
latest_release(), so every load of that tab was another request to the API. It reads
status()now, which never leaves the site. -
An up-to-date plugin reports itself instead of staying silent.
check_for_update()
answeredfalsewhen no update was available, which puts the plugin in neither
$updates->responsenor$updates->no_update— and WordPress readsno_updateto decide
whether a row offers automatic updates at all, which is where the state and the re-check
link are printed.wp_update_plugins()compares the versions and routes the answer
itself, so a known release is now always reported. It cannot cause a downgrade: a release
behind the installed copy fails that comparison and lands inno_update. -
Versions are padded to three components before being compared.
version_compare( '1.2', '1.2.0' )reports less-than, so an unpadded comparison against a
two-component header cleared a site that had an update waiting. A tag is also validated
rather than trimmed —ltrim( $tag, 'vV' )let a tag naming a branch through as though it
were a version. -
There is no zipball fallback. GitHub's generated archive is the development tree with
novendor/, so it installed a plugin whose Action Scheduler was absent and which
degraded to WP-Cron silently. No recognised asset now means no update offered. -
The Updates setting said the opposite of what it does. The label was rewritten to
"without being asked" when unattended updates landed and the description under it was left
saying WordPress still asks before installing — one cell answering the question two ways,
and the wrong answer was the reassuring one. It also described an auto-update toggle that
is no longer there.
Notes for anybody debugging this later
-
The asset name and the repository name are different strings, and both are pinned.
bin/releasebuildstrending-now-<version>.zipwhile the repository is
trending-now-plugin. The sibling scanner plugins use one constant for both because in
them the two happen to match; copying that check here would refuse every legitimate
release. -
The release cache is a blog transient, not a site transient. The scanners use site
transients and record that uninstall then has to delete them as such. These are separate
single-site installs rather than a network, so the two are equivalent here — and
uninstall.phpclears_transient_advtn_%through$wpdb, which a change of scope would
quietly step around. -
Only the origin survives on the other side. The path is discarded there, so a
subdirectory install sends its full home and loses the directory. Recorded rather than
worked around: no install in the network is in a subdirectory, measured rather than
assumed. -
ADVTN_Manual_Feed::identity()is pure and static so the decision is testable without
WordPress. The URL is assembled byadd_query_arg(), which is core's job — the parameters
are what this plugin decides, and a stub of core's URL builder could differ from it while
the test still passed.
Trending Now 1.2.0
Added
-
A site can subscribe its curated links to a feed. Instead of the same links being
typed into every site in a network, one list is maintained centrally and each site pulls
it on its own schedule. While subscribed, Manual links becomes a read-only mirror:
the rows render disabled and every fetch replaces them. Unticking Subscribed leaves the
links in place and editable again — turning off a sync must not delete content, and
nothing changes on the front end at that moment.Four settings —
manual_feed_url,manual_feed_token,manual_feed_interval_hours
(default 6) andmanual_feed_enabled. The token is optional: a public feed needs none,
and theAuthorizationheader is then omitted entirely rather than sent empty. -
POST /wp-json/advtn/v1/feed-fetch, signed with the same secret as/ingest, and
wp trending-now feed-fetch [--force]. The local timer is the mechanism; this is the
escape hatch for a site quiet enough that WP-Cron rarely fires.
Notes for anybody debugging this later
-
A response is judged by its body, never its status code. A feed served from a
single-page app's host answers200with HTML for any unrecognised path, so one letter
wrong in the URL looks exactly like success — on every subscribed site, for as long as
nobody checks. A payload counts only if it carries both afeedobject and anitems
list. -
A failed fetch changes nothing, verified byte-for-byte. This runs unattended on sites
nobody is watching, so a feed answering badly must cost them nothing. -
A
401does not mean the token is wrong. A feed answers401both for a gated feed
and for one that does not exist, identically and on purpose, so that nobody can discover
which feeds exist by guessing names. It is the one refusal this plugin cannot diagnose,
and the message says so rather than sending somebody to check the field most likely to be
correct. -
--forceskips the stored ETag as well as the interval, and that half is not a
convenience.If-None-Matchis the site claiming "I already hold version N" — precisely
what somebody forcing a fetch has stopped believing. Found by wiping one site's links and
forcing a fetch: the feed answered304, the plugin reported "The feed has not changed
since the last fetch", and the empty site stayed empty. Recovery would have had to wait
for an unrelated edit upstream to bump the version, on every site at once, with nothing on
screen explaining the wait.
Fixed
- The local development stack could not start from a clean checkout.
.gitignore's
*.sqlexcluded.docker/init-db.sql, whichdocker-compose.ymlmounts — so Docker
created a directory at the mount point and MariaDB died withCan't read from a directory 'stdin', an error naming neither the file nor the reason.
Trending Now 1.1.7
Some themes will not render a shortcode on the homepage. The workaround is to put the widget in a widget area instead — but a widget area is site-wide, so the block meant for the homepage then appears on every page. Nothing in the plugin could say render here, not there.
Added
-
match_pathon the shortcode,matchPathon the block. A comma-separated list of paths. Empty or absent renders everywhere, so nothing existing changes.[trending_now match_path="/,/trending"]Matching is exact and trailing-slash-insensitive:
/trendingcovers/trendingand/trending/, but not/trending/page/2/and not/trending-2024. That was chosen over prefix matching deliberately — a rule where/archivesilently swallows forty child URLs is harder to reason about than one where you add a second entry. If a section-wide rule is wanted later, a trailing*is the additive way in.Query strings are ignored, so
/?utm_source=xstill matches/. On a subdirectory install/means the site's homepage rather than the server root. A pasted full URL works — the scheme and host are stripped. Curated links are unaffected; this gates a placement, not an item.Both
match_pathandmatchPathwork on the shortcode.shortcode_parse_atts()lowercases attribute names while parsing the tag, beforeshortcode_atts()merges them against defaults, so a single-spelling attribute would have made the documented camelCase form a silent no-op the moment anyone typed a capital P. The block takesmatchPathonly — block attributes are not lowercased.The gate runs before the renderer. A non-matching placement adds no render-cache variant, and a matching one shares the variant it would have had without the attribute.
The block still previews in the editor regardless of the path, since it renders over REST with the editor's own URL. That bypass requires an edit capability, so an anonymous REST or admin-ajax read of rendered content is gated like any other request rather than exempted.
This does not change the theme restriction that pushed you into a widget. A theme that strips shortcodes from the homepage still will. What it fixes is the workaround leaking onto every other page.
Trending Now 1.1.6
Fixes archive pagination introduced in 1.1.5. Recommended for anyone running 1.1.5.
Fixed
-
The archive paginated over a set it was no longer showing. 1.1.5 made
max_age_hoursbound/trending/, but the archive's item count is cached in a transient for 15 minutes and nothing invalidated it when the set it counts changed. A site with 2,500 retained rows and 350 inside the window offered 50 pages of which 7 had anything on them, and the pages past the seventh returned200with an empty list —prepare_response()could not redirect them, because it decides what is out of range by reading the same stale number.ADVTN_Ingest::finalize()now drops the count. It already marks rows stale, prunes them and purges both the render cache and the host page cache; the count is derived from the same rows and had simply been left out.That omission outlived its own 15-minute TTL, which is why the wrong pagination persisted rather than ageing out: the page cache purge re-primes the archive HTML on the next request and bakes whatever count is current into pages that then live for the page cache's own lifetime.
Two further guards, because a 15-minute cache over a window that moves every second will always be slightly wrong at the edges:
- A page past the first that resolves to no rows now redirects to page 1 instead of serving an empty page
200. Empty indexable pages are worse than a redirect. - The upgrade to database version 3 clears any count cached by an earlier version, so existing sites are corrected on upgrade rather than leaving it for the first visitor to trip over.
ADVTN_Archive::current_items()is memoized so the new emptiness check costs no extra query — an archive pageview still issues exactly one, verified underSAVEQUERIES. - A page past the first that resolves to no rows now redirects to page 1 instead of serving an empty page
Reproduced at production scale before the fix (2,546 rows, 77 inside the window):
Page 1 of 51, with/trending/page/40/returning200and zero items. After: that page redirects,finalize()clears the count, and the upgrade path clears it too.
Trending Now 1.1.5
A SerpAPI source that fetched fine all afternoon began timing out overnight:
cURL error 28: Operation timed out after 5001 milliseconds with 0 bytes received
Nothing had broken. http_timeout defaults to 5 seconds and SerpAPI's endpoint does a live scrape whose latency varies. Three gaps turned a tunable-too-low into an outage nobody saw coming, and this release closes them.
Added
- Per-source HTTP timeout. Each source row takes its own
timeout, overriding the global. A slow news provider can have 30 seconds without an owned site waiting 30 seconds to fail. Blank or0inherits the global. - Attempt history. The last 20 fetches per source, with
p50,maxand the timeout in force shown together on the Sources tab. Latency drifting from 2s toward the ceiling is now a number you can see before it becomes a failure. - Ingest now. Retries one source from the admin — writes, rebuilds the list and purges caches, unlike Test fetch which deliberately writes nothing. No WP-CLI required. It ignores the failure backoff, because that is exactly when you would press it.
- Failure logs now carry
duration_msand the timeout in force, so a timeout explains itself instead of naming a number with no context.
Changed
max_age_hoursnow bounds/trending/as well as the widget, and defaults to 72. It previously defaulted to0— no cutoff — and applied only to widget selection, so the archive listed the entire 90-day retained set. An article four days old left the widget yesterday and sat on the archive for another twelve weeks. Rows outside the window stay in the table for deduplication until retention prunes them.exposure_floor_daysdefaults to 2, down from 3. The floor counts from when an item was first shown and the cutoff from when it was published, so an equal pair is cut short by any ingest lag. The Settings mismatch warning now fires when the two meet, not only when the floor exceeds the cutoff.
Upgrading: stored settings win over defaults, so an existing install keeps its current values. Check Settings → Maximum age if you want the new 72-hour window.
Fixed
http_timeoutnever applied torsssources at all.fetch_feed()builds its own SimplePie instance that WordPress never wires the timeout to, so every RSS source sat on SimplePie's default regardless of the setting.- A single-source retry no longer suppresses the next full cycle.
finalize()stampsadvtn_last_ingest, which gates the due-check, the 30-hour stale banner and thelast_ingestvalue external monitoring alerts on. Refreshing one source stamped it, deferring every other source's scheduled run by up toingest_interval_hours— making the button you press because a source is failing the thing that hid ingestion having stopped. Fixed for both the admin button andwp trending-now ingest --sync --source=<id>.
Trending Now 1.1.4
The minimum PHP version is now 7.4, down from 8.1.
Exactly two things required PHP 8 — a pair of str_ends_with() calls, in the release-asset check and the SerpAPI domain allowlist. Both are plain substr() comparisons now. Nothing else in the codebase used 8.0+ syntax or functions: the two modern features it leans on hardest, typed properties and arrow functions, both landed in 7.4. The old floor of 8.1 was where the plugin started, not something it had earned.
Verified by running it
On PHP 7.4.33 with WordPress 7.0.3, not by inspection:
- activation, schema creation, secret generation
- live SerpAPI fetches through both the top-stories and domain-allowlist paths
wp_restagainst two real network sources, featured images resolving- curated links, a full ingest cycle and selection
- all three layouts, the archive, relative timestamps
- the updater asset check and the page-cache purge
- all four admin tabs
Error log empty throughout. Every file also lints clean under 7.4, and the test suite passes on both 7.4 and 8.x.
One thing to check before installing
WordPress itself must be 6.4 or newer — that requirement has not changed, and it is enforced on the version header regardless of PHP. A site on old PHP is often on old WordPress too; if activation is refused, that is why.
Note
PHP 7.4 has been end-of-life since November 2022 and receives no security fixes. This release exists for a legacy install that cannot move yet — it is not a reason to stay there. Development continues against PHP 8.x.
No behaviour, data or settings changes.
Trending Now 1.1.3
Fixes the widget and the archive showing different articles, and adds an excerpt toggle.
The discrepancy
Not the selection — the page cache. On the site this was reported from, the two pages had been captured by WP Rocket nearly two hours apart, so each was serving whatever the list looked like at the moment it was cached. Anything ingested in between appeared on one and not the other.
The plugin busted its own render cache at the end of every ingest cycle and stopped there, which is only half the job on any cached site.
It now purges WP Rocket, LiteSpeed, W3 Total Cache, WP Super Cache, WP Fastest Cache, Cachify, SG Optimizer, Nginx Helper, Comet Cache, Breeze and Autoptimize whenever the selection is committed, curated links are saved, settings change, or the cache is purged by hand. Each integration is detected separately and the whole thing is wrapped, so a cache that refuses to clear cannot take down an ingest cycle. advtn_purge_page_cache fires first for anything not listed, including CDNs.
Settings and Diagnostics both report which cache was detected, so an unsupported one is visible rather than silently doing nothing.
Worth knowing regardless: the archive lists everything retained, fifty per page, while the widget is a curated selection — news share, per-source cap, age cutoff. A widget item can legitimately sit several pages into the archive. The widget is not the first N of the archive.
Added
show_excerpton the shortcode, the block and the display settings, in all three layouts and on the archive. Off by default: plenty of sources return no excerpt — Google News does not — so switching it on globally gives a ragged mix of items with and without.show_iconsis now exposed on the shortcode and block too, which it should have been when it was added.
If you have custom CSS for the archive
From 1.1.1 the archive renders with the widget classes (…__items, …__item, …__source) instead of its own …-archive__items markup, so both views match by construction. Custom CSS written against the old class names will stop applying — rename the selectors or drop them, since the plugin now styles it.
Full detail in CHANGELOG.md.
Trending Now 1.1.2
Styling fix for sites where the widget did not match the intended layout — bullet points reappearing and the thumbnail sitting below the headline instead of beside it.
Two separate causes, both about the cascade rather than the CSS being missing.
Inheritance. list-style: none was set on the <ul> only. That value inherits down to each <li>, and an inherited value loses to any direct declaration on the element — even a generic li { list-style: disc } at specificity (0,0,1). It is now set on the list items themselves, with ::marker { content: none } behind it.
Specificity. The structural rules were written at (0,2,0), which an everyday theme selector such as .entry-content ul li (0,2,1) outranks. Losing display: flex is precisely what drops the image onto its own line. Selectors are now compounded on the root class, and display, flex-wrap, list-style, margin and padding are marked important — this markup is injected into themes the plugin does not control.
No changes to behaviour, data or settings. Update and hard-refresh; if you run a page cache, purge it, since the stylesheet is embedded in the cached HTML.
Trending Now 1.1.1
Presentation release: the widget now looks like a news feed out of the box, and three ordering and layout bugs are fixed.
This is the first release existing sites can install by themselves — 1.1.0 shipped the updater, so a 1.1.0 install offers this on the Plugins screen with no manual upload. Verified end to end against this release.
Release lookups are cached for six hours, so a site may not notice immediately; Trending Now → Diagnostics → Check for updates forces it.
Added
newsis the default layout, thumbnails on. A bare[trending_now]now renders source line, headline and an image pinned to the right of each row, rather than a plain text list. Sites that never chose a layout inherit it; anything set explicitly is untouched.- Maximum age cutoff (
max_age_hours) — hide anything published longer ago than the window.48for a two-day feed,0to disable. It applies before the selection tiers, so it outranks the exposure floor; the admin warns when a floor is set longer than the cutoff, since that promises a run the cutoff will not let finish. - Timestamps read
45mor6hinside the last day and a date beyond it, with a style toggle (date_style) to force dates instead. Relative stamps read as live, but on a once-a-day cycle every item drifts towards20htogether, which advertises the batch update — the toggle trades one against the other. - Site icons beside source names (
show_icons, off by default). - Display settings — layout, thumbnails, source names, timestamps and their style are now configurable in the admin and act as defaults for the shortcode, block and template tag.
- SerpAPI top stories mode — Google News mainstream front page for a country and language, no query required. Returns roughly ten articles per fetch, so a large news share wants more than one source.
- The archive matches the widget, using the same card markup, classes and display settings instead of its own unstyled list.
Fixed
- The newest articles were rendered last. The list was sorted by selection tier before publication date, so anything already shown outranked anything brand new — on a twelve-slot widget the two freshest stories sat at positions 11 and 12. Tiers decide which items are selected, not the order they appear in.
- The thumbnail wrapped below the headline. The base item rule sets
flex-wrap: wrap, which the news rule never reset, so a long headline claimed the whole line and pushed the image down. Short headlines happened to look right, which is why it read as intermittent. - Relative timestamps were frozen in the cache. The label was baked in at build time and the cache is only busted once per ingest cycle, so an article rendered at
42mstill claimed42mup to twenty hours later. Labels are now recalculated per request from thedatetimeattribute already in the markup — no database access, and the cached blob stays valid.
Notes
Setting news_share_pct to 50 also needs max_source_share_pct at 50 if the news comes from a single source; otherwise the per-source cap throttles it and the shortfall is quietly backfilled with network links.
Full detail in CHANGELOG.md.