* WebSub: ignore http/https scheme difference in Self URL comparison
The PubSubHubbub endpoint logged 'Self URL does not match registered
canonical URL' whenever a feed's self URL differed from the registered
canonical URL only by http vs https (common with http->https redirects),
spamming the log. Compare the URLs ignoring the http/https scheme while
still requiring host/path/query to match exactly.
Closes#3087
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Stylistic preferences
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
## Problem
The feed **tree** (left sidebar) and the **feed-title view** (main body) order feeds differently for names with accents or symbols. As reported in #8300, a Spanish feed starting with `Ú` appears **last** in the tree, after every ASCII-named feed.
The tree is sorted in PHP by `FreshRSS_Category::sortFeeds()` with `strnatcasecmp()`, which compares raw UTF-8 **bytes** — so any name starting with a non-ASCII character (`0xC3…` for `Á`, `Ñ`, `Ú`, …) sorts after `A–Z`. The main view is sorted by the database according to its collation, which orders accented letters near their base letter — hence the mismatch (as @Alkarex noted in the issue).
Closes#8300.
## Change
`sortFeeds()` now compares with a `Collator` (`ext-intl`, already a hard requirement) for the user's language, falling back to the previous `strnatcasecmp()` if a collator cannot be created:
```php
$collator = \Collator::create(FreshRSS_Context::hasUserConf() ? FreshRSS_Context::userConf()->language : '');
```
Sorting a Spanish set, before / after:
```
old: banana, Manzana, nube, Zorro, Ábaco, Ñandú, Úlcera, árbol
new: Ábaco, árbol, banana, Manzana, nube, Ñandú, Úlcera, Zorro
```
This makes the tree order locale-aware and much closer to the main view. Exact parity with the main view still depends on the database collation (which is admin/DB-specific and cannot be reproduced generically in PHP), but the "accented names sort last" problem is gone.
* Feeds tree: sort feed names with locale-aware collation
The feed tree sorted names byte-wise with strnatcasecmp(), which places
names starting with a non-ASCII character (e.g. an accented capital such
as "U-acute") after every ASCII name. Use a Collator for the user's
language so accented and non-ASCII names sort near their base letter,
closer to the database collation used on the main feed-title view.
For https://github.com/FreshRSS/FreshRSS/issues/8300
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Guard Collator with class_exists() so intl stays optional
The intl extension is only a recommended (not required) dependency, but
sortFeeds() called \Collator::create() unconditionally when a user language
was set. On installs without intl the \Collator class does not exist, so the
call raised a fatal "Class \"Collator\" not found" — effectively promoting
intl from recommended to required.
Guard the call with class_exists('Collator'): when intl is absent, fall back
to the previous byte-wise strnatcasecmp() sort. Locale-aware ordering stays a
progressive enhancement and the listed requirements are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Shorten the sortFeeds() collation comment per review
Trim the rationale comment to a concise four lines as suggested in review,
keeping the locale-aware/fallback and class_exists() reasoning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Stylistic preference
* Factorise localeCompare
* Minor code preference
* Preserve natural locale sort behaviour
* Avoid repeated locale lookup in comparator
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Closes#6223.
Supersedes the investigation draft in #6765.
## Summary
- Removed the remaining explicit uses of the `simple` layout from the email-validation flows.
- Deleted `app/layout/simple.phtml` so those pages now use the shared `layout.phtml`.
- Kept the existing `disable_aside` behavior for the profile page while an email validation token is pending.
## Notes
The replacement for `simple.phtml` is the normal shared layout. Access while email validation is pending is still constrained by the existing `FreshRSS::checkEmailValidated()` route guard, which allows the validation/profile/logout paths and forwards other routes to `user#validateEmail`.
* Add option to keep or reset article sort order when navigating
Adds a new per-user reading option, "Keep the current sort order when
navigating between categories and feeds" (config key: sticky_sort), under
Settings > Reading > "Left navigation: Categories". It defaults to enabled,
which reproduces the current behaviour exactly.
Background (addresses #8953)
----------------------------
Once a sort/order was active in one view, it was propagated to every sidebar
link and to the "mark all as read" redirect. Opening another category then
ignored that category's own defaultSort/defaultOrder, because FreshRSS_Context
only applies those defaults when sort/order are absent from the request. As a
result the current view's sort (whether chosen manually or resolved from that
view's default) "bled" into the next category/feed.
Behaviour of the new option
---------------------------
- ON (default): unchanged. The current sort/order stays sticky while
navigating, exactly as before.
- OFF: sidebar links and the "mark all as read" redirect no longer carry
sort/order, so each target resolves its own order in FreshRSS_Context:
feed/category defaultSort/defaultOrder, falling back to the global Reading
sort criterion when none is configured.
order/sort propagation across the UI (for reference)
----------------------------------------------------
- Categories & feeds (sidebar): now gated by sticky_sort (app/layout/aside_feed.phtml).
- "Mark all as read" -> next category, both the top navigation button and the
stream-footer button: now gated by sticky_sort
(app/Controllers/entryController.php::readAction, non-AJAX redirect).
- Custom user queries: unaffected. A query's link is a self-contained URL built
from that query's own saved parameters, so switching query A -> B never
carried A's sort; no option is needed there.
- Stream transition links when sorting by category/feed name: intentionally
left unchanged (out of scope; the sort is intrinsic to that grouping).
Compatibility
-------------
- Not a breaking change: the default (true) reproduces the previous behaviour.
- No database migration. The setting is a standard user configuration value
with its default declared in config-user.default.php, so existing users keep
the previous behaviour transparently.
- The read/unread state filter still persists across navigation regardless of
this option.
i18n
----
English strings authored; en-US marked as IGNORE. Translations for the other
25 languages were produced with an automated (AI) translation service and
should be reviewed by native speakers.
* Update app/i18n/en/conf.php
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
* Comments preference
* Update app/i18n/en/conf.php
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
* i18n updates and comment
* Update translations to match reworded sticky_sort strings
Re-applies the 24 non-English/French translations of the "sticky_sort"
reading option after the English and French wording was updated to
"Keep manual sort order during navigation".
* improve Dutch
* i18n DIRTY
* i18n en: custom sort order
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
- Add a new `EntriesRead` / `entries_read` hook for extensions.
- Trigger the hook when `markRead()` changes entry read/unread state for explicit entry IDs.
- Cover both the default DAO path and the SQLite/PGSQL DAO path.
- Add Minz hook tests for the new hook signature and argument passing.
## Why
Extensions can already react to favorite/bookmark changes through `EntriesFavorite`. This adds the matching read/unread hook requested in #4051 for extensions that need to collect stats or react to manual read state changes.
Closes#4051
Two "uses cache" LOG_DEBUG syslog() calls were emitted on every feed
refresh regardless of the simplepie_syslog_enabled setting, so users who
disabled it still saw "FreshRSS SimplePie uses cache for ..." spam in
syslog (issue #8540). Guard both with simplepie_syslog_enabled, like the
neighbouring SimplePie GET logs already are. Real error and fatal logs
are left untouched.
For https://github.com/FreshRSS/FreshRSS/issues/8540
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Generate new articles notification string in PHP
Closes#6470
Changes proposed in this pull request:
- Generate notifications string in PHP
- Use plurals support
* New articles notification: switch to plural forms
* README status
* fix paramInt invocation
* i18n
* make fix-all
* Apply suggestion from @Frenzie
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Feeds moved between instances via OPML lost their refresh interval,
forcing manual reconfiguration. Add a 'frss:ttl' attribute (signed, so a
negative value also carries the muted state) to the OPML export, and read
it back on import. Documented in docs/en/developers/OPML.md.
For https://github.com/FreshRSS/FreshRSS/issues/5914
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat: report new articles count per feed in actualize_script output
Closes#8291
## Changes proposed in this pull request
`app/actualize_script.php` now reports, for each feed that received new articles, how many new articles were fetched — in addition to the existing per-user summary. This makes batch refreshes (cron / systemd timers) much more informative.
Example output:
```
FreshRSS actualize admin…
10 new article(s) from feed: FreshRSS releases
3 new article(s) from feed: Some other blog
```
The feature is implemented **entirely within the CLI script**, reusing the existing extension-hook mechanism that the script already uses for the actualization mutex — no change to `feedController` or to any other behaviour:
- `FeedBeforeActualize` records each feed's name.
- `EntryBeforeAdd` counts new entries per feed. This hook fires only in the "genuinely new GUID" branch of the actualization loop (updated articles go through `EntryBeforeUpdate`), so the count matches the controller's own `$nbNewArticles` counter. Because the counting hook is registered after `$app->init()`, it runs last in the chain and is skipped for entries dropped by another extension — i.e. it counts only articles that are actually added.
Counts are printed through the script's existing `notice()` helper, so they reach STDOUT regardless of the `environment` setting (unlike `Minz_Log::notice`, which is suppressed in `production`).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Minor
* Remove tab
The tab character is escaped in some logs, e.g.
```
[notice] --- \x091 new article(s) from feed: L’Express
```
* Sort per-feed output and also report updated articles
Addresses review feedback on #8948:
- Sort the per-feed report by descending article count, then feed name.
- Also count updated articles (via EntryBeforeUpdate) and report them
alongside new ones, e.g. '10 new, 2 updated article(s) from feed: X'.
EntryBeforeAdd fires only for new articles (new-GUID branch), so
updated articles need the separate EntryBeforeUpdate hook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Reduced code
* Do not report feeds with no new or updated articles
FeedBeforeActualize initializes every actualized feed in $feedStatistics
and fires before the TTL/mute skips, so skipped or unchanged feeds would
otherwise print an empty count (' article(s) from feed: X'). Skip them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Minor code logic preference
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Worked on Turkish translation
* Add my name to CREDITS.md
* Run make fix-all
* Fix credits
---------
Co-authored-by: Utku Tibet <utkutibet@Utkus-MacBook-Pro.local>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Add SSRF mitigations using `filter_var` and `CURLOPT_RESOLVE`
The idea is to prevent FreshRSS from sending any HTTP requests to internal services, except for the ones that are explicitly allowed in the config.
Based on 6e82b46a48/lib/filelib.php (L3818) and https://github.com/symfony/symfony/blob/8.1/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.phphttps://github.com/FreshRSS/simplepie/pull/76https://github.com/FreshRSS/simplepie/pull/78
* Add allowlist setting in Web UI
* make readme
* Update app/i18n/fr/admin.php
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* make readme again
* make readme
* Further work
Still WIP and needs testing etc.
* Readd previous if check for domain combination allowlist
* Turn POST to GET after redirect
* Improve
* Update config.default.php
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* make readme
* Skip SSRF check if `CURLOPT_PROXY` is set
* make readme
* Fix `!empty()` mistake
* Respect max redirects feed option when fetching with `httpGet()`
* Respect max redirects during SimplePie fetching + fix bypass
bypass fix: `CURLOPT_FOLLOWLOCATION` was moved below so that emulated redirects are enforced.
* Avoid FreshRSS and Minz code in SimplePie
https://github.com/FreshRSS/FreshRSS/pull/8400#discussion_r2935375980
* Corrected hook code
* phpdoc wrong return type
* Add CIDR support in allowlist
* Implement simple DNS caching
* Suppress `dns_get_record()` warnings
* A bit of proof-reading
* Minor typo
* Fix proxy logic
* Fix HTTP POST redirect logic
* Proofread checkCIDR
Add fixes for several situations
* Remove credentials from URL in logs
* Ensure `CURLOPT_FOLLOWLOCATION` is `false` by setting it at the end
* Fix codesniffer long line
* Fix potential bypass due to wrong return value
If there were no records returned by `dns_get_record()`, no overrides to `CURLOPT_RESOLVE` would get passed,
and a potential bypass could occur, when cURL would try to resolve the domain by itself.
* Put the URL at the end in logs
* Add documentation and environment variable support
* make readme
* Fix wrong behavior in case of IP
* Fix duplicate selector in CSS
* Minor type check change
* i18n fr, en
* Minor type check change
* Fix whitespace i18n fr
* make fix-all
* Fix `$ips_ok` not being returned after domain records were cached
* make readme
* PHPStan fix
* make readme
* Minor syntax in SimplePie
* Only return `null` if no allowed IPs were found
* Add wildcard *, help message
* Consistent docs with help message
* i18n: pl
* SimplePie compatibility PHP 7.2
* make fix-all
* Sync SimplePie
* https://github.com/FreshRSS/simplepie/pull/76
* 💥 Breaking change in the Changelog
* Document `INTERNAL_HOST_ALLOWLIST` in Docker docs
* Remove `Cookie` and `Authorization` headers in `httpGet()` during cross-origin redirect
* Minor whitespace
And same comment convention than below
* Remove authentication headers and change POST to GET on redirect in SimplePie
* Remove .local in Docker example
* Fill in default ports when comparing URL origins
* Remove .local from other places than the Docker example
* Rewrite WebSub subscribe to use `httpGet()`
* make fix-all
* Also unset `CURLOPT_USERPWD` during redirects
* phpcs fix
* Always unset `CURLOPT_FOLLOWLOCATION`
* Bump SimplePie
https://github.com/FreshRSS/simplepie/pull/78
* Update logic for CURLOPT_FOLLOWLOCATION
* Fix PHPStan
* Changelog fix security section
* Update most common RSS Bridge case
https://hub.docker.com/r/rssbridge/rss-bridge
* Replace misleading 127.0.0.1:8080 example for Docker
This does not make sense for a Docker container
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* fix: move disable button before remove button in user management
The order of action buttons when modifying a user was inconsistent.
The "disable" button appeared after the "remove" button, which is
counterintuitive since disabling a user is a less destructive action
than removing one.
This commit reorders the buttons in `details.phtml` to reflect the
severity of each action, from least to most destructive:
update → purge → promote/demote → disable/enable → delete
Closes#8877
* Remove blank lines
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
I already changed some places that were using normal string comparison to hash_equals before, but didn't check other places.
Now the checks should be consistent.
* Add a warning message to the API password section, add a log warning when a client uses GET, use POST in sample curl API usage
References #8834.
* Apply suggestion from @Inverle
Co-authored-by: Inverle <inverle@proton.me>
* Apply suggestion from @Inverle (Polish translation)
Co-authored-by: Inverle <inverle@proton.me>
* French translation
* Dutch translation
* German
* Apply suggestion from @Inverle api via post
Co-authored-by: Inverle <inverle@proton.me>
* Apply suggestion from @Inverle via POST (French)
Co-authored-by: Inverle <inverle@proton.me>
* Add user-agent
* update translation info in README
* avoid phpcs line too long
* also trigger on email as suggested by @Inverle
* i18n: fr typography
* make fix-all
---------
Co-authored-by: Inverle <inverle@proton.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
#8839
**Change(s)**
- Fix some typos
- Revise the translations that I hadn't checked before
- Add some new translations
**File(s) changed**
`app/i18n/zh-TW/{admin,conf,gen,sub,user}.php`
`README.md`
`README.fr.md`
* feat(import): accept .txt URL lists alongside OPML/JSON/ZIP
Detects .txt by extension and wraps the URL list into a minimal OPML
document so the existing import pipeline handles dedup, categories and
feed limits unchanged. Blank lines, `#` comments and a UTF-8 BOM are
skipped; lines that don't parse as URLs are logged and dropped without
aborting the batch.
Works through both `cli/import-for-user.php` and the web import form.
* utf8BOM
* ENT_COMPAT
---------
Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Add an opt-in CLI that exports each user's database to
`data/users/<user>/sqlite-backups/<YYYYMMDDTHHMMSSZ>.sqlite` (UTC) and
prunes older files to a configured count. Gated by two new settings,
`auto_sqlite_export.enabled` and `auto_sqlite_export.retention`.
Kept separate from `cli/db-backup.php` / `cli/db-restore.php`, which
stay the fixed-filename migration tool. First step of #8183.
Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
Replace four :has(~.date) selectors with a has-date modifier class
on .flux_header, mirroring the existing has-thumbnail and has-summary
classes. The condition (whether the date is rendered) is known
server-side and can be exposed on the parent <ul> directly. Same
elements matched in modern browsers. The rules now also apply in
browsers without :has() support, fixing the date column overlapping
the title in SeaMonkey (#6776).
Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
The multiline class on the title <a> existed only so the parent <li>
could detect it via :has(.multiline). The condition (thumbnail or
summary enabled) is already exposed on the parent <ul> by the
has-thumbnail and has-summary classes, so keying the rule on those
directly removes the indirection. Same elements matched in modern
browsers. The rule now also applies where :has() isn't supported,
relevant to #6776.
Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
When topline_website is "icon", the <li> renders class="item website
icon". Bare .icon rules in Nord and Dark apply a filter that cascades
onto the favicon <img> inside, so article-list favicons render tinted
in icon-only mode. No theme CSS targets the unprefixed value; layout
modes are driven from the parent <ul> (.websiteicon, .websitename).
Co-authored-by: Bjørn A. Andersen <polybjorn@users.noreply.github.com>
The internal search was shown in the UI as the user search.
This was due to the lazy nature of the Generator. Improve the try/catch behaviour at the same time.
How to test:
* Make a user query with a search parameter
* Share the user query as HTML
* Observe the search field (should be empty with this PR, while it contained the internal search before)
We were not vulnerable to it, but here is an additional layer of security against search ingestions, in particular in public user queries, where someone could try to ingest a search like `) OR (interesting`