500 Commits

Author SHA1 Message Date
supahgreg
a95f0a4844 Uncomment the Weblate item and mention which platforms Docker images are created for. 2025-10-06 21:26:17 +00:00
Greg
2cfb58cf8c Merge pull request #8 from tt-rss/feature/switch-to-github-org
Switch links to the 'tt-rss' GitHub organization
2025-10-06 14:33:29 -05:00
supahgreg
a7d5566aa9 Switch links to the 'tt-rss' GitHub organization. 2025-10-06 19:02:28 +00:00
Greg
14ac789ddc Merge pull request #7 from supahgreg/bugfix/idiorm-composer-info
Correct idiorm dependency info
2025-10-06 11:37:54 -05:00
supahgreg
d89e825c8a Correct idiorm dependency info. 2025-10-06 16:01:54 +00:00
supahgreg
7c69ee73e1 minor: Tweak some words in the README. 2025-10-06 03:23:43 +00:00
supahgreg
f23fff932a README improvements. 2025-10-06 03:12:06 +00:00
supahgreg
958d25b831 minor: Update the wiki link in 'Config::sanity_check()'. 2025-10-06 02:45:35 +00:00
supahgreg
d5c3d75ff2 Add a GitHub pull request template. 2025-10-05 21:42:17 +00:00
supahgreg
b49de6e92c Add some basic content to 'CONTRIBUTING.md'. 2025-10-05 21:32:25 +00:00
supahgreg
ceab15254b minor: make PHPStan happy after a4eed151b7 2025-10-05 21:23:24 +00:00
supahgreg
a4eed151b7 minor: Deal with potentially-missing 'inverse' key when testing filters. 2025-10-05 21:19:42 +00:00
Greg
18f59793e0 Merge pull request #1 from supahgreg/feature/phpstan-bump
Bump PHPStan to 2.1.30 and address new findings.
2025-10-05 15:53:55 -05:00
supahgreg
e0b116f904 Bump PHPStan to 2.1.30 and address new findings.
Also some minor adjacent cleanup.
2025-10-05 20:51:13 +00:00
supahgreg
ec367b23f4 Switch from 'tt-rss-web-static' links to wiki links. 2025-10-05 20:18:56 +00:00
supahgreg
eb05374f24 minor wording tweaks in the Enhancement Request issue template. 2025-10-05 18:36:58 +00:00
supahgreg
0b50b0f1f0 Add some GitHub issue templates. 2025-10-05 18:34:18 +00:00
supahgreg
63464f3729 Add a Dependabot config for PHP package dependency updates. 2025-10-05 05:46:10 +00:00
supahgreg
a135edcfb1 Drop the 'eslint-formatter-gitlab' dev dependency. 2025-10-05 05:26:50 +00:00
supahgreg
2878ae815f Bump node dev dependencies. 2025-10-05 05:23:35 +00:00
supahgreg
46ebef7ebf Bump Dojo from 1.16.5 to 1.17.3.
This should help with some of the security findings.
2025-10-05 04:34:29 +00:00
supahgreg
6e8a188e4a Include a short SHA tag when publishing images. 2025-10-05 03:58:49 +00:00
supahgreg
435358265c Provide expected Docker image build args in the Publish workflow. 2025-10-05 01:51:55 +00:00
supahgreg
cb17b3b95e Mention that Docker images are now being published. 2025-10-05 00:56:27 +00:00
supahgreg
c3fbc81e19 Ignore various paths in the Publish workflow. 2025-10-05 00:55:00 +00:00
supahgreg
0bc55e8366 Enable building+publishing the 'web-nginx' image. 2025-10-05 00:33:04 +00:00
supahgreg
b6fd27c756 Restore behavior of using the 'latest' tag on images.
A 'main' tag was being created+used.

https://github.com/docker/metadata-action?tab=readme-ov-file#latest-tag
2025-10-05 00:21:36 +00:00
supahgreg
fc95bae2a6 Add a Publish workflow. 2025-10-05 00:13:35 +00:00
supahgreg
c3f3eb8387 Revert PHPUnit job name change from 3d8c54877f.
GitHub doesn't handle long-ish names well in the UI, so just go with the default behavior.
2025-10-04 22:20:50 +00:00
supahgreg
3d8c54877f Update some names in the PHP Code Quality workflow. 2025-10-04 22:17:15 +00:00
supahgreg
a32720615f Also trigger the PHP Code Quality workflow if 'phpunit.xml' changes. 2025-10-04 21:56:58 +00:00
supahgreg
4f2423198a Add a workflow job for PHPUnit. 2025-10-04 21:53:44 +00:00
supahgreg
664f37ae71 Appeasing PHPStan.
The two if statements involved are related to calls to 'tt-rss.org' that are intentionally disabled.
2025-10-04 21:20:32 +00:00
supahgreg
591ee81ad3 Add a GitHub workflow for code quality (initially existing PHPStan). 2025-10-04 21:15:25 +00:00
supahgreg
4583ae8dc3 Mention why the commit history is a bit inaccurate on 'github.com'. 2025-10-04 00:52:51 +00:00
supahgreg
d7a91de140 Initial attempt to support both 'main' and 'master' branches. 2025-10-03 22:03:52 +00:00
supahgreg
5e99eb41ec Remove references to, and integrations with, 'tt-rss.org'. 2025-10-03 21:00:43 +00:00
Andrew Dolgov
c67b943aa8 Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!188
2025-10-02 11:17:38 +03:00
Rivo Zängov
7c79b771e1 Translated using Weblate (Estonian)
Currently translated at 41.5% (292 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/et/
2025-10-01 13:02:03 +00:00
Andrew Dolgov
b369bf8107 Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!187
2025-09-27 21:22:48 +03:00
BlueTurtle
437f8515ad Translated using Weblate (French)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/fr/
2025-09-27 12:02:22 +02:00
Andrew Dolgov
35aa534c71 Merge branch 'bugfix/cli-opml-export-owner_uid' into 'master'
Fix OPML exporting via 'update.php'.

See merge request tt-rss/tt-rss!186
2025-09-18 07:18:17 +03:00
wn_
98779acdc5 Fix OPML exporting via 'update.php'. 2025-09-18 00:29:31 +00:00
Andrew Dolgov
139632c417 Merge branch 'builtin-feed' into 'master'
use built-in feed for integration test

See merge request tt-rss/tt-rss!185
2025-09-02 22:24:44 +03:00
Andrew Dolgov
36644365c8 use built-in feed for integration test 2025-09-02 22:22:24 +03:00
Andrew Dolgov
be3ee920b1 Merge branch 'update-comicpress' into 'master'
Update ComicPress logic

See merge request tt-rss/tt-rss!183
2025-09-02 21:24:44 +03:00
vjkcxl
c914d0710f Fix Danby Draws 2025-09-02 11:15:21 -05:00
vjkcxl
17bd835530 Revert whitespace changes 2025-09-02 10:59:59 -05:00
vjkcxl
17c6d7af8d Hopefully fix PHPStan 2025-09-01 14:42:00 -05:00
vjkcxl
6c0bcd90ed Try to add types 2025-09-01 14:32:23 -05:00
vjkcxl
efe6fbd3fa Update ComicPress logic
This updates the logic to work across a variety of additional sites.
Additionally, it grabs the author's comments from comics, such as the text on Buttersafe.

This does not update the list of supported comics.
There are too many comic sites to enumerate all of them anyway.
2025-09-01 14:06:10 -05:00
wn
98dbf49733 Merge branch 'bugfix/published-feed-title' into 'master'
Fix getting the title for syndicated feeds.

See merge request tt-rss/tt-rss!182
2025-08-29 10:03:27 -05:00
wn_
ecef0ae951 Tweak the 'Feeds::_get_title()' param order to make PHP happy.
Required params need to go before optional.
2025-08-29 12:28:05 +00:00
wn_
fd5ce90efe Minor fix in 'OPML#opml_export()'. 2025-08-29 12:22:27 +00:00
wn_
e5c5a1bf42 Make 'owner_uid' required for 'Feeds::_get_title()' and 'Feeds::_get_cat_title()'. 2025-08-29 12:19:04 +00:00
wn_
9aafc7bb8d Fix getting the title for syndicated feeds.
https://gitlab.tt-rss.org/tt-rss/tt-rss/-/merge_requests/181 missed that 'Feeds::_get_title()' gets invoked when headlines are generated for syndicated feeds.
2025-08-29 12:04:20 +00:00
Andrew Dolgov
57cd48d9f7 Merge branch 'bugfix/limit-more-by-uid' into 'master'
Filter more results by user ID.

See merge request tt-rss/tt-rss!181
2025-08-26 08:14:10 +03:00
wn_
9982871ac1 Filter more results by user ID. 2025-08-25 16:06:41 +00:00
Andrew Dolgov
2d12ced897 Merge branch 'feature/guzzle-7.10.0' into 'master'
Bump Guzzle to 7.10.0 for PHP 8.5 compatibility.

See merge request tt-rss/tt-rss!180
2025-08-25 13:39:41 +03:00
wn_
618cb5bf78 Bump Guzzle to 7.10.0 for PHP 8.5 compatibility.
https://github.com/guzzle/guzzle/compare/7.9.2...7.10.0
2025-08-24 14:40:07 +00:00
Andrew Dolgov
f7fc00326e Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!179
2025-08-15 08:02:19 +03:00
Edgars Andersons
31d5887831 Translated using Weblate (Latvian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2025-08-14 20:02:12 +00:00
Andrew Dolgov
0669995c47 Merge branch 'autoswitch-retries' into 'master'
bump amount of retries when trying to auto-switch theme

See merge request tt-rss/tt-rss!178
2025-08-14 18:37:05 +03:00
Andrew Dolgov
25f416719b bump amount of retries when trying to auto-switch theme 2025-08-14 18:34:26 +03:00
Andrew Dolgov
e8db412dfc Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!177
2025-08-13 22:05:27 +03:00
josé m
c60b4a6a69 Translated using Weblate (Galician)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/gl/
2025-08-13 19:02:17 +00:00
Andrew Dolgov
a627f40477 Merge branch 'docker-updater-patch' into 'master'
Add update-ca-certificates to updater.sh

See merge request tt-rss/tt-rss!176
2025-08-13 15:02:31 +03:00
Julian Labus
1cd05e5b61 Add update-ca-certificates to updater.sh 2025-08-13 13:38:33 +02:00
Andrew Dolgov
20843a9d15 Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!175
2025-08-11 21:45:06 +03:00
Patrick Ahles
aff4e3e840 Translated using Weblate (French)
Currently translated at 99.4% (699 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/fr/
2025-08-11 18:02:49 +02:00
Andrew Dolgov
13e1d674ee Merge branch 'bugfix/default-dark-theme-bookmarklets' into 'master'
Fix default dark mode CSS path in UtilityJS, use configurable default themes in bookmarklets.

See merge request tt-rss/tt-rss!174
2025-08-10 17:30:57 +03:00
wn_
b803f85ec2 Fix default dark mode CSS path in UtilityJS, use configurable default themes in bookmarklets. 2025-08-10 12:27:38 +00:00
Andrew Dolgov
c6624d06a8 Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!173
2025-08-10 12:26:28 +03:00
Edgars Andersons
9de87345c2 Translated using Weblate (Latvian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2025-08-09 14:01:52 +02:00
Andrew Dolgov
4758c5f0bf Merge branch 'navigator-offline' into 'master'
check if backend is reachable when handling night mode change instead of...

See merge request tt-rss/tt-rss!172
2025-08-08 14:56:44 +03:00
Andrew Dolgov
d84260b59d check if backend is reachable when handling night mode change instead of relying on navigator.onLine 2025-08-08 14:36:52 +03:00
Andrew Dolgov
2ec0aa7cad Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!171
2025-08-08 14:23:18 +03:00
Edgars Andersons
91ac7c3f1f Translated using Weblate (Latvian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2025-08-08 09:02:29 +00:00
ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝)
6fc1ba172c Translated using Weblate (Latvian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2025-08-07 07:01:59 +02:00
Andrew Dolgov
75b035b93c Merge branch 'fix-youtube-nocookie' into 'master'
Fix youtube-nocookie support

See merge request tt-rss/tt-rss!170
2025-08-05 07:30:20 +03:00
vjkcxl
9fd54db30d Fix youtube-nocookie support 2025-08-04 22:55:28 -05:00
Andrew Dolgov
906063b1d1 Merge branch 'configurable-default-themes' into 'master'
make default light/dark themes configurable, add support for main application and login form

See merge request tt-rss/tt-rss!169
2025-08-04 20:09:08 +03:00
Andrew Dolgov
19fc3bff21 drop unused legacy (?) handler method OPML->import() 2025-08-04 20:05:59 +03:00
Andrew Dolgov
0d0745da44 add necessary plumbing for auto light/dark switch for the forgotpass handler 2025-08-04 19:54:12 +03:00
Andrew Dolgov
8eb340c3ca make default light/dark themes configurable, add support for main application and login form 2025-08-01 17:22:21 +03:00
Andrew Dolgov
851ddf4bbe Merge branch 'publishedcounter' into 'master'
implement special counter display when viewing by published, similar to marked

See merge request tt-rss/tt-rss!168
2025-07-30 20:23:50 +03:00
Andrew Dolgov
c7aa33fe8a fix publishedcounter not working for feed counters 2025-07-30 20:04:02 +03:00
Andrew Dolgov
aeb0d42f89 add missing count_published to get_cats() and fix some misleading indenting 2025-07-30 19:57:56 +03:00
Andrew Dolgov
bc312b1205 implement special counter display when viewing by published, similar to marked 2025-07-30 19:24:53 +03:00
wn
8b07dc8453 Merge branch 'retry-night-mode-switch' into 'master'
attempt to retry night mode switch if we're offline

See merge request tt-rss/tt-rss!167
2025-07-27 12:22:58 -05:00
Andrew Dolgov
524bdeb419 attempt to retry night mode switch if we're offline 2025-07-27 20:07:55 +03:00
Andrew Dolgov
bb39b34d72 Merge branch 'bugfix/media-thumbnail' into 'master'
Look for media thumbnails in more places.

See merge request tt-rss/tt-rss!166
2025-07-27 07:50:28 +03:00
wn_
f62aaa307b Look for media thumbnails in more places.
https://www.rssboard.org/media-rss#optional-elements
2025-07-26 20:00:25 +00:00
Andrew Dolgov
dea3f2dcb2 Merge branch 'no-more-666' into 'master'
set sane permissions on cache/lockfiles/feed-icons instead of hardcoding a+rwx

See merge request tt-rss/tt-rss!165
2025-07-12 14:35:03 +03:00
Andrew Dolgov
d9e2cd44ce set sane permissions on cache/lockfiles/feed-icons instead of hardcoding a+rwx 2025-07-12 08:33:51 +03:00
wn
d3599707ac Merge branch 'feature/article-display-url-skip-request' into 'master'
Use existing headline info for 'Article.displayUrl()'

See merge request tt-rss/tt-rss!164
2025-07-08 10:41:13 -05:00
wn_
5b99ccf662 Use existing headline info for 'Article.displayUrl()' 2025-07-08 10:54:10 +00:00
Andrew Dolgov
da4b886f08 Merge branch 'feature/feedparser-tweaks' into 'master'
Disallow subscribing if feed content is invalid

See merge request tt-rss/tt-rss!163
2025-07-07 07:38:21 +03:00
wn_
0cd788220d Separate feed type detection from init, don't subscribe on failures.
Also some FeedParser tweaks.
2025-07-06 02:33:18 +00:00
Andrew Dolgov
46e05583a9 Merge branch 'pidlock' into 'master'
Pidlock

See merge request tt-rss/tt-rss!162
2025-07-04 14:01:37 +03:00
Andrew Dolgov
dea41c6a3d Merge branch 'master' of gitlab.tt-rss.org:tt-rss/tt-rss 2025-07-04 13:59:28 +03:00
Andrew Dolgov
b989b1cd5b stop checking for pidlock in yet another place 2025-07-04 13:59:22 +03:00
Andrew Dolgov
05a90d4ce1 Merge branch 'undying' into 'master'
Undying

See merge request tt-rss/tt-rss!161
2025-07-04 13:32:57 +03:00
Andrew Dolgov
775a6066f1 Merge branch 'master' of gitlab.tt-rss.org:tt-rss/tt-rss 2025-07-04 13:31:30 +03:00
Andrew Dolgov
ec0a19c5a6 replace all instances of die() with print+exit because die() returns exit code 0 2025-07-04 13:31:15 +03:00
Andrew Dolgov
9b38a57570 Merge branch 'housekeeping' into 'master'
Housekeeping

See merge request tt-rss/tt-rss!160
2025-07-04 13:24:28 +03:00
Andrew Dolgov
b0dc82dc7e only exit with nonzero exit code if there was an error 2025-07-04 13:22:06 +03:00
Andrew Dolgov
79e0d6ecc2 run housekeeping tasks on task 0 if task id is passed, stop checking for pidlock 2025-07-04 13:18:50 +03:00
Andrew Dolgov
c1542671c1 return nonzero exit code when fatal error is triggered in on a CLI SAPI 2025-07-04 13:17:25 +03:00
Andrew Dolgov
0034cd69f8 Merge branch 'housekeeping_task' into 'master'
only run housekeeping on task 0 when --feeds is invoked with pidlock and task...

See merge request tt-rss/tt-rss!159
2025-07-04 13:02:54 +03:00
Andrew Dolgov
7b4f039651 Merge branch 'master' of gitlab.tt-rss.org:tt-rss/tt-rss 2025-07-04 13:00:45 +03:00
Andrew Dolgov
2e62d27b9f only run housekeeping on task 0 when --feeds is invoked with pidlock and task id, similar to daemon-loop 2025-07-04 13:00:26 +03:00
Andrew Dolgov
ee2f556265 Merge branch 'scripts' into 'master'
add separate script that invokes update.php with args, add basic info blurbs to other scripts

See merge request tt-rss/tt-rss!158
2025-07-04 09:48:55 +03:00
Andrew Dolgov
629535329d add separate script that invokes update.php with args, add basic info blurbs to other scripts 2025-07-04 09:46:46 +03:00
Andrew Dolgov
50eff08fcb Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!157
2025-07-04 07:50:05 +03:00
தமிழ்நேரம்
7c34df1946 Translated using Weblate (Tamil)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ta/
2025-07-04 00:07:24 +02:00
தமிழ்நேரம்
f99ef00b0a Translated using Weblate (Tamil)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ta/
2025-07-04 00:07:22 +02:00
தமிழ்நேரம்
7ab3eb8431 Translated using Weblate (Tamil)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ta/
2025-07-04 00:07:20 +02:00
தமிழ்நேரம்
7ba4b40a63 Translated using Weblate (Tamil)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ta/
2025-07-04 00:07:18 +02:00
Andrew Dolgov
2643440aba Merge branch 'master' of gitlab.tt-rss.org:tt-rss/tt-rss 2025-07-03 21:39:25 +03:00
Andrew Dolgov
fb219848b6 add same workaround to updater startup script 2025-07-03 21:39:17 +03:00
Andrew Dolgov
f57bb8ec24 Merge branch 'sslmode' into 'master'
add workaround for what possibly is a PDO bug related to tls connections to pgsql

See merge request tt-rss/tt-rss!156
2025-07-03 21:37:43 +03:00
Andrew Dolgov
bab03ea516 add workaround for what possibly is a PDO bug related to tls connections to pgsql 2025-07-03 21:30:55 +03:00
Andrew Dolgov
2d56105c93 Merge branch 'sslmode' into 'master'
add support for PG sslmode and set it to prefer encrypted connections by default

See merge request tt-rss/tt-rss!155
2025-07-03 19:24:04 +03:00
Andrew Dolgov
4088636865 add support for PG sslmode and set it to prefer encrypted connections by default 2025-07-03 19:04:32 +03:00
wn
18f8f55ce5 Merge branch 'search-by-tags' into 'master'
allow searching by tags (prefix tag:)

See merge request tt-rss/tt-rss!154
2025-06-23 11:17:52 -05:00
Andrew Dolgov
12ef981bfb allow searching by tags (prefix tag:) 2025-06-23 18:56:47 +03:00
Andrew Dolgov
664f832aac Merge branch 'feature/subscription-fail-info' into 'master'
Show more info when subscribing fails

See merge request tt-rss/tt-rss!153
2025-06-21 07:52:36 +03:00
wn_
09c11df764 Clean up displaying subscription error info, log more detailed info to the event log. 2025-06-19 20:37:49 +00:00
wn_
692c7a8949 Remove unused subscription return code 6 2025-06-17 18:02:57 +00:00
wn_
5b0d325733 Escape error content displayed when subscribing fails (as it might contain HTML). 2025-06-17 17:59:10 +00:00
wn_
ef1f3cbcef Show some HTML content as a hover tip when the 'feed URL' returned HTML without feeds.
Also tweak the 'Feeds::_subscribe()' documentation a bit.
2025-06-17 17:52:06 +00:00
Andrew Dolgov
4e47a39c2a Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!152
2025-06-09 20:19:28 +03:00
Yurt Page
8cfb96ed54 Translated using Weblate (Russian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ru/
2025-06-09 17:02:42 +00:00
Yurt Page
f549459a5c Translated using Weblate (Russian)
Currently translated at 83.0% (584 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ru/
2025-06-08 18:07:22 +02:00
Yurt Page
c3a0968697 Translated using Weblate (Russian)
Currently translated at 82.7% (582 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ru/
2025-06-08 15:27:18 +00:00
Andrew Dolgov
b6f0724d51 Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!151
2025-06-08 17:32:25 +03:00
Yurt Page
1e35eb9b1b Translated using Weblate (Russian)
Currently translated at 82.3% (579 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ru/
2025-06-08 15:42:05 +02:00
Andrew Dolgov
3d43eecc50 Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!150
2025-06-04 08:11:32 +03:00
Dario Di Ludovico
2da58d7ff0 Translated using Weblate (Italian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/it/
2025-06-03 19:38:43 +00:00
Andrew Dolgov
a1dcd06b3e Merge branch 'feature/syndicated-feed-improvements' into 'master'
Generated feed tweaks

See merge request tt-rss/tt-rss!149
2025-06-03 19:47:14 +03:00
wn_
3047b294a6 Minor CSS+style tweaks in 'Handler_Public#generate_syndicated_feed()'. 2025-06-03 15:17:27 +00:00
wn_
814ab48169 Use the current timestamp for feed-level 'updated' in 'Handler_Public#generate_syndicated_feed()'.
The last article's 'updated' value was not a good indicator of when the feed updated for various reasons, so we'll just use the current timestamp to represent the dynamic nature of the content.
2025-06-03 14:10:08 +00:00
wn_
446f9dcb23 Style tweaks in 'Handler_Public#generate_syndicated_feed()' 2025-06-03 14:10:08 +00:00
wn_
8255f71c2e Fail early in 'Handler_Public#generate_syndicated_feed()' on unrecognized format. 2025-06-03 14:10:08 +00:00
Andrew Dolgov
87fb1de91d Merge branch 'feature/json-mime-type' into 'master'
Use the official JSON MIME type of 'application/json'

See merge request tt-rss/tt-rss!146
2025-06-03 16:57:15 +03:00
wn_
34c7e11d84 Use the official JSON MIME type of 'application/json'. 2025-06-03 13:53:12 +00:00
Andrew Dolgov
2095052521 Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!148
2025-06-03 16:44:44 +03:00
Patrick Ahles
30bf1e9dbe Translated using Weblate (Dutch)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/nl/
2025-06-03 15:39:21 +02:00
Edgars Andersons
ca5ef48df2 Translated using Weblate (Latvian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2025-06-03 15:39:21 +02:00
Patrick Ahles
93a41aa282 Translated using Weblate (French)
Currently translated at 98.7% (694 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/fr/
2025-06-03 15:39:21 +02:00
Andrew Dolgov
18b67c837c Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Chinese (Traditional Han script))

See merge request tt-rss/tt-rss!147
2025-06-03 16:39:01 +03:00
Andrew Dolgov
e63fca3dec Merge branch 'master' into weblate-integration 2025-06-03 16:36:56 +03:00
Andrew Dolgov
227d45687f php8.3 -> php8.4 2025-06-03 10:34:45 +03:00
TonyRL
408ab99450 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/zh_Hant/
2025-06-01 23:37:59 +00:00
Andrew Dolgov
516fcfee98 Merge branch 'feature/alpine-3.22' into 'master'
Bump to Alpine 3.22

See merge request tt-rss/tt-rss!144
2025-05-31 19:10:16 +03:00
Andrew Dolgov
166495ebbf Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!145
2025-05-31 19:09:57 +03:00
TonyRL
558327b3b5 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 99.0% (696 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/zh_Hant/
2025-05-31 18:03:58 +02:00
wn_
0c8ec170f9 Bump to Alpine 3.22 2025-05-30 18:33:43 +00:00
Andrew Dolgov
6abd7fdc4c Merge branch 'weblate-integration' into 'master'
WIP: weblate-integration

See merge request tt-rss/tt-rss!143
2025-05-29 07:54:55 +03:00
Andrew Dolgov
0a6b41a3df let's use gitlab rss feed in phpunit for now 2025-05-29 07:48:45 +03:00
Achim Schumacher
fc6ce314d6 Translated using Weblate (German)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/de/
2025-05-28 18:03:10 +00:00
Andrew Dolgov
a26d12ba3b auto create MR on push into weblate-integration 2025-05-27 13:09:29 +03:00
Andrew Dolgov
0688e6dadd cache_starred_images - make scheduled tasks intervals configurable 2025-05-27 08:27:49 +03:00
Andrew Dolgov
0787af48ff add phpunit when running without /tt-rss 2025-05-24 16:48:20 +03:00
wn
c236560b70 Merge branch 'limit-web-root-to-nginx' into 'master'
remove APP_WEB_ROOT from fpm container because it expect a different value...

See merge request tt-rss/tt-rss!142
2025-05-24 08:29:41 -05:00
Andrew Dolgov
52267d2639 remove APP_WEB_ROOT from fpm container because it expect a different value from nginx container, replace with APP_INSTALL_BASE_DIR specific to fpm container 2025-05-24 13:10:28 +03:00
Andrew Dolgov
9d4e945386 Merge branch 'feature/drop-migrate_feed_icons' into 'master'
Drop legacy feed icon storage migration and unused 'Config::ICONS_DIR'.

See merge request tt-rss/tt-rss!141
2025-05-22 22:45:02 +03:00
wn_
25d8655214 Drop legacy feed icon storage migration and unused 'Config::ICONS_DIR'. 2025-05-22 18:05:02 +00:00
Andrew Dolgov
3783d987e5 Merge branch 'unprotected-phpunit' 2025-05-22 20:53:16 +03:00
Andrew Dolgov
a2a4e831cf set some needs 2025-05-22 20:51:50 +03:00
Andrew Dolgov
3e5889394a use generic push commit job 2025-05-22 20:50:46 +03:00
Andrew Dolgov
eb2ef63c46 Merge branch 'scheduler-env' into 'master'
make default task schedules configurable

See merge request tt-rss/tt-rss!140
2025-05-22 20:37:46 +03:00
Andrew Dolgov
dba83a639c fix wrong config param being used & add a link to cron syntax we support 2025-05-22 20:36:09 +03:00
Andrew Dolgov
581fbd762d try running integration tests without harbor 2025-05-22 20:23:28 +03:00
Andrew Dolgov
b25684a5a6 make default task schedules configurable 2025-05-22 20:01:00 +03:00
Andrew Dolgov
6695a81e3a drop legacy ci file 2025-05-22 19:57:05 +03:00
Andrew Dolgov
777e60f854 drop integration test include, drop legacy CI file 2025-05-22 19:56:36 +03:00
Andrew Dolgov
9b998b3069 fix typo 2025-05-22 14:58:57 +03:00
Andrew Dolgov
fa9da54d91 shorten services 2025-05-22 14:57:23 +03:00
Andrew Dolgov
0a3bec7201 switch selenium to services 2025-05-22 14:44:54 +03:00
Andrew Dolgov
91ed6e6f62 add app_fastcgi_pass to gitlab-ci 2025-05-22 14:34:37 +03:00
Andrew Dolgov
a51d5ac438 add APP_FASTCGI_PASS 2025-05-22 14:22:40 +03:00
Andrew Dolgov
b29de3eb7c add APP_WEB_ROOT to fpm container 2025-05-22 14:05:24 +03:00
Andrew Dolgov
819fde7318 try switching phpunit off helm 2025-05-22 13:54:58 +03:00
Andrew Dolgov
f91c19b040 Merge branch 'protected/selenium-standalone' 2025-05-22 12:45:35 +03:00
Andrew Dolgov
f21c0aa8f6 Merge branch 'master' of gitlab.tt-rss.org:tt-rss/tt-rss 2025-05-22 12:45:31 +03:00
Andrew Dolgov
349d4c5931 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Chinese (Simplified Han script))

See merge request tt-rss/tt-rss!139
2025-05-22 12:44:57 +03:00
Andrew Dolgov
9c66b9b326 adjust timeouts again 2025-05-22 12:42:41 +03:00
Andrew Dolgov
80647fa4e8 fix typo 2025-05-22 12:40:23 +03:00
Andrew Dolgov
cb6fe8f974 adjust timeouts 2025-05-22 12:36:20 +03:00
Ptsa Daniel
45fc831243 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/zh_Hans/
2025-05-22 08:59:04 +00:00
Andrew Dolgov
99caba74a9 also add driver implicit wait for element search 2025-05-22 11:48:40 +03:00
Andrew Dolgov
33047a86ba try adding selenium page timeout 2025-05-22 11:45:08 +03:00
Andrew Dolgov
b447cda515 increase selenium timeout/attempts 2025-05-22 11:23:01 +03:00
Andrew Dolgov
2294090778 switch selenium to standalone container 2025-05-22 11:17:30 +03:00
wn
b9b5c378b0 Merge branch 'feature/filter-action-refs' into 'master'
Improve naming when working with filter actions.

See merge request tt-rss/tt-rss!138
2025-05-21 15:00:31 -05:00
wn_
df28c71641 Improve naming when working with filter actions.
Also updated some related typing and documentation.
2025-05-21 18:34:16 +00:00
Andrew Dolgov
9ffb096e87 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (German)

See merge request tt-rss/tt-rss!137
2025-05-21 18:50:07 +03:00
Achim Schumacher
5cf787064d Translated using Weblate (German)
Currently translated at 98.2% (691 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/de/
2025-05-21 11:04:07 +02:00
Andrew Dolgov
eab69f8796 Merge branch 'feature/orm-and-misc' into 'master'
Use ORM in more places, deprecate const SUBSTRING_FOR_DATE, some minor fixes

See merge request tt-rss/tt-rss!135
2025-05-19 08:06:56 +03:00
Andrew Dolgov
f4973264d3 Merge branch 'feature/cache_starred_images-media' into 'master'
Improve media detection in 'cache_starred_images'.

See merge request tt-rss/tt-rss!136
2025-05-19 07:56:27 +03:00
wn_
a164790268 Improve media detection in 'cache_starred_images'.
This was mostly a copy from 'RSSUtils::cache_media()'.
2025-05-19 02:17:41 +00:00
wn_
ce36b27a0d Fix check for no articles found in 'RSSUtils::update_rss_feed()'.
FeedParser will always return an array.
2025-05-18 16:08:13 +00:00
wn_
2749c75b72 Minor ORM usage tweak in 'RSSUtils::update_rss_feed()'. 2025-05-18 16:06:44 +00:00
wn_
adf71f09a9 Use ORM in remaining parts of 'Pref_Users'. 2025-05-18 15:46:35 +00:00
wn_
cdd48bb1fa Use ORM in 'Counters::get_feeds()' (and simplify stuff). 2025-05-18 14:51:47 +00:00
wn_
2fa54cc627 Deprecate and remove use of the 'SUBSTRING_FOR_DATE' constant.
With MySQL support removed (b154bc7a10) this constant is unnecessary.
2025-05-18 14:26:05 +00:00
wn_
0acaac7115 Remove an outdated check for 'E_DEPRECATED' existence. 2025-05-18 14:09:27 +00:00
Andrew Dolgov
d859c56636 send content-length with cached files 2025-05-18 09:06:01 +03:00
Andrew Dolgov
895e29ec26 Merge branch 'feature/remove-past-comparison-qpart' into 'master'
Get rid of 'Db::past_comparison_qpart()'.

See merge request tt-rss/tt-rss!134
2025-05-18 07:50:05 +03:00
wn_
c472f00445 Get rid of 'Db::past_comparison_qpart()'.
With MySQL support dropped this function is just an unnecessary layer of abstraction.
2025-05-17 19:08:15 +00:00
Andrew Dolgov
868c1cadad API/getFeeds: return feed last_error & update_interval 2025-05-17 12:49:41 +03:00
Andrew Dolgov
aa58ab1ce0 drop gocomics and other inactive af_comics filters - third time the charm 2025-05-17 08:03:09 +03:00
Andrew Dolgov
2e50f96901 Revert "drop gocomics and other inactive af_comics filters"
This reverts commit 5f064b4477.
2025-05-17 08:02:42 +03:00
Andrew Dolgov
d0d90e4ec8 Merge branch 'master' of gitlab.fakecake.org:git/tt-rss/tt-rss 2025-05-17 08:02:11 +03:00
Andrew Dolgov
888bf821d6 drop gocomics and other inactive af_comics filters 2025-05-17 08:01:57 +03:00
Andrew Dolgov
5f064b4477 drop gocomics and other inactive af_comics filters 2025-05-17 08:00:04 +03:00
Andrew Dolgov
a931b91099 af_comics - fix penny arcade to new markup 2025-05-17 07:57:39 +03:00
Andrew Dolgov
8aac6f2d3d return standard Content-Length header for API responses in addition to nonstandard Api-Content-Length 2025-05-16 22:02:25 +03:00
Andrew Dolgov
191da49ab1 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Galician)

See merge request tt-rss/tt-rss!133
2025-05-16 18:31:10 +03:00
josé m
ea7e8404cb Translated using Weblate (Galician)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/gl/
2025-05-09 06:01:51 +02:00
Andrew Dolgov
0affed20d2 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Latvian)

See merge request tt-rss/tt-rss!132
2025-05-06 19:42:45 +00:00
Besnik Bleta
e68069d33e Translated using Weblate (Albanian)
Currently translated at 95.4% (671 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/sq/
2025-05-06 21:41:32 +02:00
Edgars Andersons
b0402aba34 Translated using Weblate (Latvian)
Currently translated at 100.0% (703 of 703 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2025-05-06 21:41:31 +02:00
Andrew Dolgov
b73ab44e21 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Tamil)

See merge request tt-rss/tt-rss!131
2025-05-06 19:41:15 +00:00
Andrew Dolgov
8b3bd37549 Merge branch 'pg-optimize' into 'master'
drop some pointless queries now that we can use RETURNING for inserts

See merge request tt-rss/tt-rss!130
2025-05-06 04:44:03 +00:00
Andrew Dolgov
77e5deb9dd use RETURNING syntax when creating base filter record during OPML import 2025-05-06 05:06:17 +03:00
Andrew Dolgov
e91c49b747 use RETURNING syntax when creating article record in share anything 2025-05-06 05:04:47 +03:00
Andrew Dolgov
9735ff83cc use RETURNING syntax when creating base filter record 2025-05-06 05:01:23 +03:00
Andrew Dolgov
ea6f42dc61 switch insert query for base article record to named parameters and add previously missing ts_content stuff 2025-05-05 22:08:01 +03:00
Andrew Dolgov
677cd7453f drop some pointless queries now that we can use RETURNING for inserts 2025-05-05 21:55:38 +03:00
Andrew Dolgov
070585ac5e only open PDO transaction while performing CRUD operations on article 2025-05-05 20:45:40 +03:00
Hosted Weblate
f1df08ba20 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/
2025-05-05 18:06:49 +02:00
Besnik Bleta
9d7344dfa5 Translated using Weblate (Albanian)
Currently translated at 95.8% (666 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/sq/
2025-05-05 18:06:47 +02:00
தமிழ்நேரம்
2464153ffc Translated using Weblate (Tamil)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ta/
2025-05-05 18:06:47 +02:00
Andrew Dolgov
6d11acc713 Merge branch 'plugin-cringe' into 'master'
expose scheduled tasks to plugins, switch cache_starred_images plugin to use...

See merge request tt-rss/tt-rss!129
2025-05-04 17:40:13 +00:00
Andrew Dolgov
4cc40ddaa4 scheduler - only register built-in purge_orphaned_scheduled_tasks if running as default name 2025-05-04 20:25:29 +03:00
Andrew Dolgov
5263a07f61 record last cron expression (and stub owner_uid) used by scheduled task 2025-05-04 18:06:43 +03:00
Andrew Dolgov
fc059fc0fc expose scheduled tasks to plugins, switch cache_starred_images plugin to use them instead of housekeeping hook 2025-05-04 17:50:03 +03:00
Andrew Dolgov
d4faf2d369 Merge branch 'feature/move-purge_orphaned_scheduled_tasks' into 'master'
Move registration of 'purge_orphaned_scheduled_tasks' into Scheduler.

See merge request tt-rss/tt-rss!128
2025-05-04 14:21:33 +00:00
wn_
3ee0f331cc Move registration of 'purge_orphaned_scheduled_tasks' into Scheduler. 2025-05-04 14:20:17 +00:00
Andrew Dolgov
07eb34529f Merge branch 'feature/purge-orphaned-scheduled-tasks' into 'master'
Periodically purge orphaned scheduled task records

See merge request tt-rss/tt-rss!126
2025-05-04 14:10:56 +00:00
Andrew Dolgov
8f9f06e7c0 Merge branch 'feature/scheduled-task-no-update-users' into 'master'
Move DAEMON_UPDATE_LOGIN_LIMIT-related logging to a scheduled task, exclude disabled+readonly users

See merge request tt-rss/tt-rss!127
2025-05-04 14:09:32 +00:00
wn_
853864794a Move logging users excluded from updates to a daily scheduled task, exclude disabled or readonly users. 2025-05-04 13:28:09 +00:00
wn_
868385442a Periodically purge orphaned scheduled task records. 2025-05-04 12:57:58 +00:00
Andrew Dolgov
4ce5e6e8e1 rebase translations 2025-05-04 13:45:30 +03:00
Andrew Dolgov
fecab891fc add a basic prefs panel for scheduled task records 2025-05-04 13:44:08 +03:00
Andrew Dolgov
ec12a514a2 Revert "bring back cleanup of potentially sensitive environment variables but exclude CLI SAPI to prevent updater failures"
Breaks OIDC

This reverts commit 247efe3137.
2025-05-04 13:30:07 +03:00
Andrew Dolgov
5eba9fd116 Merge branch 'weblate-integration' into 'master'
Added translation using Weblate (Albanian)

See merge request tt-rss/tt-rss!125
2025-05-04 10:22:48 +00:00
Besnik Bleta
f9c0aacf72 Added translation using Weblate (Albanian) 2025-05-04 12:21:27 +02:00
Andrew Dolgov
bb0a136944 Merge branch 'cringe-jobs' into 'master'
Cringe jobs

See merge request tt-rss/tt-rss!124
2025-05-04 09:36:27 +00:00
Andrew Dolgov
01159fa6f8 error handler - dump caught exception/fatal error to debug log if running under CLI SAPI 2025-05-03 08:18:16 +03:00
Andrew Dolgov
4cda1da5c0 adjust scheduler logging to be somewhat more alike to feed updater 2025-05-03 07:55:45 +03:00
Andrew Dolgov
997c10437e reorder housekeeping tasks by interval 2025-05-02 23:26:13 +03:00
Andrew Dolgov
55bb464cc9 update static composer autoload 2025-05-02 23:24:09 +03:00
Andrew Dolgov
d5d15072e1 move scheduled tasks to a separate class, add some try-catches, improve/shorten logging and descriptions 2025-05-02 22:51:07 +03:00
Andrew Dolgov
5256edd484 schema - spaces to tabs 2025-05-02 21:51:07 +03:00
Andrew Dolgov
bc0da8edb6 Merge branch 'marked-hook' into 'master'
add plugin hooks invoked when articles get un/marked or un/published

See merge request tt-rss/tt-rss!123
2025-05-02 18:40:47 +00:00
Andrew Dolgov
3098dc0a16 rename article mark/publish hooks 2025-05-02 21:28:37 +03:00
Andrew Dolgov
b30f8c93a0 rename article mark/publish hooks 2025-05-02 21:27:50 +03:00
Andrew Dolgov
dc6ea08ca4 add workaround for due tasks because housekeeping is not run every minute, fix last_run not updated to NOW() in the db 2025-05-02 14:03:45 +03:00
Andrew Dolgov
247efe3137 bring back cleanup of potentially sensitive environment variables but exclude CLI SAPI to prevent updater failures 2025-05-02 13:37:08 +03:00
Andrew Dolgov
aeca30cb0c drop SIMPLE_UPDATE_MODE, limit housekeeping and updates to background processes 2025-05-02 13:26:58 +03:00
Andrew Dolgov
a51c1d5176 fix tasks_run never incremented 2025-05-02 13:18:48 +03:00
Andrew Dolgov
36f60b51d7 make digest sending a hourly cron job 2025-05-02 13:17:20 +03:00
Andrew Dolgov
44b5b33f3d remove synchronous usages of _purge_orphans() 2025-05-02 10:28:35 +03:00
Andrew Dolgov
a268f52de6 record task duration in seconds 2025-05-02 10:23:30 +03:00
Andrew Dolgov
6a40940ad6 split housekeeping jobs to separate scheduled tasks on longer cooldown intervals, add table to record task execution timestamps, bump schema 2025-05-02 10:17:13 +03:00
Andrew Dolgov
f22e32a26b import cron-expression 2025-05-02 08:53:38 +03:00
Andrew Dolgov
0520ca2226 deal with published hook in _create_published_article 2025-05-02 08:26:52 +03:00
Andrew Dolgov
5f70e41118 add plugin hooks invoked when articles get un/marked or un/published 2025-05-01 22:36:33 +03:00
wn
4ae17d0f1c Merge branch 'feature/phpstan-updates' into 'master'
PHPStan update and addressing findings

See merge request tt-rss/tt-rss!122
2025-04-30 16:34:52 +00:00
Andrew Dolgov
4cb8a84df4 Merge branch 'rip-mysql' into 'master'
initial attempt to remove mysql-related stuff from tt-rss

See merge request tt-rss/tt-rss!120
2025-04-28 04:48:01 +00:00
wn_
f097c5ed97 Remove an unnecessary session UID existence check in 'UserHelper::authenticate()'.
PHPStan 'if.alwaysTrue'
2025-04-27 15:13:15 +00:00
wn_
1c9fddd757 Add a PHPStan ignore for a non-issue in 'UrlHelper::fetch()'. 2025-04-27 15:05:28 +00:00
wn_
5c2c95a897 Remove unused 'PluginHost::HOOK_FORMAT_ARTICLE_CDM'.
PHPStan 'method.notFound'.
2025-04-27 14:53:53 +00:00
wn_
ae5394f6f9 Address 'method.resultUnused' in 'api/index.php'. 2025-04-27 14:50:02 +00:00
wn_
7ad1efed3e Bump PHPStan to 2.1.13 2025-04-27 14:41:39 +00:00
wn_
0961c8bd4c Remove a PHPStan ignore related to PHP < 8 2025-04-27 14:39:06 +00:00
Andrew Dolgov
f80187e05f Merge branch 'master' into rip-mysql 2025-04-25 18:54:29 +03:00
Andrew Dolgov
0e4b8bd653 add eslint-formatter-gitlab npm dependency 2025-04-25 18:53:55 +03:00
Andrew Dolgov
be82663ac9 cache_starred_images: disable chmod() on cache directory, it doesn't seem to be necessary anymore and breaks on S3 cache implementation 2025-04-17 17:25:13 +03:00
Andrew Dolgov
75556e2f3d Merge branch 'master' into rip-mysql 2025-04-17 14:07:26 +03:00
Andrew Dolgov
d2ccdaf400 Merge branch 'fix-schema' into 'master'
* fix 148 migration for pgsql not setting default value of

See merge request tt-rss/tt-rss!121
2025-04-17 10:43:50 +00:00
Andrew Dolgov
f7199a47c2 * fix 148 migration for pgsql not setting default value of ttrss_feeds.auth_pass breaking OPML import
* replace no-op migrations for mysql with 'select 1'
2025-04-17 11:51:47 +03:00
Andrew Dolgov
8cf3059951 more type hints 2025-04-14 15:31:06 +03:00
Andrew Dolgov
945690fffc add some type hints 2025-04-14 15:28:57 +03:00
Andrew Dolgov
3c138a71a1 add deprecation notice for sql_random_function() 2025-04-14 15:27:58 +03:00
Andrew Dolgov
54e8ab7e3d update DB_TYPE deprecation notice 2025-04-14 15:24:34 +03:00
Andrew Dolgov
7e403aae92 further mysql/DB_TYPE related cleanup 2025-04-14 15:21:10 +03:00
Andrew Dolgov
b154bc7a10 initial attempt to remove mysql-related stuff from tt-rss 2025-04-14 12:59:00 +03:00
fox
60606aaa97 Merge branch 'mysql-noop' into 'master'
no-op auth_pass varchar to text migration for mysql

See merge request git/tt-rss/tt-rss!8
2025-04-14 09:39:11 +00:00
Andrew Dolgov
561d922e78 no-op auth_pass varchar to text migration for mysql 2025-04-14 12:36:13 +03:00
Andrew Dolgov
50e614499b Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Chinese (Traditional Han script))

See merge request tt-rss/tt-rss!119
2025-04-14 06:24:42 +00:00
Andrew Dolgov
f9e8911727 Revert "cleanup environment variables related to global configuration after instantiating config object"
This reverts commit e4f1480453.
2025-04-08 18:38:10 +03:00
Andrew Dolgov
44e23469a0 Merge branch 'less-leaks' into 'master'
cleanup environment variables related to global configuration after instantiating config object

See merge request tt-rss/tt-rss!118
2025-04-08 11:16:22 +00:00
Andrew Dolgov
e4f1480453 cleanup environment variables related to global configuration after instantiating config object 2025-04-08 14:09:15 +03:00
Andrew Dolgov
008c518d5d Merge branch 'session-encryption' into 'master'
add optional encryption for stored session data using Sodium library

See merge request tt-rss/tt-rss!117
2025-04-08 10:54:24 +00:00
Andrew Dolgov
17b4e98249 spaces to tabs 2025-04-08 13:52:00 +03:00
Andrew Dolgov
597971f238 we no longer directly modify schema_version in migrations 2025-04-08 09:48:44 +03:00
Andrew Dolgov
f00d9a18f8 if possible, automatically encrypt stored plaintext password for feed on update 2025-04-08 09:43:03 +03:00
Andrew Dolgov
eedc1460e5 support transparent encryption for feed passwords, bump schema to drop length limit of ttrss_feeds.auth_pass 2025-04-08 09:36:04 +03:00
Andrew Dolgov
25d3ce4ee8 drop SESSION-specific stuff and move encrypt/decrypt helpers to a separate class; add a command line flag to generate encryption keys 2025-04-08 08:55:44 +03:00
TonyRL
aa552ef057 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/zh_Hant/
2025-04-08 00:41:40 +00:00
Andrew Dolgov
58677fc791 rename SODIUM_ENCRYPTION_KEY to SESSION_ENCRYPTION_KEY and move related stuff to Sessions class 2025-04-07 20:28:35 +03:00
Andrew Dolgov
026d68fc2d add optional encryption for stored session data using Sodium library 2025-04-07 20:09:31 +03:00
wn
bb2c4b3801 Merge branch 'allow-session-cookies' into 'master'
allow setting lifetime to 0 for session cookies

See merge request tt-rss/tt-rss!116
2025-04-07 06:58:33 +00:00
Andrew Dolgov
20ba3c67cc allow setting lifetime to 0 for session cookies 2025-04-07 07:14:01 +03:00
Andrew Dolgov
eaacca5792 Merge branch 'bugfix/hook-feed-basic-info-result' into 'master'
Only use valid feed basic info from plugins.

See merge request tt-rss/tt-rss!115
2025-04-04 18:02:15 +00:00
wn_
e1256b06ea Only use valid feed basic info from plugins. 2025-04-04 17:53:47 +00:00
wn
f70cd0d149 Merge branch 'bugfix/gocomics-changes-2' into 'master'
Use the correct suffix for GoComics permalinks.

See merge request tt-rss/tt-rss!114
2025-04-04 17:42:31 +00:00
wn_
7cef3a5ac2 Use the correct suffix for GoComics permalinks. 2025-04-04 17:40:24 +00:00
Andrew Dolgov
7bb6ebb356 Merge branch 'bugfix/gocomics-changes' into 'master'
Handle changes to GoComics.

See merge request tt-rss/tt-rss!113
2025-04-04 17:35:51 +00:00
wn_
c4788023a4 Handle changes to GoComics. 2025-04-04 17:33:05 +00:00
Andrew Dolgov
8df250f2eb Merge branch 'feature/search-in-hash' into 'master'
Include the search query+language as part of the URL hash

See merge request tt-rss/tt-rss!112
2025-03-31 04:57:52 +00:00
Andrew Dolgov
90942d9ccf Merge branch 'better-filter-tests' into 'master'
filter test dialog improvements:

See merge request tt-rss/tt-rss!111
2025-03-31 04:25:19 +00:00
wn_
fdf9a08197 Bump ESLint ecmaVersion to 2020.
It wasn't aware of optional chaining.
2025-03-30 18:12:55 +00:00
wn_
ca751c10e1 Include the search query+language as part of the URL hash. 2025-03-30 18:05:53 +00:00
Andrew Dolgov
2d041f7d28 use server-side localized formatting for matching rule to display as a tooltip (for now) 2025-03-30 20:41:50 +03:00
Andrew Dolgov
b4962b670d stop sending matched content twice for the tooltip, use smaller objects containing only regular expression and rule type 2025-03-30 20:21:06 +03:00
wn_
10c488e1d0 Strip '[\r\n\t]' from entry content during filter test.
This is to help get the content and regex match strings a bit closer.
2025-03-29 10:36:08 +00:00
Andrew Dolgov
043162b0eb enforce maximum length on resulting rule regexp match, highlight based on strings with stripped tags 2025-03-28 18:36:11 +03:00
Andrew Dolgov
42ea2ebec0 * fix filter test not returning anything for filters set for specific
feed ID
 * show content preview after first matched rule, not article beginning
 * show meaningful preview for filters matching on article link, tags,
   and author
2025-03-28 16:51:15 +03:00
Andrew Dolgov
8986a3e7ee add limited highlighting of filter test results based on matched rules 2025-03-28 07:59:46 +03:00
Andrew Dolgov
49766ab01f filter test dialog improvements:
- properly return results for filter rules matching specific feeds or
   categories
 - fix test results never returned for Uncategorized
 - show tooltip with specific word match and matched rule on resulting
   item hover
2025-03-27 22:22:34 +03:00
Andrew Dolgov
c1e6a5ff63 enable ta translation 2025-03-26 09:40:54 +03:00
Andrew Dolgov
4b677f10e4 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Tamil)

See merge request tt-rss/tt-rss!110
2025-03-26 06:39:42 +00:00
Andrew Dolgov
d04fc6f26d add integration runner tag 2025-03-26 09:24:41 +03:00
Edgars Andersons
ee81566f5e Translated using Weblate (Latvian)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2025-03-21 17:11:23 +01:00
தமிழ்நேரம்
b8b475cdf6 Translated using Weblate (Tamil)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ta/
2025-03-20 20:24:42 +01:00
தமிழ்நேரம்
e01dc2e0d8 Translated using Weblate (Tamil)
Currently translated at 55.2% (384 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ta/
2025-03-19 18:49:07 +01:00
Andrew Dolgov
5dcb8db933 allow setting update interval in subcribe to feed dialog 2025-03-19 12:47:10 +03:00
Andrew Dolgov
5d69120056 Merge branch 'feature/search-to-sql-phrases' into 'master'
Support phrases in searches (PostgreSQL only)

See merge request tt-rss/tt-rss!109
2025-03-19 09:33:56 +00:00
wn_
434da183e7 Support using phrases in searches (PostgreSQL only). 2025-03-18 00:31:23 +00:00
Andrew Dolgov
6f9429f405 Merge branch 'feature/search-to-sql-tweaks' into 'master'
Some improvements in 'Feeds::_search_to_sql()'.

See merge request tt-rss/tt-rss!108
2025-03-17 17:28:44 +00:00
wn_
4053af899f Some improvements in 'Feeds::_search_to_sql()'.
* Pass in the profile so some preferences can be retrieved correctly.
* Consistently use the owner UID that gets passed in (previously some session var uses).
2025-03-15 22:37:17 +00:00
Andrew Dolgov
28cb97ddc5 Merge branch 'weblate-integration' into 'master'
Added translation using Weblate (Tamil)

See merge request tt-rss/tt-rss!107
2025-03-14 15:41:16 +00:00
Andrew Dolgov
1dc0c98c51 allow app passwords via auth_internal even if DISABLE_LOGIN_FORM is set 2025-03-14 11:57:48 +03:00
Andrew Dolgov
405cae963b Merge branch 'protected/DISABLE_LOGIN_FORM' into 'master'
add Config::DISABLE_LOGIN_FORM to allow limiting logins to SSO providers

See merge request tt-rss/tt-rss!106
2025-03-14 08:44:17 +00:00
Andrew Dolgov
d373c1f978 add Config::DISABLE_LOGIN_FORM to allow limiting logins to SSO providers 2025-03-14 11:43:25 +03:00
தமிழ்நேரம்
3563a517a6 Added translation using Weblate (Tamil) 2025-03-09 22:43:32 +01:00
Andrew Dolgov
1fc4eed6cd Merge branch 'feature/time-comparison-gen' into 'master'
Add and use 'Db::past_comparison_qpart()'.

See merge request tt-rss/tt-rss!105
2025-03-08 11:58:11 +00:00
Andrew Dolgov
9983954bf1 Merge branch 'bugfix/user-language' into 'master'
Get rid of the 'language' session variable.

See merge request tt-rss/tt-rss!104
2025-03-04 19:11:53 +00:00
wn_
89b0332d38 Add and use 'Db::now_comparison_qpart()'.
This introduces a helper to build a query part comparing a field against a past datetime (determined by '$now - $some_interval'), eliminating certain boilerplate code.
2025-03-04 18:34:35 +00:00
wn_
7e335de7b8 Get rid of the 'language' session variable.
It had issues (no profile usage, only set at login), so it's cleanest to just replace its one usage with 'Prefs::get()'.
2025-03-04 02:27:28 +00:00
Andrew Dolgov
532570ca17 Merge branch 'feature/favicon-mime-type-detection' into 'master'
Use the fileinfo module for favicon validation in 'RSSUtils::update_favicon()'.

See merge request tt-rss/tt-rss!103
2025-02-26 04:33:39 +00:00
wn_
f8198933b1 Use the fileinfo module for favicon validation in 'RSSUtils::update_favicon()'. 2025-02-25 20:23:17 +00:00
Andrew Dolgov
bfdfea88b9 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Czech)

See merge request tt-rss/tt-rss!102
2025-02-20 14:37:03 +00:00
Andrew Dolgov
85929b5d67 Merge branch 'feature/efficient-filter-testing' into 'master'
Only continue filter testing when there are likely more entries to check.

See merge request tt-rss/tt-rss!101
2025-02-20 13:13:51 +00:00
wn_
777c2b4c97 Move filter test results HTML building to the frontend. 2025-02-15 20:39:35 +00:00
wn_
e0d9ffcbc1 Only continue filter testing when there are likely more entries to check.
Prior to this, a filter test could needlessly result in up to 100 backend requests (limit 100, max_offset 10000) when the filter's associated feeds+categories have fewer than 10000 entries.
2025-02-15 16:51:25 +00:00
Patrik Coch
b85330096e Translated using Weblate (Czech)
Currently translated at 98.2% (683 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/cs/
2025-02-11 22:02:00 +01:00
Andrew Dolgov
169ff6de34 Merge branch 'feature/filter-test-cleanup' into 'master'
Clean up 'Pref_Filters::testFilterDo()'.

See merge request tt-rss/tt-rss!100
2025-02-08 17:19:20 +00:00
wn_
708563acd4 Indicate filters might have matched more than the test found.
Due to the limits set when testing a filter it's possible more results (some of which the user may be expecting to see) aren't displayed.
2025-02-08 16:55:39 +00:00
wn_
a34927d184 Clean up 'Pref_Filters::testFilterDo()'.
Use ORM, drop the '5 rule' limit, etc.
2025-02-08 16:53:12 +00:00
Andrew Dolgov
59b94a9e45 Merge branch 'feed-icons-misc' into 'master'
Feed icons cleanup

See merge request tt-rss/tt-rss!99
2025-01-27 05:39:13 +00:00
wn_
d361c1c65d Remove now-unused 'Config::ICONS_URL'. 2025-01-26 17:21:12 +00:00
wn_
7618101e33 Reduce use of legacy 'Config::ICONS_DIR'.
Also some minor cleanup in 'API#_get_config()'.
2025-01-26 17:19:12 +00:00
wn_
117d210c8a Include 'cache/feed-icons' in the default backup process.
This is worthwhile since custom icons might've been uploaded and post-restore icon fetches might take a while.
2025-01-26 16:56:34 +00:00
Andrew Dolgov
0eb4571c19 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Finnish)

See merge request tt-rss/tt-rss!98
2025-01-22 19:20:39 +00:00
Andrew Dolgov
f1b1320438 fix extra comma 2025-01-22 22:18:20 +03:00
Andrew Dolgov
f0687060d7 shorten_expanded: add simple event debounce 2025-01-22 22:14:33 +03:00
Ricky Tigg
ffe47821e0 Translated using Weblate (Finnish)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/fi/
2025-01-17 07:51:59 +01:00
Andrew Dolgov
a071edaa9d Merge branch 'feature/copy-filter' into 'master'
Add the ability to clone an existing filter.

See merge request tt-rss/tt-rss!97
2025-01-09 08:58:44 +00:00
wn_
5a93056c1c Fix setting a custom title when cloning a single filter. 2025-01-06 16:04:27 +00:00
wn_
e546e73914 Use 'clone' wording for filter duplication. 2025-01-06 15:49:40 +00:00
wn_
2eb3c150c2 Fix a return type warning in 'Pref_Filters'.
'Pref_Filters#get_details()' gets passed the ID of an existing filter, so we don't need to handle some edge case of it not existing.
2025-01-06 14:30:57 +00:00
wn_
0da9ef81bd Prompt for the new filter name when only copying one.
This also reworks 'Pref_Filters' a bit so it's easier to retrieve filter titles.
2025-01-06 14:17:38 +00:00
wn_
91496a0d24 Add the ability to copy an existing filter. 2025-01-06 00:33:18 +00:00
Andrew Dolgov
93e00d5aab Merge branch 'af-comics-dumbingofage' into 'master'
[af_comics] Add processing of dumbingofage.com images

See merge request tt-rss/tt-rss!96
2025-01-05 19:55:12 +00:00
Nathan Neulinger
ebe080dfe4 Add processing of dumbingofage.com images 2025-01-05 12:27:37 -06:00
Andrew Dolgov
d85cfb5c56 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Chinese (Simplified Han script))

See merge request tt-rss/tt-rss!95
2024-12-30 20:09:07 +00:00
Dario Di Ludovico
b076eb0005 Translated using Weblate (Italian)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/it/
2024-12-29 10:00:36 +00:00
Mikalai Daronin
83ae3ce619 Translated using Weblate (Belarusian)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/be/
2024-12-29 10:00:34 +00:00
qx100
eac64efe53 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/zh_Hans/
2024-12-27 09:00:40 +01:00
Andrew Dolgov
fc89d2e633 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Greek)

See merge request tt-rss/tt-rss!94
2024-12-22 16:39:29 +00:00
cuhsy
a147e36a47 Translated using Weblate (Greek)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/el/
2024-12-22 17:00:39 +01:00
Andrew Dolgov
7b72715678 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Spanish)

See merge request tt-rss/tt-rss!93
2024-12-20 05:58:29 +00:00
Patrick Ahles
9e12c9d8e9 Translated using Weblate (Dutch)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/nl/
2024-12-17 07:00:33 +00:00
Edgars Andersons
b8dc4794c6 Translated using Weblate (Latvian)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2024-12-17 07:00:31 +00:00
josé m
52b8e0f562 Translated using Weblate (Galician)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/gl/
2024-12-17 07:00:28 +00:00
gallegonovato
4fed050215 Translated using Weblate (Spanish)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/es/
2024-12-17 07:00:27 +00:00
Andrew Dolgov
472058e06d Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Polish)

See merge request tt-rss/tt-rss!92
2024-12-16 08:15:47 +00:00
Anarion Dunedain
b172ed039b Translated using Weblate (Polish)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/pl/
2024-12-16 07:38:51 +01:00
Andrew Dolgov
09f32efa3b Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Latvian)

See merge request tt-rss/tt-rss!91
2024-12-16 06:38:45 +00:00
Andrew Dolgov
eb47047351 maybe fix integration tests failing by always initializing apitest fields 2024-12-16 09:34:14 +03:00
Andrew Dolgov
e990a3c00f Merge branch 'feature/php-misc' into 'master'
More native typing, use some new PHP stuff

See merge request tt-rss/tt-rss!88
2024-12-16 06:29:28 +00:00
Edgars Andersons
912d1d6b1b Translated using Weblate (Latvian)
Currently translated at 79.1% (550 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2024-12-16 05:41:15 +01:00
Andrew Dolgov
d1c0ba5944 Merge branch 'weblate-integration' into 'master'
Update translation files

See merge request tt-rss/tt-rss!90
2024-12-16 04:41:09 +00:00
Andrew Dolgov
13804c4beb Merge branch 'feature/smart_date_time_never' into 'master'
Consistently "smart display" the Unix epoch as "Never", clean up calls to `TimeHelper::make_local_datetime()`

See merge request tt-rss/tt-rss!89
2024-12-16 04:39:54 +00:00
wn_
f9b2291c28 Don't bother passing unused arguments to 'TimeHelper::make_local_datetime()'.
There's no point in passing '$long' unless '$no_smart_dt' is set to 'true'.
2024-12-15 18:39:03 +00:00
wn_
119c7f13dc Consistently handle the 'smart' display of default/never dates. 2024-12-15 17:47:27 +00:00
wn_
6f8f1b30d5 minor PHPDoc cleanup in PluginHost 2024-12-15 16:41:45 +00:00
wn_
cfbbb9d714 Clean up some virtual feed stuff in PluginHost.
Among other things, this makes 'PluginHost->add_feed()' return false if the feed was not added.
2024-12-15 16:09:42 +00:00
Edgars Andersons
d47e8957de Translated using Weblate (Latvian)
Currently translated at 67.0% (466 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2024-12-15 17:01:08 +01:00
Dario Di Ludovico
662164334f Translated using Weblate (Italian)
Currently translated at 100.0% (695 of 695 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/it/
2024-12-15 17:01:06 +01:00
wn_
62a6191f04 Deal with FeedEnclosure property accessed before initialization. 2024-12-15 13:43:49 +00:00
wn_
18b17cbc83 Revert some stuff based upon feedback 2024-12-15 13:39:54 +00:00
wn_
57dd754e07 Use a native DNF type for 'PluginHost->get_feed_handler()'. 2024-12-14 12:26:59 +00:00
wn_
a1bd6cea1b Use native typing in more places and clean up 'FeedEnclosure' a bit. 2024-12-14 12:26:59 +00:00
wn_
333bab90a7 Remove use of 'ReturnTypeWillChange'.
'ReturnTypeWillChange' was a workaround needed until we reached PHP 8.0, which introduced union types and allowed alignment with 'SessionHandlerInterface'.
2024-12-14 12:26:59 +00:00
wn_
1742fb65c5 Use the spread operator instead of 'array_merge' in more places.
PHP 8.1 introduced support for merging string-key arrays (last array with a wins).
2024-12-14 12:26:59 +00:00
Hosted Weblate
688ab74eb6 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/
2024-12-14 10:18:06 +01:00
Andrew Dolgov
d5b1258d29 rebase translations 2024-12-14 12:17:51 +03:00
Andrew Dolgov
899d7d258c pref feed tree - switch to flexbox for row layout, remove floating param 2024-12-14 12:17:22 +03:00
Andrew Dolgov
e473d8ecc5 show amount of stored article in prefs feed tree 2024-12-14 11:37:58 +03:00
Andrew Dolgov
0caf502b79 fix correct font-family not applying for multiple select element 2024-12-13 08:04:29 +03:00
Andrew Dolgov
13f32bb62e Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Estonian)

See merge request tt-rss/tt-rss!87
2024-12-13 05:03:51 +00:00
Priit Jõerüüt
e858fc45fd Translated using Weblate (Estonian)
Currently translated at 0.2% (2 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/et/
2024-12-12 13:00:32 +00:00
Andrew Dolgov
0ef2dd7175 Merge branch 'feature/alpine_3.21-and-php_8.4' into 'master'
Bump to Alpine 3.21 and PHP 8.4, raise the minimum to PHP 8.2

See merge request tt-rss/tt-rss!85
2024-12-10 05:22:37 +00:00
wn_
3860435cba Add the 'ca-certificates' package to provide 'update-ca-certificates'.
By default only the 'ca-certificates-bundle' package is installed.

* https://pkgs.alpinelinux.org/contents?file=&path=&name=ca-certificates&branch=v3.21&repo=main&arch=x86_64
* https://pkgs.alpinelinux.org/contents?file=&path=&name=ca-certificates-bundle&branch=v3.21&repo=main&arch=x86_64
2024-12-09 19:29:22 +00:00
wn_
65bb110770 minor package installation formatting tweak 2024-12-09 19:21:56 +00:00
wn_
133e8ea2a4 Centralize the PHP version suffix used for packages and paths. 2024-12-09 18:54:02 +00:00
wn_
f6a8facfd4 Bump 'spomky-labs/otphp' to 11.3.x.
This is mainly for PHP 8.4 compatibility.
2024-12-09 17:58:28 +00:00
wn_
cd2c10f9f7 Bump the minimum required PHP version to 8.2.0.
Discussion: https://gitlab.tt-rss.org/tt-rss/tt-rss/-/merge_requests/85
2024-12-09 17:53:23 +00:00
Andrew Dolgov
f15db7b961 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Spanish)

See merge request tt-rss/tt-rss!86
2024-12-09 14:19:46 +00:00
Ricky Tigg
50259a1bc1 Translated using Weblate (Finnish)
Currently translated at 100.0% (694 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/fi/
2024-12-06 10:00:33 +01:00
wn_
096f803f2f Bump to Alpine 3.21 and PHP 8.4.
* https://alpinelinux.org/posts/Alpine-3.21.0-released.html
* https://www.php.net/releases/8.4/en.php
2024-12-05 15:59:30 +00:00
Anarion Dunedain
791c9f287c Translated using Weblate (Polish)
Currently translated at 100.0% (694 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/pl/
2024-12-03 11:00:30 +01:00
Edgars Andersons
efb16289c6 Translated using Weblate (Latvian)
Currently translated at 58.2% (404 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/lv/
2024-11-30 23:00:20 +01:00
Patrick Ahles
30ab6a6046 Translated using Weblate (Dutch)
Currently translated at 100.0% (694 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/nl/
2024-11-29 22:00:25 +01:00
josé m
d2f4e123be Translated using Weblate (Galician)
Currently translated at 100.0% (694 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/gl/
2024-11-29 22:00:24 +01:00
gallegonovato
9340f3fd88 Translated using Weblate (Spanish)
Currently translated at 100.0% (694 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/es/
2024-11-29 22:00:23 +01:00
Andrew Dolgov
4dfa7a666a Merge branch 'weblate-integration' into 'master'
Update translation files

See merge request tt-rss/tt-rss!84
2024-11-28 16:03:16 +00:00
Hosted Weblate
04c0f2a9b9 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/
2024-11-28 06:15:51 +01:00
Andrew Dolgov
ca61521f42 rebase translations 2024-11-28 08:15:31 +03:00
Andrew Dolgov
3dd71e41a1 also deal with The Oatmeal posts, not just comics 2024-11-26 20:15:34 +03:00
Andrew Dolgov
b045da0e5e add af_comics filter for The Oatmeal 2024-11-26 20:08:44 +03:00
Andrew Dolgov
8c42b3a3bf Merge branch 'bugfix/feedparser-rdf-type' into 'master'
Fix RDF feed support in FeedParser.

See merge request tt-rss/tt-rss!83
2024-11-26 16:51:43 +00:00
wn_
b5777b5a7c Add+use FeedParser::FEED_UNKNOWN 2024-11-26 16:50:51 +00:00
wn_
d85bdcfd78 Fix RDF feed support in FeedParser. 2024-11-26 16:45:03 +00:00
Andrew Dolgov
aaeabbc961 API: allow catchup for search results (bumps api level to 22) 2024-11-25 19:47:28 +03:00
Andrew Dolgov
103bafd90a Merge branch 'feature/php8-str-funcs' into 'master'
Use PHP 8 'str_' functions.

See merge request tt-rss/tt-rss!81
2024-11-24 14:06:57 +00:00
wn_
667528d5b9 Use PHP 8 'str_' functions.
A few more characters in some places, but helps with readability.
2024-11-24 13:59:29 +00:00
Andrew Dolgov
53fee911e6 Merge branch 'feature/php8-match' into 'master'
Use match expressions in some places.

See merge request tt-rss/tt-rss!82
2024-11-24 13:55:00 +00:00
wn_
9b0baf9b32 Use match expressions in some places. 2024-11-24 13:45:26 +00:00
Andrew Dolgov
43e8864ead allow nullable password in auto_create_user for backwards compatibility 2024-11-24 12:55:59 +03:00
Andrew Dolgov
b089d67e26 Merge branch 'getfiltertree-eldritch-horrors' into 'master'
getfiltertree: switch to ORM and simplify code

See merge request tt-rss/tt-rss!79
2024-11-24 06:37:31 +00:00
Andrew Dolgov
7892b41234 Merge branch 'feature/php8-union-types' into 'master'
Use native union types in most places.

See merge request tt-rss/tt-rss!80
2024-11-24 06:36:58 +00:00
Andrew Dolgov
aad69b7ca6 make filter search properly case-insensitive 2024-11-24 09:24:20 +03:00
wn_
abcd0e8ba2 Use native union types in most places. 2024-11-23 17:43:24 +00:00
Andrew Dolgov
1ce1aee40f simplify resulting tree root object 2024-11-23 18:15:18 +03:00
Andrew Dolgov
6bc9097b0f getfiltertree: switch to ORM and simplify code 2024-11-23 18:13:09 +03:00
Andrew Dolgov
d4636716fb collapse actions summary list in filter tree if 'toggle rule display' is disabled, remove label-specific icon display, simplify markup 2024-11-23 17:43:20 +03:00
Andrew Dolgov
81ccfed4b4 Merge branch 'filter-fixes' into 'master'
get filter action descriptions on pref_filters class init instead of doing it...

See merge request tt-rss/tt-rss!78
2024-11-23 14:10:56 +00:00
Andrew Dolgov
4dc0e8cd29 fix text-muted being set to default text foreground color in light mode, adjust styling of filter actions list 2024-11-23 17:08:33 +03:00
Andrew Dolgov
417065b8f5 show cumulative score adjustment (if any) and up to 3 actions total in filter tree 2024-11-23 16:54:22 +03:00
Andrew Dolgov
c2d0c5a5c9 update usage of deprecated function 2024-11-23 15:59:53 +03:00
Andrew Dolgov
648024eb2e bump minimum required php version to 8.0 & remove some deprecated code 2024-11-23 15:54:40 +03:00
Andrew Dolgov
7be6484fee use select_many() without _expr 2024-11-23 15:49:50 +03:00
Andrew Dolgov
1fc19d6e50 adjust indent 2024-11-23 11:54:44 +03:00
Andrew Dolgov
64d9a77fde switch filters _get_rules_list() to ORM 2024-11-23 11:54:24 +03:00
Andrew Dolgov
c23b76eb72 pass resulting action description through gettext 2024-11-23 11:21:18 +03:00
Andrew Dolgov
ce5a96cb30 set type hint for $action_descriptions 2024-11-23 11:14:39 +03:00
Andrew Dolgov
bbc28e626a fix _get_name failing on filters without any rules because of wrong type of JOIN 2024-11-23 11:11:19 +03:00
Andrew Dolgov
31ca090c63 get filter action descriptions on pref_filters class init instead of doing it all the time, use ORM in _get_action_name 2024-11-23 11:07:50 +03:00
Andrew Dolgov
f28df34fec fix action params not hiding in edit action dialog if param-less action was initially selected 2024-11-23 10:47:41 +03:00
Andrew Dolgov
987936f57a pref_filters - refactor _get_name to use ORM, show cumulative score in tree filter description 2024-11-23 10:26:12 +03:00
Andrew Dolgov
9c1fb45d73 Merge branch 'feature/php-qrcode-5.0.x' into 'master'
Bump 'chillerlan/php-qrcode' to 5.0.x.

See merge request tt-rss/tt-rss!77
2024-11-21 17:36:54 +00:00
wn_
64a36970d6 Bump 'chillerlan/php-qrcode' to 5.0.x.
* Maintains PHP `7.4` compatibility and adds PHP `8.4` compatibility
  * The `4.4.x` branch does the same, but I didn't see any reason not to go to `5.0.x`.
* https://github.com/chillerlan/php-qrcode/releases
2024-11-21 17:34:32 +00:00
Andrew Dolgov
1e14fc0fd9 Merge branch 'wn-master-patch-27216' into 'master'
Fix array key warning in 'Feeds::_get_headlines()'.

See merge request tt-rss/tt-rss!76
2024-11-20 01:50:14 +00:00
wn
486c92240a Fix array key warning in 'Feeds::_get_headlines()'. 2024-11-19 23:13:10 +00:00
Andrew Dolgov
4bdd926a1c Merge branch 'feature/replace-get_pref-and-set_pref' into 'master'
Eliminate use of deprecated 'get_pref()' and 'set_pref()'.

See merge request tt-rss/tt-rss!75
2024-11-19 16:46:48 +00:00
wn_
154abc61a0 Eliminate use of deprecated 'get_pref()' and 'set_pref()'. 2024-11-18 21:59:45 +00:00
Andrew Dolgov
394d606fe9 Merge branch 'feature/phpstan-2.0.x' into 'master'
PHPStan 2.0.x

See merge request tt-rss/tt-rss!74
2024-11-13 18:38:32 +00:00
wn_
859ce4d7f6 Deal with an error showing up in Gitlab PHPStan runs 2024-11-13 02:11:58 +00:00
wn_
2dda9f9ab5 Minor @var cleanup in Counters 2024-11-12 04:14:51 +00:00
wn_
76b9cd8274 Make the 'requireOnce.fileNotFound' PHPStan error ignore more specific. 2024-11-12 03:59:52 +00:00
wn_
5a200755b8 Move 'IVirtualFeed' checks into 'PluginHost::get_feed_handler()'. 2024-11-12 03:49:58 +00:00
wn_
dca2ae60a1 Remove some PHPStan ignores and make others rule-specific. 2024-11-12 03:38:45 +00:00
wn_
a784305cc7 Address PHPStan findings as of 2.0.1 2024-11-12 03:15:53 +00:00
wn_
e4c57769e0 Upgrade PHPStan to 2.0.1 2024-11-12 01:26:43 +00:00
Andrew Dolgov
6273e26ea4 Merge branch 'feature/search-improvements' into 'master'
Search improvements

See merge request tt-rss/tt-rss!72
2024-11-11 04:36:47 +00:00
Andrew Dolgov
42ebdb027e fix get_self_url() misbehaving in plugins/ 2024-11-04 13:59:02 +03:00
Andrew Dolgov
d8718b7574 Merge branch 'master' of gitlab.tt-rss.org:tt-rss/tt-rss 2024-11-01 11:02:09 +03:00
Andrew Dolgov
b12b2afcc1 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Arabic (Saudi Arabia))

See merge request tt-rss/tt-rss!73
2024-11-01 08:01:47 +00:00
Andrew Dolgov
eb841b761c af_comics - fix for Danby Draws and maybe other similar wodpress-based sites 2024-11-01 10:59:57 +03:00
Younes
710e42cfd8 Translated using Weblate (Arabic (Saudi Arabia))
Currently translated at 59.2% (411 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/ar_SA/
2024-10-24 14:15:51 +00:00
Andrew Dolgov
68da94cc36 move sql patch file stuff after all initialization tasks 2024-10-24 11:35:05 +03:00
Andrew Dolgov
dba6a39d2a drop RESTORE_SCHEMA, add simple mechanism to apply SQL patch files after migrations 2024-10-24 11:08:20 +03:00
Andrew Dolgov
9f0eb4d7fc support TTRSS_DB_PORT in container startup scripts 2024-10-21 08:59:55 +03:00
wn_
842e9af4cf Feeds::_search_to_sql(): updates for clarity and SQL quoting. 2024-10-20 17:57:32 +00:00
wn_
142ca20cb0 Fix keyword searches with a quoted string value.
Before this change curly braces wrapped the keyword and its value, making the pair get treated as leftover words.

Also make the search query modification and CSV parsing a bit clearer with some comments and minor refactoring.
2024-10-20 16:08:20 +00:00
Andrew Dolgov
5ea96397c0 properly support search queries in viewfeed debugger, improve some debugging messages and output 2024-10-15 19:58:52 +03:00
Andrew Dolgov
a58a0cd888 launch gulp from local node_modules 2024-10-15 19:36:26 +03:00
Andrew Dolgov
468f464b48 Merge branch 'feature/guzzle-7.9.2' into 'master'
Update Guzzle to 7.9.2

See merge request tt-rss/tt-rss!70
2024-10-10 16:01:54 +00:00
Andrew Dolgov
7fafad2ac2 Merge branch 'weblate-integration' into 'master'
Added translation using Weblate (Estonian)

See merge request tt-rss/tt-rss!71
2024-10-10 15:57:04 +00:00
wn_
124c4e2542 Update Guzzle to 7.9.2
https://github.com/guzzle/guzzle/releases
2024-10-07 20:22:01 +00:00
Priit Jõerüüt
946337fd5d Added translation using Weblate (Estonian) 2024-10-04 23:46:37 +02:00
Andrew Dolgov
df489df309 Merge branch 'drop-opentelemetry' into 'master'
drop php-http/guzzle7-adapter

See merge request tt-rss/tt-rss!69
2024-10-01 18:50:08 +00:00
Andrew Dolgov
2ea888fdc6 drop php-http/guzzle7-adapter 2024-10-01 18:10:28 +03:00
Andrew Dolgov
df33ddaea1 Merge branch 'drop-opentelemetry' into 'master'
drop opentelemetry

See merge request tt-rss/tt-rss!68
2024-10-01 14:54:29 +00:00
Andrew Dolgov
7e0f5f295c drop OPENTELEMETRY_ global config entries 2024-10-01 17:22:37 +03:00
Andrew Dolgov
e1bac5d855 composer dump-autoload --optimize 2024-10-01 17:21:28 +03:00
Andrew Dolgov
884fd92f13 drop opentelemetry 2024-10-01 16:00:34 +03:00
Andrew Dolgov
8fcc68baf5 Merge branch 'master' of gitlab.tt-rss.org:tt-rss/tt-rss 2024-09-25 12:33:13 +03:00
Andrew Dolgov
b3489fa2a7 Merge branch 'weblate-integration' into 'master'
Translated using Weblate (Spanish)

See merge request tt-rss/tt-rss!67
2024-09-25 09:32:58 +00:00
Andrew Dolgov
4ec871eee4 disable absolute redirects & redirect port in phpdoc image 2024-09-25 12:31:14 +03:00
gallegonovato
e11bc1cd1c Translated using Weblate (Spanish)
Currently translated at 100.0% (694 of 694 strings)

Translation: TinyTinyRSS/webui
Translate-URL: https://hosted.weblate.org/projects/tt-rss/webui/es/
2024-09-23 13:15:37 +02:00
1582 changed files with 94337 additions and 209993 deletions

View File

@@ -1,6 +1,6 @@
ARG PROXY_REGISTRY
FROM ${PROXY_REGISTRY}alpine:3.20
FROM ${PROXY_REGISTRY}alpine:3.22
EXPOSE 9000/tcp
ARG ALPINE_MIRROR
@@ -8,15 +8,22 @@ ARG ALPINE_MIRROR
ENV SCRIPT_ROOT=/opt/tt-rss
ENV SRC_DIR=/src/tt-rss/
# Normally there's no need to change this, should point to a volume shared with nginx container
ENV APP_INSTALL_BASE_DIR=/var/www/html
# Used to centralize the PHP version suffix for packages and paths
ENV PHP_SUFFIX=84
RUN [ ! -z ${ALPINE_MIRROR} ] && \
sed -i.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
apk add --no-cache dcron php83 php83-fpm php83-phar php83-sockets php83-pecl-apcu \
php83-pdo php83-gd php83-pgsql php83-pdo_pgsql php83-xmlwriter php83-opcache \
php83-mbstring php83-intl php83-xml php83-curl php83-simplexml \
php83-session php83-tokenizer php83-dom php83-fileinfo php83-ctype \
php83-json php83-iconv php83-pcntl php83-posix php83-zip php83-exif \
php83-openssl git postgresql-client sudo php83-pecl-xdebug rsync tzdata && \
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php83/php.ini && \
sed -i.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
apk add --no-cache ca-certificates dcron git postgresql-client rsync sudo tzdata \
php${PHP_SUFFIX} \
$(for p in ctype curl dom exif fileinfo fpm gd iconv intl json mbstring opcache \
openssl pcntl pdo pdo_pgsql pecl-apcu pecl-xdebug phar posix session simplexml sockets sodium tokenizer xml xmlwriter zip; do \
php_pkgs="$php_pkgs php${PHP_SUFFIX}-$p"; \
done; \
echo $php_pkgs) && \
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php${PHP_SUFFIX}/php.ini && \
sed -i -e 's/^listen = 127.0.0.1:9000/listen = 9000/' \
-e 's/;\(clear_env\) = .*/\1 = no/i' \
-e 's/;\(pm.status_path = \/status\)/\1/i' \
@@ -24,8 +31,8 @@ RUN [ ! -z ${ALPINE_MIRROR} ] && \
-e 's/^\(user\|group\) = .*/\1 = app/i' \
-e 's/;\(php_admin_value\[error_log\]\) = .*/\1 = \/tmp\/error.log/' \
-e 's/;\(php_admin_flag\[log_errors\]\) = .*/\1 = on/' \
/etc/php83/php-fpm.d/www.conf && \
mkdir -p /var/www ${SCRIPT_ROOT}/config.d
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf && \
mkdir -p /var/www ${SCRIPT_ROOT}/config.d ${SCRIPT_ROOT}/sql/post-init.d
ARG CI_COMMIT_BRANCH
ENV CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH}
@@ -40,6 +47,7 @@ ARG CI_COMMIT_SHA
ENV CI_COMMIT_SHA=${CI_COMMIT_SHA}
ADD .docker/app/startup.sh ${SCRIPT_ROOT}
ADD .docker/app/update.sh ${SCRIPT_ROOT}
ADD .docker/app/updater.sh ${SCRIPT_ROOT}
ADD .docker/app/dcron.sh ${SCRIPT_ROOT}
ADD .docker/app/backup.sh /etc/periodic/weekly/backup
@@ -51,7 +59,7 @@ ADD .docker/app/config.docker.php ${SCRIPT_ROOT}
COPY . ${SRC_DIR}
ARG ORIGIN_REPO_XACCEL=https://git.tt-rss.org/fox/ttrss-nginx-xaccel.git
ARG ORIGIN_REPO_XACCEL=https://github.com/tt-rss/tt-rss-plugin-nginx-xaccel.git
RUN git clone --depth=1 ${ORIGIN_REPO_XACCEL} ${SRC_DIR}/plugins.local/nginx_xaccel
@@ -87,12 +95,10 @@ ENV TTRSS_XDEBUG_ENABLED=""
ENV TTRSS_XDEBUG_HOST=""
ENV TTRSS_XDEBUG_PORT="9000"
ENV TTRSS_DB_TYPE="pgsql"
ENV TTRSS_DB_HOST="db"
ENV TTRSS_DB_PORT="5432"
ENV TTRSS_MYSQL_CHARSET="UTF8"
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php83"
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php${PHP_SUFFIX}"
ENV TTRSS_PLUGINS="auth_internal, note, nginx_xaccel"
CMD ${SCRIPT_ROOT}/startup.sh

View File

@@ -2,14 +2,14 @@
DST_DIR=/backups
KEEP_DAYS=28
APP_ROOT=/var/www/html/tt-rss
APP_ROOT=$APP_INSTALL_BASE_DIR/tt-rss
if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; then
if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; then
DST_FILE=ttrss-backup-$(date +%Y%m%d).sql.gz
echo backing up tt-rss database to $DST_DIR/$DST_FILE...
export PGPASSWORD=$TTRSS_DB_PASS
export PGPASSWORD=$TTRSS_DB_PASS
pg_dump --clean -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME | gzip > $DST_DIR/$DST_FILE
@@ -18,7 +18,7 @@ if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; then
echo backing up tt-rss local directories to $DST_DIR/$DST_FILE...
tar -cz -f $DST_DIR/$DST_FILE $APP_ROOT/*.local \
$APP_ROOT/feed-icons/ \
$APP_ROOT/cache/feed-icons/ \
$APP_ROOT/config.php
echo cleaning up...

View File

@@ -1,6 +1,10 @@
#!/bin/sh -e
#
# this script initializes the working copy on a persistent volume and starts PHP FPM
#
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
# TODO this should do a reasonable amount of attempts and terminate with an error
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; do
echo waiting until $TTRSS_DB_HOST is ready...
sleep 3
done
@@ -11,18 +15,18 @@ unset HTTP_HOST
if ! id app >/dev/null 2>&1; then
addgroup -g $OWNER_GID app
adduser -D -h /var/www/html -G app -u $OWNER_UID app
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
fi
update-ca-certificates || true
DST_DIR=/var/www/html/tt-rss
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
[ -e $DST_DIR ] && rm -f $DST_DIR/.app_is_ready
export PGPASSWORD=$TTRSS_DB_PASS
[ ! -e /var/www/html/index.php ] && cp ${SCRIPT_ROOT}/index.php /var/www/html
[ ! -e $APP_INSTALL_BASE_DIR/index.php ] && cp ${SCRIPT_ROOT}/index.php $APP_INSTALL_BASE_DIR
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
if [ ! -d $DST_DIR ]; then
@@ -56,16 +60,21 @@ for d in cache lock feed-icons plugins.local themes.local templates.local cache/
sudo -u app mkdir -p $DST_DIR/$d
done
# this is some next level bullshit
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
chown -R app:app /root # /.postgresql
for d in cache lock feed-icons; do
chmod 777 $DST_DIR/$d
find $DST_DIR/$d -type f -exec chmod 666 {} \;
chown -R app:app $DST_DIR/$d
chmod -R u=rwX,g=rX,o=rX $DST_DIR/$d
done
sudo -u app cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
chmod 644 $DST_DIR/config.php
chown -R $OWNER_UID:$OWNER_GID $DST_DIR \
/var/log/php83
/var/log/php${PHP_SUFFIX}
if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
echo updating all local plugins...
@@ -77,24 +86,17 @@ if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
cd $PLUGIN && \
sudo -u app git config core.filemode false && \
sudo -u app git config pull.rebase false && \
sudo -u app git pull origin master || echo warning: attempt to update plugin $PLUGIN failed.
sudo -u app git pull origin main || sudo -u app git pull origin master || echo warning: attempt to update plugin $PLUGIN failed.
fi
done
else
echo skipping local plugin updates, disabled.
fi
PSQL="psql -q -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME"
PSQL="psql -q -h $TTRSS_DB_HOST -p $TTRSS_DB_PORT -U $TTRSS_DB_USER $TTRSS_DB_NAME"
$PSQL -c "create extension if not exists pg_trgm"
RESTORE_SCHEMA=${SCRIPT_ROOT}/restore-schema.sql.gz
if [ -r $RESTORE_SCHEMA ]; then
$PSQL -c "drop schema public cascade; create schema public;"
zcat $RESTORE_SCHEMA | $PSQL
fi
# this was previously generated
rm -f $DST_DIR/config.php.bak
@@ -104,7 +106,7 @@ if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
fi
echo enabling xdebug with the following parameters:
env | grep TTRSS_XDEBUG
cat > /etc/php83/conf.d/50_xdebug.ini <<EOF
cat > /etc/php${PHP_SUFFIX}/conf.d/50_xdebug.ini <<EOF
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request = yes
@@ -114,17 +116,17 @@ EOF
fi
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php83/php.ini
/etc/php${PHP_SUFFIX}/php.ini
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
/etc/php83/php-fpm.d/www.conf
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf
sudo -Eu app php83 $DST_DIR/update.php --update-schema=force-yes
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --update-schema=force-yes
if [ ! -z "$ADMIN_USER_PASS" ]; then
sudo -Eu app php83 $DST_DIR/update.php --user-set-password "admin:$ADMIN_USER_PASS"
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-password "admin:$ADMIN_USER_PASS"
else
if sudo -Eu app php83 $DST_DIR/update.php --user-check-password "admin:password"; then
if sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-check-password "admin:password"; then
RANDOM_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16 ; echo '')
echo "*****************************************************************************"
@@ -132,21 +134,21 @@ else
echo "* If you want to set it manually, use ADMIN_USER_PASS environment variable. *"
echo "*****************************************************************************"
sudo -Eu app php83 $DST_DIR/update.php --user-set-password "admin:$RANDOM_PASS"
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-password "admin:$RANDOM_PASS"
fi
fi
if [ ! -z "$ADMIN_USER_ACCESS_LEVEL" ]; then
sudo -Eu app php83 $DST_DIR/update.php --user-set-access-level "admin:$ADMIN_USER_ACCESS_LEVEL"
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-access-level "admin:$ADMIN_USER_ACCESS_LEVEL"
fi
if [ ! -z "$AUTO_CREATE_USER" ]; then
sudo -Eu app /bin/sh -c "php83 $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
php83 $DST_DIR/update.php --force-yes --user-add \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_PASS:$AUTO_CREATE_USER_ACCESS_LEVEL\""
sudo -Eu app /bin/sh -c "php${PHP_SUFFIX} $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
php${PHP_SUFFIX} $DST_DIR/update.php --force-yes --user-add \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_PASS:$AUTO_CREATE_USER_ACCESS_LEVEL\""
if [ ! -z "$AUTO_CREATE_USER_ENABLE_API" ]; then
# TODO: remove || true later
sudo -Eu app /bin/sh -c "php83 $DST_DIR/update.php --user-enable-api \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_ENABLE_API\"" || true
sudo -Eu app /bin/sh -c "php${PHP_SUFFIX} $DST_DIR/update.php --user-enable-api \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_ENABLE_API\"" || true
fi
fi
@@ -158,6 +160,11 @@ rm -f /tmp/error.log && mkfifo /tmp/error.log && chown app:app /tmp/error.log
unset ADMIN_USER_PASS
unset AUTO_CREATE_USER_PASS
find ${SCRIPT_ROOT}/sql/post-init.d/ -type f -name '*.sql' | while read F; do
echo applying SQL patch file: $F
$PSQL -f $F
done
touch $DST_DIR/.app_is_ready
exec /usr/sbin/php-fpm83 --nodaemonize --force-stderr
exec /usr/sbin/php-fpm${PHP_SUFFIX} --nodaemonize --force-stderr

86
.docker/app/update.sh Normal file
View File

@@ -0,0 +1,86 @@
#!/bin/sh -e
#
# this script kickstarts a minimal working environment and runs update.php, could be used as an entrypoint for a cronjob
# which doesn't share a volume with FPM/updater
#
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
unset HTTP_PORT
unset HTTP_HOST
if ! id app >/dev/null 2>&1; then
addgroup -g $OWNER_GID app
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
fi
update-ca-certificates || true
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
if [ ! -d $DST_DIR ]; then
mkdir -p $DST_DIR
chown $OWNER_UID:$OWNER_GID $DST_DIR
sudo -u app rsync -a --no-owner \
$SRC_DIR/ $DST_DIR/
else
chown -R $OWNER_UID:$OWNER_GID $DST_DIR
sudo -u app rsync -a --no-owner --delete \
--exclude /cache \
--exclude /lock \
--exclude /feed-icons \
--exclude /plugins/af_comics/filters.local \
--exclude /plugins.local \
--exclude /templates.local \
--exclude /themes.local \
$SRC_DIR/ $DST_DIR/
sudo -u app rsync -a --no-owner --delete \
$SRC_DIR/plugins.local/nginx_xaccel \
$DST_DIR/plugins.local/nginx_xaccel
fi
else
echo "warning: working copy in $DST_DIR won't be updated, make sure you know what you're doing."
fi
for d in cache lock feed-icons plugins.local themes.local templates.local cache/export cache/feeds cache/images cache/upload; do
sudo -u app mkdir -p $DST_DIR/$d
done
# this is some next level bullshit
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
chown -R app:app /root # /.postgresql
for d in cache lock feed-icons; do
chown -R app:app $DST_DIR/$d
chmod -R u=rwX,g=rX,o=rX $DST_DIR/$d
done
sudo -u app cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
chmod 644 $DST_DIR/config.php
if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
if [ -z "${TTRSS_XDEBUG_HOST}" ]; then
export TTRSS_XDEBUG_HOST=$(ip ro sh 0/0 | cut -d " " -f 3)
fi
echo enabling xdebug with the following parameters:
env | grep TTRSS_XDEBUG
cat > /etc/php${PHP_SUFFIX}/conf.d/50_xdebug.ini <<EOF
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request = yes
xdebug.client_port = ${TTRSS_XDEBUG_PORT}
xdebug.client_host = ${TTRSS_XDEBUG_HOST}
EOF
fi
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php${PHP_SUFFIX}/php.ini
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php "$@"

View File

@@ -1,4 +1,7 @@
#!/bin/sh -e
#
# this scripts waits for startup.sh to finish (implying a shared volume) and runs multiprocess daemon when working copy is available
#
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
unset HTTP_PORT
@@ -12,22 +15,30 @@ sleep 30
if ! id app; then
addgroup -g $OWNER_GID app
adduser -D -h /var/www/html -G app -u $OWNER_UID app
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
fi
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
update-ca-certificates || true
# TODO this should do a reasonable amount of attempts and terminate with an error
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; do
echo waiting until $TTRSS_DB_HOST is ready...
sleep 3
done
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php83/php.ini
/etc/php${PHP_SUFFIX}/php.ini
DST_DIR=/var/www/html/tt-rss
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
while [ ! -s $DST_DIR/config.php -a -e $DST_DIR/.app_is_ready ]; do
echo waiting for app container...
sleep 3
done
sudo -E -u app /usr/bin/php83 /var/www/html/tt-rss/update_daemon2.php "$@"
# this is some next level bullshit
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
chown -R app:app /root # /.postgresql
sudo -E -u app "${TTRSS_PHP_EXECUTABLE}" $APP_INSTALL_BASE_DIR/tt-rss/update_daemon2.php "$@"

View File

@@ -2,3 +2,4 @@ ARG PROXY_REGISTRY
FROM ${PROXY_REGISTRY}nginxinc/nginx-unprivileged:1-alpine
COPY ./phpdoc /usr/share/nginx/html/ttrss-docs
COPY .docker/phpdoc/redirects.conf /etc/nginx/conf.d/

View File

@@ -0,0 +1,2 @@
port_in_redirect off;
absolute_redirect off;

View File

@@ -8,6 +8,7 @@ COPY .docker/web-nginx/nginx.conf /etc/nginx/templates/nginx.conf.template
# By default, nginx will send the php requests to "app" server, but this server
# name can be overridden at runtime by passing an APP_UPSTREAM env var
ENV APP_UPSTREAM=${APP_UPSTREAM:-app}
ENV APP_FASTCGI_PASS="${APP_FASTCGI_PASS:-\$backend}"
# Webroot (defaults to /var/www/html)
ENV APP_WEB_ROOT=${APP_WEB_ROOT:-/var/www/html}

View File

@@ -47,7 +47,7 @@ http {
set $backend "${APP_UPSTREAM}:9000";
fastcgi_pass $backend;
fastcgi_pass ${APP_FASTCGI_PASS};
}
# Allow PATH_INFO for PHP files in plugins.local directories with an /api/ sub directory to allow plugins to leverage when desired
@@ -68,9 +68,9 @@ http {
set $backend "${APP_UPSTREAM}:9000";
fastcgi_pass $backend;
fastcgi_pass ${APP_FASTCGI_PASS};
}
location / {
try_files $uri $uri/ =404;
}

View File

@@ -7,7 +7,7 @@ module.exports = {
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2018
"ecmaVersion": 2020
},
"rules": {
"accessor-pairs": "error",

54
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Bug Report
description: Report a bug
labels: [bug]
body:
- type: textarea
id: problem
attributes:
label: What is the problem?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction Steps
description: (If known) Minimal steps that can reproduce the issue.
validations:
required: false
- type: input
id: version
attributes:
label: tt-rss Version
description: Which version (commit) of tt-rss are you using?
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: |
How are you running tt-rss? Include all relevant details (e.g. Docker image, OS, PHP version, etc.).
validations:
required: true
- type: textarea
id: other
attributes:
label: Other Information
description: |
Anything else you want to provide (e.g. related issues, suggestions on how to fix, links for context, etc.).
validations:
required: false
- type: checkboxes
id: acknowledgments
attributes:
label: Acknowledge
description: Please mark any checkbox that applies (otherwise leave unchecked).
options:
- label: I'm interested in implementing the fix
required: false

View File

@@ -0,0 +1,20 @@
name: Documentation Issue
description: Report an issue with documentation
labels: [documentation]
body:
- type: textarea
id: issue
attributes:
label: What is the issue?
validations:
required: true
- type: checkboxes
id: acknowledgments
attributes:
label: Acknowledge
description: Please mark any checkbox that apply (otherwise leave unchecked).
options:
- label: I'm interested in implementing the fix
required: false

57
.github/ISSUE_TEMPLATE/enhancement.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Enhancement Request
description: Request a new enhancement/feature
labels: [enhancement]
body:
- type: textarea
id: description
attributes:
label: Description
description: Short description of the enhancement/feature you are proposing.
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use Case
description: Why do you need this enhancement/feature?
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: |
(If known) How would you propose the enhancement/feature be implemented?
validations:
required: false
- type: input
id: version
attributes:
label: tt-rss Version
description: Which version (commit) of tt-rss are you currently using?
validations:
required: true
- type: textarea
id: other
attributes:
label: Other Information
description: |
Anything else you want to provide (e.g. related issues, suggestions on how to implement, links for context, etc.).
validations:
required: false
- type: checkboxes
id: acknowledgments
attributes:
label: Acknowledge
description: Please mark any checkbox that apply (otherwise leave unchecked).
options:
- label: I'm interested in implementing the enhancement/feature
required: false
- label: This enhancement/feature might result in a breaking change
required: false

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: composer
directory: /
schedule:
interval: weekly

25
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,25 @@
## Description
<!-- Describe your changes in detail -->
## Motivation and Context
<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->
## How Has This Been Tested?
<!-- Please describe in detail how you tested your changes. -->
## Screenshots (if appropriate)
<!-- If screenshots will help in understanding the change, include them here. -->
## Types of Changes
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Refactoring (non-breaking change)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.

67
.github/workflows/php-code-quality.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: PHP Code Quality
on:
pull_request:
paths:
- '**.php'
- 'phpstan.neon'
- 'phpunit.xml'
# Allow manual triggering
workflow_dispatch:
# Allow other workflows (e.g. Publish) to invoke this one.
workflow_call:
env:
fail-fast: true
permissions:
contents: read
jobs:
phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
tools: none
- name: Run PHPStan
run: vendor/bin/phpstan analyze --no-progress
phpunit:
name: PHPUnit
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
experimental: [false]
include:
- php: '8.5'
experimental: true
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Set up PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: none
- name: Run PHPUnit
run: vendor/bin/phpunit --exclude integration --coverage-filter classes --coverage-filter include

93
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: Publish
on:
push:
branches: [main]
paths-ignore:
- '.**'
- 'tests/**'
- '*.*-dist'
- '*.js'
- '*.json'
- '*.lock'
- '*.md'
- '*.neon'
- '*.xml'
# Allow manual triggering
workflow_dispatch:
permissions:
contents: read
jobs:
test-php:
uses: ./.github/workflows/php-code-quality.yml
publish-dockerhub:
name: Publish ${{ matrix.image.name }} to Docker Hub
needs:
- test-php
runs-on: ubuntu-latest
strategy:
matrix:
image:
- name: app
dockerfile: ./.docker/app/Dockerfile
repository: supahgreg/tt-rss
- name: web-nginx
dockerfile: ./.docker/web-nginx/Dockerfile
repository: supahgreg/tt-rss-web-nginx
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Get commit timestamp
run: echo "COMMIT_TIMESTAMP=$(git show -s --format=%ci HEAD)" >> $GITHUB_ENV
- name: Get commit short SHA
run: echo "COMMIT_SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image.repository }}
tags: |
# update 'latest'
type=raw,value=latest
# short SHA with a 'sha-' prefix (e.g. sha-abc123)
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push to Docker Hub
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.image.dockerfile }}
platforms: linux/arm64,linux/amd64
# TODO: clean up build arg and environment variable naming.
build-args: |
CI_COMMIT_BRANCH=${{ github.ref_name }}
CI_COMMIT_SHA=${{ github.sha }}
CI_COMMIT_SHORT_SHA=${{ env.COMMIT_SHORT_SHA }}
CI_COMMIT_TIMESTAMP=${{ env.COMMIT_TIMESTAMP }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: true
sbom: true
push: true

View File

@@ -21,9 +21,6 @@ include:
- project: 'ci/ci-templates'
ref: master
file: .ci-lint-common.yml
- project: 'ci/ci-templates'
ref: master
file: .ci-integration-test.yml
- project: 'ci/ci-templates'
ref: master
file: .ci-update-helm-imagetag.yml
@@ -45,15 +42,8 @@ ttrss-fpm-pgsql-static:build:
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/app/Dockerfile
IMAGE_TAR: ${IMAGE_TAR_FPM}
ttrss-fpm-pgsql-static:push-master-commit-only:
extends: .crane-image-registry-push-master-commit-only
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
- job: ttrss-fpm-pgsql-static:build
ttrss-fpm-pgsql-static:push-branch:
extends: .crane-image-registry-push-branch
ttrss-fpm-pgsql-static:push-commit-only-gitlab:
extends: .crane-image-registry-push-commit-only-gitlab
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
@@ -65,15 +55,8 @@ ttrss-web-nginx:build:
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/web-nginx/Dockerfile
IMAGE_TAR: ${IMAGE_TAR_WEB}
ttrss-web-nginx:push-master-commit-only:
extends: .crane-image-registry-push-master-commit-only
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
- job: ttrss-web-nginx:build
ttrss-web-nginx:push-branch:
extends: .crane-image-registry-push-branch
ttrss-web-nginx:push-commit-only-gitlab:
extends: .crane-image-registry-push-commit-only-gitlab
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
@@ -85,7 +68,7 @@ phpdoc:build:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- php83 /phpDocumentor.phar -d classes -d include -t phpdoc --visibility=public
- php84 /phpDocumentor.phar -d classes -d include -t phpdoc --visibility=public
artifacts:
paths:
- phpdoc
@@ -105,16 +88,51 @@ phpdoc:publish:
phpunit-integration:
image: ${PHP_IMAGE}
variables:
TEST_HELM_REPO: oci://registry.fakecake.org/infra/helm-charts/tt-rss
extends: .integration-test
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
TTRSS_DB_HOST: db
TTRSS_DB_USER: ${POSTGRES_USER}
TTRSS_DB_NAME: ${POSTGRES_DB}
TTRSS_DB_PASS: ${POSTGRES_PASSWORD}
FF_NETWORK_PER_BUILD: "true"
APP_WEB_ROOT: /builds/shared-root
APP_INSTALL_BASE_DIR: ${APP_WEB_ROOT}
APP_BASE: "/tt-rss"
APP_FASTCGI_PASS: app:9000 # skip resolver
AUTO_CREATE_USER: test
AUTO_CREATE_USER_PASS: 'test'
AUTO_CREATE_USER_ACCESS_LEVEL: '10'
AUTO_CREATE_USER_ENABLE_API: 'true'
APP_URL: http://web-nginx/tt-rss
API_URL: ${APP_URL}/api/
HEALTHCHECK_URL: ${APP_URL}/public.php?op=healthcheck
__URLHELPER_ALLOW_LOOPBACK: 'true'
services:
- &svc_db
name: registry.fakecake.org/docker.io/postgres:15-alpine
alias: db
- &svc_app
name: ${CI_REGISTRY}/${CI_PROJECT_PATH}/ttrss-fpm-pgsql-static:${CI_COMMIT_SHORT_SHA}
alias: app
- &svc_web
name: ${CI_REGISTRY}/${CI_PROJECT_PATH}/ttrss-web-nginx:${CI_COMMIT_SHORT_SHA}
alias: web-nginx
rules:
- if: $CI_COMMIT_BRANCH
needs:
- job: ttrss-fpm-pgsql-static:push-commit-only-gitlab
- job: ttrss-web-nginx:push-commit-only-gitlab
before_script:
# wait for everything to start
- |
for a in `seq 1 15`; do
curl -fs ${HEALTHCHECK_URL} && break
sleep 5
done
script:
- export K8S_NAMESPACE=$(kubectl get pods -o=custom-columns=NS:.metadata.namespace | tail -1)
- export API_URL="http://tt-rss-${CI_COMMIT_SHORT_SHA}-app.$K8S_NAMESPACE.svc.cluster.local/tt-rss/api/"
- export TTRSS_DB_HOST=tt-rss-${CI_COMMIT_SHORT_SHA}-app.$K8S_NAMESPACE.svc.cluster.local
- export TTRSS_DB_USER=postgres
- export TTRSS_DB_NAME=postgres
- export TTRSS_DB_PASS=password
- php83 vendor/bin/phpunit --group integration --do-not-cache-result --log-junit phpunit-report.xml --coverage-cobertura phpunit-coverage.xml --coverage-text --colors=never
- cp tests/integration/feed.xml ${APP_WEB_ROOT}/${APP_BASE}/
- php84 vendor/bin/phpunit --group integration --do-not-cache-result --log-junit phpunit-report.xml --coverage-cobertura phpunit-coverage.xml --coverage-text --colors=never
artifacts:
when: always
reports:
@@ -124,22 +142,32 @@ phpunit-integration:
path: phpunit-coverage.xml
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
phpunit-integration:root-location:
variables:
APP_WEB_ROOT: /builds/shared-root/tt-rss
APP_INSTALL_BASE_DIR: /builds/shared-root
APP_BASE: ""
APP_URL: http://web-nginx
extends: phpunit-integration
selenium:
extends: phpunit-integration
image: ${SELENIUM_IMAGE}
variables:
TEST_HELM_REPO: oci://registry.fakecake.org/infra/helm-charts/tt-rss
SELENIUM_GRID_ENDPOINT: http://selenium-hub.selenium-grid.svc.cluster.local:4444/wd/hub
extends: .integration-test
SELENIUM_GRID_ENDPOINT: http://selenium:4444/wd/hub
services:
- *svc_db
- *svc_app
- *svc_web
- name: registry.fakecake.org/docker.io/selenium/standalone-chrome:4.32.0-20250515
alias: selenium
script:
- export K8S_NAMESPACE=$(kubectl get pods -o=custom-columns=NS:.metadata.namespace | tail -1)
- |
for i in `seq 1 3`; do
for i in `seq 1 10`; do
echo attempt $i...
python3 tests/integration/selenium_test.py && break
sleep 3
sleep 10
done
needs:
- job: phpunit-integration
artifacts:
when: always
reports:
@@ -224,3 +252,15 @@ update-prod:
ACCESS_TOKEN: ${PROD_HELM_TOKEN}
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $PROD_HELM_TOKEN != null
# https://about.gitlab.com/blog/how-to-automatically-create-a-new-mr-on-gitlab-with-gitlab-ci/
weblate-integration-auto-mr:
image: ${INFRA_IMAGE}
stage: publish
rules:
- if: $CI_COMMIT_BRANCH == "weblate-integration" && $AUTO_MR_TOKEN != null
script:
- HOST=${CI_PROJECT_URL} CI_PROJECT_ID=${CI_PROJECT_ID}
CI_COMMIT_REF_NAME=${CI_COMMIT_REF_NAME}
GITLAB_USER_ID=${GITLAB_USER_ID}
PRIVATE_TOKEN=${AUTO_MR_TOKEN} ./utils/autoMergeRequest.sh

2
.vscode/tasks.json vendored
View File

@@ -38,7 +38,7 @@
"label": "gulp: default",
"options": {
"env": {
"PATH": "${env:PATH}:/usr/lib/sdk/node16/bin/"
"PATH": "${workspaceRoot}/node_modules/.bin:$PATH"
}
}
}

View File

@@ -1,39 +1,5 @@
## Contributing code the right way
Contributions (code, translations, reporting issues, etc.) are welcome.
TLDR: it works *almost* like Github.
Due to spam, new Gitlab users are set to [external](https://docs.gitlab.com/ee/user/admin_area/external_users.html). In order to do anything, you'll need to ask for your account to be promoted. Sorry for the inconvenience.
1. Register on the [Gitlab](https://gitlab.tt-rss.org);
2. Post on the forums asking for your account to be promoted;
3. Fork the repository you're interested in;
4. Do the needful;
6. File a PR against master branch and verify that CI pipeline (especially, PHPStan) passes;
If you have any other questions, see this [forum thread](https://discourse.tt-rss.org/t/how-to-contribute-code-via-pull-requests-on-git-tt-rss-org/1850).
Please don't inline patches in forum posts, attach files instead (``.patch`` or ``.diff`` file extensions should work).
### FAQ
#### How do I push or pull without SSH?
You can't use SSH directly because tt-rss Gitlab is behind Cloudflare. You can use HTTPS with personal access tokens instead.
Create a personal access token in [Gitlab preferences](https://gitlab.tt-rss.org/-/user_settings/personal_access_tokens);
Optionally, configure Git to transparently work with tt-rss Gitlab repositories using HTTPS:
```
git config --global \
--add url."https://gitlab-token:your-personal-access-token@gitlab.tt-rss.org/".insteadOf \
"git@gitlab.tt-rss.org:"
```
Alternatively, checkout over HTTPS while adding the token manually:
```
git clone https://gitlab-token:your-personal-access-token@gitlab.tt-rss.org/tt-rss/tt-rss.git tt-rss
```
That's it.
> [!NOTE]
> The original tt-rss project handled translations via Weblate.
> It's yet to be determined how this project will handle things.

View File

@@ -1,10 +1,36 @@
Tiny Tiny RSS
=============
Tiny Tiny RSS (tt-rss)
======================
Web-based news feed aggregator, designed to allow you to read news from
any location, while feeling as close to a real desktop application as possible.
Tiny Tiny RSS (tt-rss) is a free, flexible, open-source, web-based news feed (RSS/Atom/other) reader and aggregator.
http://tt-rss.org
## Getting started
Please refer to [the wiki](https://github.com/tt-rss/tt-rss/wiki).
## Some notes about this project
* The original tt-rss project, hosted at https://tt-rss.org/ and its various subdomains, [will be gone after 2025-11-01](https://community.tt-rss.org/t/the-end-of-tt-rss-org/7164).
* Massive thanks to fox for creating tt-rss, and maintaining it (and absolutely everything else that went along with it) for so many years.
* This project (https://github.com/tt-rss/tt-rss) is a fork of tt-rss as of 2025-10-03, created by one of its long-time contributors (`wn_`/`wn_name` on `tt-rss.org`, `supahgreg` on `github.com`).
* The goal is to continue tt-rss development, with an initial focus on replacing `tt-rss.org` references and integrations + getting things working.
* Developer note: Due to use of `invalid@email.com` on `supahgreg`'s pre-2025-10-03 commits (which were done on `tt-rss.org`) GitHub incorrectly shows `ivanivanov884`
(the GitHub user associated with that e-mail address) as the author instead of `wn_`/`supahgreg`. Apologies for any confusion. `¯\_(ツ)_/¯`
* Plugins that were under https://gitlab.tt-rss.org/tt-rss/plugins have been mirrored to `https://github.com/tt-rss/tt-rss-plugin-*`.
* Plugin repository names have changed to get a consistent `tt-rss-plugin-*` prefix.
* Documentation from https://tt-rss.org has been recreated in https://github.com/tt-rss/tt-rss/wiki .
* The repository that held the content for https://tt-rss.org was mirrored to https://github.com/tt-rss/tt-rss-web-static .
Some content tweaks were made after mirroring (prior to the wiki being set up), and the repository is now archived.
* Docker images (for `linux/amd64` and `linux/arm64`) are being built and published to Docker Hub [via GitHub Actions](https://github.com/tt-rss/tt-rss/actions/workflows/publish.yml).
* See https://hub.docker.com/r/supahgreg/tt-rss/ and https://hub.docker.com/r/supahgreg/tt-rss-web-nginx/ , and
[the installation guide](https://github.com/tt-rss/tt-rss/wiki/Installation-Guide) for how they can be used.
## Development and contributing
* Contributions (code, translations, reporting issues, etc.) are welcome.
* Development and issue tracking primarily happens in https://github.com/tt-rss/tt-rss .
* Help translate tt-rss into your own language using [Weblate](https://hosted.weblate.org/engage/tt-rss/).
## License
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

View File

@@ -31,7 +31,7 @@
if (!empty($_SESSION["uid"])) {
if (!Sessions::validate_session()) {
header("Content-Type: text/json");
header("Content-Type: application/json");
print json_encode([
"seq" => -1,
@@ -55,10 +55,14 @@
} else /* if (method_exists($handler, 'index')) */ {
$handler->index($method);
}
$handler->after();
// API isn't currently overriding Handler#after()
// $handler->after();
}
header("Api-Content-Length: " . ob_get_length());
$content_length = ob_get_length();
header("Api-Content-Length: $content_length");
header("Content-Length: $content_length");
ob_end_flush();

View File

@@ -14,7 +14,7 @@
/* Public calls compatibility shim */
$public_calls = array("globalUpdateFeeds", "rss", "getUnread", "getProfiles", "share");
$public_calls = array("rss", "getUnread", "getProfiles", "share");
if (array_search($op, $public_calls) !== false) {
header("Location: public.php?" . $_SERVER['QUERY_STRING']);
@@ -35,9 +35,7 @@
return;
}
$span = OpenTelemetry\API\Trace\Span::getCurrent();
header("Content-Type: text/json; charset=utf-8");
header("Content-Type: application/json; charset=utf-8");
if (Config::get(Config::SINGLE_USER_MODE)) {
UserHelper::authenticate("admin", null);
@@ -45,10 +43,9 @@
if (!empty($_SESSION["uid"])) {
if (!Sessions::validate_session()) {
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
UserHelper::load_user_plugins($_SESSION["uid"]);
@@ -57,7 +54,6 @@
if (Config::is_migration_needed()) {
print Errors::to_json(Errors::E_SCHEMA_MISMATCH);
$span->setAttribute('error', Errors::E_SCHEMA_MISMATCH);
return;
}
@@ -100,7 +96,7 @@
];
// shortcut syntax for plugin methods (?op=plugin--pmethod&...params)
/* if (strpos($op, PluginHost::PUBLIC_METHOD_DELIMITER) !== false) {
/* if (str_contains($op, PluginHost::PUBLIC_METHOD_DELIMITER)) {
list ($plugin, $pmethod) = explode(PluginHost::PUBLIC_METHOD_DELIMITER, $op, 2);
// TODO: better implementation that won't modify $_REQUEST
@@ -115,17 +111,15 @@
if (class_exists($op) || $override) {
if (strpos($method, "_") === 0) {
if (str_starts_with($method, "_")) {
user_error("Refusing to invoke method $method of handler $op which starts with underscore.", E_USER_WARNING);
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
if ($override) {
/** @var Plugin|IHandler|ICatchall $handler */
$handler = $override;
} else {
$reflection = new ReflectionClass($op);
@@ -133,16 +127,14 @@
}
if (implements_interface($handler, 'IHandler')) {
$span->addEvent("construct/$op");
$handler->__construct($_REQUEST);
if (validate_csrf($csrf_token) || $handler->csrf_ignore($method)) {
$span->addEvent("before/$method");
$before = $handler->before($method);
if ($before) {
$span->addEvent("method/$method");
if ($method && method_exists($handler, $method)) {
$reflection = new ReflectionMethod($handler, $method);
@@ -150,44 +142,38 @@
$handler->$method();
} else {
user_error("Refusing to invoke method $method of handler $op which has required parameters.", E_USER_WARNING);
header("Content-Type: text/json");
header("Content-Type: application/json");
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
if (method_exists($handler, "catchall")) {
$handler->catchall($method);
} else {
header("Content-Type: text/json");
header("Content-Type: application/json");
$span->setAttribute('error', Errors::E_UNKNOWN_METHOD);
print Errors::to_json(Errors::E_UNKNOWN_METHOD, ["info" => get_class($handler) . "->$method"]);
}
}
$span->addEvent("after/$method");
$handler->after();
return;
} else {
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
} else {
user_error("Refusing to invoke method $method of handler $op with invalid CSRF token.", E_USER_WARNING);
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
}
}
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
$span->setAttribute('error', Errors::E_UNKNOWN_METHOD);

View File

@@ -1,7 +1,7 @@
<?php
class API extends Handler {
const API_LEVEL = 21;
const API_LEVEL = 23;
const STATUS_OK = 0;
const STATUS_ERR = 1;
@@ -14,8 +14,7 @@ class API extends Handler {
const E_OPERATION_FAILED = "E_OPERATION_FAILED";
const E_NOT_FOUND = "E_NOT_FOUND";
/** @var int|null */
private $seq;
private ?int $seq = null;
/**
* @param array<int|string, mixed> $reply
@@ -31,14 +30,14 @@ class API extends Handler {
function before(string $method): bool {
if (parent::before($method)) {
header("Content-Type: text/json");
header("Content-Type: application/json");
if (empty($_SESSION["uid"]) && $method != "login" && $method != "isloggedin") {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN));
return false;
}
if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref(Prefs::ENABLE_API_ACCESS)) {
if (!empty($_SESSION["uid"]) && $method != "logout" && !Prefs::get(Prefs::ENABLE_API_ACCESS, $_SESSION["uid"])) {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
return false;
}
@@ -74,7 +73,7 @@ class API extends Handler {
if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin";
if ($uid = UserHelper::find_user_by_login($login)) {
if (get_pref(Prefs::ENABLE_API_ACCESS, $uid)) {
if (Prefs::get(Prefs::ENABLE_API_ACCESS, $uid)) {
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) {
// needed for _get_config()
@@ -176,7 +175,7 @@ class API extends Handler {
if ($unread || !$unread_only) {
array_push($cats, [
'id' => $cat_id,
'title' => Feeds::_get_cat_title($cat_id),
'title' => Feeds::_get_cat_title($cat_id, $_SESSION['uid']),
'unread' => (int) $unread,
]);
}
@@ -235,13 +234,12 @@ class API extends Handler {
}
function updateArticle(): bool {
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
$article_ids = array_filter(explode(",", clean($_REQUEST["article_ids"] ?? "")));
$mode = (int) clean($_REQUEST["mode"]);
$data = clean($_REQUEST["data"] ?? "");
$field_raw = (int)clean($_REQUEST["field"]);
$field = "";
$set_to = "";
$additional_fields = "";
switch ($field_raw) {
@@ -265,20 +263,17 @@ class API extends Handler {
break;
};
switch ($mode) {
case 1:
$set_to = "true";
break;
case 0:
$set_to = "false";
break;
case 2:
$set_to = "NOT $field";
break;
}
$set_to = match ($mode) {
0 => 'false',
1 => 'true',
2 => "NOT $field",
default => null,
};
if ($field == "note") $set_to = $this->pdo->quote($data);
if ($field == "score") $set_to = (int) $data;
if ($field == 'note')
$set_to = $this->pdo->quote($data);
elseif ($field == 'score')
$set_to = (int) $data;
if ($field && $set_to && count($article_ids) > 0) {
@@ -289,6 +284,12 @@ class API extends Handler {
WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
$sth->execute([...$article_ids, $_SESSION['uid']]);
if ($field == 'marked')
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $article_ids);
if ($field == 'published')
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $article_ids);
$num_updated = $sth->rowCount();
return $this->_wrap(self::STATUS_OK, array("status" => "OK",
@@ -300,17 +301,16 @@ class API extends Handler {
}
function getArticle(): bool {
$article_ids = explode(',', clean($_REQUEST['article_id'] ?? ''));
$article_ids = array_filter(explode(',', clean($_REQUEST['article_id'] ?? '')));
$sanitize_content = self::_param_to_bool($_REQUEST['sanitize'] ?? true);
// @phpstan-ignore-next-line
if (count($article_ids)) {
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->select_many('e.id', 'e.guid', 'e.title', 'e.link', 'e.author', 'e.content', 'e.lang', 'e.comments',
'ue.feed_id', 'ue.int_id', 'ue.marked', 'ue.unread', 'ue.published', 'ue.score', 'ue.note')
->select_many_expr([
'updated' => SUBSTRING_FOR_DATE.'(updated,1,16)',
'updated' => 'SUBSTRING_FOR_DATE(updated,1,16)',
'feed_title' => '(SELECT title FROM ttrss_feeds WHERE id = ue.feed_id)',
'site_url' => '(SELECT site_url FROM ttrss_feeds WHERE id = ue.feed_id)',
'hide_images' => '(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id)',
@@ -372,22 +372,19 @@ class API extends Handler {
}
/**
* @see RPC::_make_init_params()
* @see RPC::_make_runtime_info()
* @return array<string, array<string, string>|bool|int|string>
*/
private function _get_config(): array {
$config = [
"icons_dir" => Config::get(Config::ICONS_DIR),
"icons_url" => Config::get(Config::ICONS_URL)
return [
'custom_sort_types' => $this->_get_custom_sort_types(),
'daemon_is_running' => file_is_locked('update_daemon.lock'),
'icons_url' => Config::get_self_url() . '/public.php',
'num_feeds' => ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count(),
];
$config["daemon_is_running"] = file_is_locked("update_daemon.lock");
$config["custom_sort_types"] = $this->_get_custom_sort_types();
$config["num_feeds"] = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count();
return $config;
}
function getConfig(): bool {
@@ -410,11 +407,13 @@ class API extends Handler {
$feed_id = clean($_REQUEST["feed_id"]);
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
$mode = clean($_REQUEST["mode"] ?? "");
$search_query = clean($_REQUEST["search_query"] ?? "");
$search_lang = clean($_REQUEST["search_lang"] ?? "");
if (!in_array($mode, ["all", "1day", "1week", "2week"]))
$mode = "all";
Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode);
Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode, [$search_query, $search_lang]);
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
}
@@ -422,7 +421,7 @@ class API extends Handler {
function getPref(): bool {
$pref_name = clean($_REQUEST["pref_name"]);
return $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
return $this->_wrap(self::STATUS_OK, array("value" => Prefs::get($pref_name, $_SESSION["uid"], $_SESSION["profile"] ?? null)));
}
function getLabels(): bool {
@@ -550,26 +549,22 @@ class API extends Handler {
/* Virtual feeds */
$vfeeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL);
foreach (PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL) as $feed) {
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
if (is_array($vfeeds)) {
foreach ($vfeeds as $feed) {
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
/** @var IVirtualFeed $feed['sender'] */
$unread = $feed['sender']->get_unread($feed['id']);
/** @var IVirtualFeed $feed['sender'] */
$unread = $feed['sender']->get_unread($feed['id']);
if ($unread || !$unread_only) {
$row = [
'id' => PluginHost::pfeed_to_feed_id($feed['id']),
'title' => $feed['title'],
'unread' => $unread,
'cat_id' => Feeds::CATEGORY_SPECIAL,
];
if ($unread || !$unread_only) {
$row = [
'id' => PluginHost::pfeed_to_feed_id($feed['id']),
'title' => $feed['title'],
'unread' => $unread,
'cat_id' => Feeds::CATEGORY_SPECIAL,
];
array_push($feeds, $row);
}
array_push($feeds, $row);
}
}
@@ -579,7 +574,7 @@ class API extends Handler {
$unread = Feeds::_get_counters($i, false, true);
if ($unread || !$unread_only) {
$title = Feeds::_get_title($i);
$title = Feeds::_get_title($i, $_SESSION['uid']);
$row = [
'id' => $i,
@@ -623,8 +618,8 @@ class API extends Handler {
/* API only: -3 (Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL) All feeds, excluding virtual feeds (e.g. Labels and such) */
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id', 'last_error', 'update_interval')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('order_id')
->order_by_asc('title');
@@ -650,6 +645,8 @@ class API extends Handler {
'cat_id' => (int) $feed->cat_id,
'last_updated' => (int) strtotime($feed->last_updated ?? ''),
'order_id' => (int) $feed->order_id,
'last_error' => $feed->last_error,
'update_interval' => (int) $feed->update_interval,
];
array_push($feeds, $row);
@@ -660,10 +657,9 @@ class API extends Handler {
}
/**
* @param string|int $feed_id
* @return array{0: array<int, array<string, mixed>>, 1: array<string, mixed>} $headlines, $headlines_header
*/
private static function _api_get_headlines($feed_id, int $limit, int $offset,
private static function _api_get_headlines(int|string $feed_id, int $limit, int $offset,
string $filter, bool $is_cat, bool $show_excerpt, bool $show_content, ?string $view_mode, string $order,
bool $include_attachments, int $since_id, string $search = "", bool $include_nested = false,
bool $sanitize_content = true, bool $force_update = false, int $excerpt_length = 100, ?int $check_first_id = null,
@@ -674,7 +670,7 @@ class API extends Handler {
$feed = ORM::for_table('ttrss_feeds')
->select_many('id', 'cache_images')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->find_one($feed_id);
if ($feed) {
@@ -696,7 +692,6 @@ class API extends Handler {
if (!$is_cat && is_numeric($feed_id) && $feed_id < PLUGIN_FEED_BASE_INDEX && $feed_id > LABEL_BASE_INDEX) {
$pfeed_id = PluginHost::feed_to_pfeed_id($feed_id);
/** @var IVirtualFeed|false $handler */
$handler = PluginHost::getInstance()->get_feed_handler($pfeed_id);
if ($handler) {
@@ -858,7 +853,7 @@ class API extends Handler {
array_push($headlines, $headline_row);
}
} else if (is_numeric($result) && $result == -1) {
} else if ($result == -1) {
$headlines_header['first_id_changed'] = true;
}

View File

@@ -85,21 +85,21 @@ class Article extends Handler_Protected {
content = ?, content_hash = ? WHERE id = ?");
$sth->execute([$content, $content_hash, $ref_id]);
if (Config::get(Config::DB_TYPE) == "pgsql") {
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
$params = [
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
}
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
$params = [
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true,
last_published = NOW() WHERE
int_id = ? AND owner_uid = ?");
$sth->execute([$int_id, $owner_uid]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
} else {
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
@@ -108,6 +108,8 @@ class Article extends Handler_Protected {
VALUES
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
$sth->execute([$ref_id, $owner_uid]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
}
if (count($labels) != 0) {
@@ -122,23 +124,20 @@ class Article extends Handler_Protected {
$sth = $pdo->prepare("INSERT INTO ttrss_entries
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
VALUES
(?, ?, ?, NOW(), ?, ?, NOW(), NOW())");
(?, ?, ?, NOW(), ?, ?, NOW(), NOW()) RETURNING id");
$sth->execute([$title, $guid, $url, $content, $content_hash]);
$sth = $pdo->prepare("SELECT id FROM ttrss_entries WHERE guid = ?");
$sth->execute([$guid]);
if ($row = $sth->fetch()) {
$ref_id = $row["id"];
if (Config::get(Config::DB_TYPE) == "pgsql"){
$sth = $pdo->prepare("UPDATE ttrss_entries
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
$params = [
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
}
$params = [
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
last_read, note, unread, last_published)
@@ -146,6 +145,8 @@ class Article extends Handler_Protected {
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
$sth->execute([$ref_id, $owner_uid]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
if (count($labels) != 0) {
foreach ($labels as $label) {
Labels::add_article($ref_id, trim($label), $owner_uid);
@@ -298,8 +299,6 @@ class Article extends Handler_Protected {
* @return array{'formatted': string, 'entries': array<int, array<string, mixed>>}
*/
static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array {
$span = Tracer::start(__METHOD__);
$enclosures = self::_get_enclosures($id);
$enclosures_formatted = "";
@@ -326,7 +325,6 @@ class Article extends Handler_Protected {
$enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images);
if (!empty($enclosures_formatted)) {
$span->end();
return [
'formatted' => $enclosures_formatted,
'entries' => []
@@ -340,7 +338,7 @@ class Article extends Handler_Protected {
$rv['can_inline'] = isset($_SESSION["uid"]) &&
empty($_SESSION["bw_limit"]) &&
!get_pref(Prefs::STRIP_IMAGES) &&
!Prefs::get(Prefs::STRIP_IMAGES, $_SESSION["uid"], $_SESSION["profile"] ?? null) &&
($always_display_enclosures || !preg_match("/<img/i", $article_content));
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
@@ -370,7 +368,6 @@ class Article extends Handler_Protected {
}
}
$span->end();
return $rv;
}
@@ -378,8 +375,6 @@ class Article extends Handler_Protected {
* @return array<int, string>
*/
static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array {
$span = Tracer::start(__METHOD__);
$a_id = $id;
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@@ -427,7 +422,6 @@ class Article extends Handler_Protected {
$sth->execute([$tags_str, $id, $owner_uid]);
}
$span->end();
return $tags;
}
@@ -472,16 +466,9 @@ class Article extends Handler_Protected {
static function _purge_orphans(): void {
// purge orphaned posts in main content table
if (Config::get(Config::DB_TYPE) == "mysql")
$limit_qpart = "LIMIT 5000";
else
$limit_qpart = "";
$pdo = Db::pdo();
$res = $pdo->query("DELETE FROM ttrss_entries WHERE
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart");
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id)");
if (Debug::enabled()) {
$rows = $res->rowCount();
@@ -522,8 +509,6 @@ class Article extends Handler_Protected {
* @return array<int, array<int, int|string>>
*/
static function _get_labels(int $id, ?int $owner_uid = null): array {
$span = Tracer::start(__METHOD__);
$rv = array();
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@@ -569,8 +554,6 @@ class Article extends Handler_Protected {
else
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
$span->end();
return $rv;
}
@@ -580,9 +563,7 @@ class Article extends Handler_Protected {
*
* @return array<int, Article::ARTICLE_KIND_*|string>
*/
static function _get_image(array $enclosures, string $content, string $site_url, array $headline) {
$span = Tracer::start(__METHOD__);
static function _get_image(array $enclosures, string $content, string $site_url, array $headline): array {
$article_image = "";
$article_stream = "";
$article_kind = 0;
@@ -603,6 +584,7 @@ class Article extends Handler_Protected {
$tmpxpath = new DOMXPath($tmpdoc);
$elems = $tmpxpath->query('(//img[@src]|//video[@poster]|//iframe[contains(@src , "youtube.com/embed/")])');
/** @var DOMElement $e */
foreach ($elems as $e) {
if ($e->nodeName == "iframe") {
$matches = [];
@@ -635,7 +617,7 @@ class Article extends Handler_Protected {
if (!$article_image)
foreach ($enclosures as $enc) {
if (strpos($enc["content_type"], "image/") !== false) {
if (str_contains($enc["content_type"], "image/")) {
$article_image = $enc["content_url"];
break;
}
@@ -660,8 +642,6 @@ class Article extends Handler_Protected {
if ($article_stream && $cache->exists(sha1($article_stream)))
$article_stream = $cache->get_url(sha1($article_stream));
$span->end();
return [$article_image, $article_stream, $article_kind];
}
@@ -675,12 +655,12 @@ class Article extends Handler_Protected {
if (count($article_ids) == 0)
return [];
$span = Tracer::start(__METHOD__);
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where_in('id', $article_ids)
->select('ue.label_cache')
->join('ttrss_user_entries', ['ue.ref_id', '=', 'e.id'], 'ue')
->where_in('e.id', $article_ids)
->where('ue.owner_uid', $_SESSION['uid'])
->find_many();
$rv = [];
@@ -696,8 +676,6 @@ class Article extends Handler_Protected {
}
}
$span->end();
return array_unique($rv);
}
@@ -709,12 +687,12 @@ class Article extends Handler_Protected {
if (count($article_ids) == 0)
return [];
$span = Tracer::start(__METHOD__);
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where_in('id', $article_ids)
->select('ue.feed_id')
->join('ttrss_user_entries', ['ue.ref_id', '=', 'e.id'], 'ue')
->where_in('e.id', $article_ids)
->where('ue.owner_uid', $_SESSION['uid'])
->find_many();
$rv = [];
@@ -723,8 +701,6 @@ class Article extends Handler_Protected {
array_push($rv, $entry->feed_id);
}
$span->end();
return array_unique($rv);
}
}

View File

@@ -15,12 +15,11 @@ abstract class Auth_Base extends Plugin implements IAuthModule {
/** Auto-creates specified user if allowed by system configuration.
* Can be used instead of find_user_by_login() by external auth modules
* @param string $login
* @param string|false $password
* @return null|int
* @param null|string|false $password
* @throws Exception
* @throws PDOException
*/
function auto_create_user(string $login, $password = false) {
function auto_create_user(string $login, null|false|string $password = false): ?int {
if ($login && Config::get(Config::AUTH_AUTO_CREATE)) {
$user_id = UserHelper::find_user_by_login($login);
@@ -49,11 +48,9 @@ abstract class Auth_Base extends Plugin implements IAuthModule {
/** replaced with UserHelper::find_user_by_login()
* @param string $login
* @return null|int
* @deprecated
*/
function find_user_by_login(string $login) {
function find_user_by_login(string $login): ?int {
return UserHelper::find_user_by_login($login);
}
}

View File

@@ -6,7 +6,7 @@ class Config {
const T_STRING = 2;
const T_INT = 3;
const SCHEMA_VERSION = 147;
const SCHEMA_VERSION = 151;
/** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
*
@@ -18,13 +18,16 @@ class Config {
*
* or config.php:
*
* putenv('TTRSS_DB_TYPE=pgsql');
* putenv('TTRSS_DB_HOST=my-patroni.example.com');
*
* note lack of quotes and spaces before and after "=".
*
*/
/** database type: pgsql or mysql */
/** this is kept for backwards/plugin compatibility, the only supported database is PostgreSQL
*
* @deprecated usages of `Config::get(Config::DB_TYPE)` should be replaced with default (and only) value: `pgsql` or removed
*/
const DB_TYPE = "DB_TYPE";
/** database server hostname */
@@ -42,9 +45,8 @@ class Config {
/** database server port */
const DB_PORT = "DB_PORT";
/** connection charset for MySQL. if you have a legacy database and/or experience
* garbage unicode characters with this option, try setting it to a blank string. */
const MYSQL_CHARSET = "MYSQL_CHARSET";
/** PostgreSQL SSL mode (prefer, require, disabled) */
const DB_SSLMODE = "DB_SSLMODE";
/** this is a fallback falue for the CLI SAPI, it should be set to a fully-qualified tt-rss URL */
const SELF_URL_PATH = "SELF_URL_PATH";
@@ -54,13 +56,6 @@ class Config {
* your tt-rss directory protected by other means (e.g. http auth). */
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
/** enables fallback update mode where tt-rss tries to update feeds in
* background while tt-rss is open in your browser.
* if you don't have a lot of feeds and don't want to or can't run
* background processes while not running tt-rss, this method is generally
* viable to keep your feeds up to date. */
const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_MODE";
/** use this PHP CLI executable to start various tasks */
const PHP_EXECUTABLE = "PHP_EXECUTABLE";
@@ -70,12 +65,6 @@ class Config {
/** base directory for local cache (must be writable) */
const CACHE_DIR = "CACHE_DIR";
/** directory for feed favicons (directory must be writable) */
const ICONS_DIR = "ICONS_DIR";
/** URL for feed favicons */
const ICONS_URL = "ICONS_URL";
/** auto create users authenticated via external modules */
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
@@ -192,11 +181,44 @@ class Config {
/** delay updates for this feed if received HTTP 429 (Too Many Requests) for this amount of seconds (base value, actual delay is base...base*2) */
const HTTP_429_THROTTLE_INTERVAL = "HTTP_429_THROTTLE_INTERVAL";
/** host running Jaeger collector to receive traces (disabled if empty) */
const OPENTELEMETRY_ENDPOINT = "OPENTELEMETRY_ENDPOINT";
/** disables login form controls except HOOK_LOGINFORM_ADDITIONAL_BUTTONS (for SSO providers), also prevents logging in through auth_internal */
const DISABLE_LOGIN_FORM = "DISABLE_LOGIN_FORM";
/** Jaeger service name */
const OPENTELEMETRY_SERVICE = "OPENTELEMETRY_SERVICE";
/** optional key to transparently encrypt sensitive data (currently limited to sessions and feed passwords),
* key is a 32 byte hex string which may be generated using `update.php --gen-encryption-key` */
const ENCRYPTION_KEY = "ENCRYPTION_KEY";
/** scheduled task to purge orphaned articles, value should be valid cron expression
* @see https://github.com/dragonmantank/cron-expression/blob/master/README.md#cron-expressions
*/
const SCHEDULE_PURGE_ORPHANS = "SCHEDULE_PURGE_ORPHANS";
/** scheduled task to expire disk cache, value should be valid cron expression */
const SCHEDULE_DISK_CACHE_EXPIRE_ALL = "SCHEDULE_DISK_CACHE_EXPIRE_ALL";
/** scheduled task, value should be valid cron expression */
const SCHEDULE_DISABLE_FAILED_FEEDS = "SCHEDULE_DISABLE_FAILED_FEEDS";
/** scheduled task to cleanup feed icons, value should be valid cron expression */
const SCHEDULE_CLEANUP_FEED_ICONS = "SCHEDULE_CLEANUP_FEED_ICONS";
/** scheduled task to disable feed updates of inactive users, value should be valid cron expression */
const SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS = "SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS";
/** scheduled task to cleanup error log, value should be valid cron expression */
const SCHEDULE_EXPIRE_ERROR_LOG = "SCHEDULE_EXPIRE_ERROR_LOG";
/** scheduled task to cleanup update daemon lock files, value should be valid cron expression */
const SCHEDULE_EXPIRE_LOCK_FILES = "SCHEDULE_EXPIRE_LOCK_FILES";
/** scheduled task to send digests, value should be valid cron expression */
const SCHEDULE_SEND_HEADLINES_DIGESTS = "SCHEDULE_SEND_HEADLINES_DIGESTS";
/** default (fallback) light theme path */
const DEFAULT_LIGHT_THEME = "DEFAULT_LIGHT_THEME";
/** default (fallback) dark (night) theme path */
const DEFAULT_DARK_THEME = "DEFAULT_DARK_THEME";
/** default values for all global configuration options */
private const _DEFAULTS = [
@@ -206,15 +228,12 @@ class Config {
Config::DB_NAME => [ "", Config::T_STRING ],
Config::DB_PASS => [ "", Config::T_STRING ],
Config::DB_PORT => [ "5432", Config::T_STRING ],
Config::MYSQL_CHARSET => [ "UTF8", Config::T_STRING ],
Config::DB_SSLMODE => [ "prefer", Config::T_STRING ],
Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ],
Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ],
Config::SIMPLE_UPDATE_MODE => [ "", Config::T_BOOL ],
Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ],
Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ],
Config::CACHE_DIR => [ "cache", Config::T_STRING ],
Config::ICONS_DIR => [ "feed-icons", Config::T_STRING ],
Config::ICONS_URL => [ "feed-icons", Config::T_STRING ],
Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ],
Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ],
Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ],
@@ -253,24 +272,33 @@ class Config {
Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ],
Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ],
Config::AUTH_MIN_INTERVAL => [ 5, Config::T_INT ],
Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)',
Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://github.com/tt-rss/tt-rss)',
Config::T_STRING ],
Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ],
Config::OPENTELEMETRY_ENDPOINT => [ "", Config::T_STRING ],
Config::OPENTELEMETRY_SERVICE => [ "tt-rss", Config::T_STRING ],
Config::DISABLE_LOGIN_FORM => [ "", Config::T_BOOL ],
Config::ENCRYPTION_KEY => [ "", Config::T_STRING ],
Config::SCHEDULE_PURGE_ORPHANS => ["@daily", Config::T_STRING],
Config::SCHEDULE_DISK_CACHE_EXPIRE_ALL => ["@daily", Config::T_STRING],
Config::SCHEDULE_DISABLE_FAILED_FEEDS => ["@daily", Config::T_STRING],
Config::SCHEDULE_CLEANUP_FEED_ICONS => ["@daily", Config::T_STRING],
Config::SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS =>
["@daily", Config::T_STRING],
Config::SCHEDULE_EXPIRE_ERROR_LOG => ["@hourly", Config::T_STRING],
Config::SCHEDULE_EXPIRE_LOCK_FILES => ["@hourly", Config::T_STRING],
Config::SCHEDULE_SEND_HEADLINES_DIGESTS => ["@hourly", Config::T_STRING],
Config::DEFAULT_LIGHT_THEME => [ "light.css", Config::T_STRING],
Config::DEFAULT_DARK_THEME => [ "night.css", Config::T_STRING],
];
/** @var Config|null */
private static $instance;
private static ?Config $instance = null;
/** @var array<string, array<bool|int|string>> */
private $params = [];
private array $params = [];
/** @var array<string, mixed> */
private $version = [];
private array $version = [];
/** @var Db_Migrations|null $migrations */
private $migrations;
private Db_Migrations $migrations;
public static function get_instance() : Config {
if (self::$instance == null)
@@ -304,7 +332,7 @@ class Config {
* based on source git tree commit used when creating the package
* @return array<string, mixed>|string
*/
static function get_version(bool $as_string = true) {
static function get_version(bool $as_string = true): array|string {
return self::get_instance()->_get_version($as_string);
}
@@ -322,7 +350,7 @@ class Config {
/**
* @return array<string, mixed>|string
*/
private function _get_version(bool $as_string = true) {
private function _get_version(bool $as_string = true): array|string {
$root_dir = self::get_self_dir();
if (empty($this->version)) {
@@ -431,24 +459,15 @@ class Config {
return self::get_migrations()->get_version();
}
/**
* @return bool|int|string
*/
static function cast_to(string $value, int $type_hint) {
switch ($type_hint) {
case self::T_BOOL:
return sql_bool_to_bool($value);
case self::T_INT:
return (int) $value;
default:
return $value;
}
static function cast_to(string $value, int $type_hint): bool|int|string {
return match ($type_hint) {
self::T_BOOL => sql_bool_to_bool($value),
self::T_INT => (int) $value,
default => $value,
};
}
/**
* @return bool|int|string
*/
private function _get(string $param) {
private function _get(string $param): bool|int|string {
list ($value, $type_hint) = $this->params[$param];
return $this->cast_to($value, $type_hint);
@@ -466,10 +485,7 @@ class Config {
$instance->_add($param, $default, $type_hint);
}
/**
* @return bool|int|string
*/
static function get(string $param) {
static function get(string $param): bool|int|string {
$instance = self::get_instance();
return $instance->_get($param);
@@ -491,32 +507,13 @@ class Config {
$self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$self_url_path = preg_replace("/(\/api\/{1,})?(\w+\.php)?(\?.*$)?$/", "", $self_url_path);
$self_url_path = preg_replace("/(\/plugins(.local))\/.{1,}$/", "", $self_url_path);
$self_url_path = preg_replace("/(\/plugins(.local)?)\/.{1,}$/", "", $self_url_path);
return rtrim($self_url_path, "/");
}
}
/* sanity check stuff */
/** checks for mysql tables not using InnoDB (tt-rss is incompatible with MyISAM)
* @return array<int, array<string, string>> A list of entries identifying tt-rss tables with bad config
*/
private static function check_mysql_tables() {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT engine, table_name FROM information_schema.tables WHERE
table_schema = ? AND table_name LIKE 'ttrss_%' AND engine != 'InnoDB'");
$sth->execute([self::get(Config::DB_NAME)]);
$bad_tables = [];
while ($line = $sth->fetch()) {
array_push($bad_tables, $line);
}
return $bad_tables;
}
static function sanity_check(): void {
/*
@@ -530,7 +527,7 @@ class Config {
$errors = [];
if (strpos(self::get(Config::PLUGINS), "auth_") === false) {
if (!str_contains(self::get(Config::PLUGINS), "auth_")) {
array_push($errors, "Please enable at least one authentication module via PLUGINS");
}
@@ -542,8 +539,8 @@ class Config {
array_push($errors, "Please don't run this script as root.");
}
if (version_compare(PHP_VERSION, '7.4.0', '<')) {
array_push($errors, "PHP version 7.4.0 or newer required. You're using " . PHP_VERSION . ".");
if (version_compare(PHP_VERSION, '8.2.0', '<')) {
array_push($errors, "PHP version 8.2.0 or newer required. You're using " . PHP_VERSION . ".");
}
if (!class_exists("UConverter")) {
@@ -599,10 +596,6 @@ class Config {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)");
}
if (!is_writable(self::get(Config::ICONS_DIR))) {
array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".self::get(Config::ICONS_DIR).").\n");
}
if (!is_writable(self::get(Config::LOCK_DIRECTORY))) {
array_push($errors, "LOCK_DIRECTORY is not writable (chmod -R 777 ".self::get(Config::LOCK_DIRECTORY).").\n");
}
@@ -621,29 +614,6 @@ class Config {
}
}
if (self::get(Config::DB_TYPE) == "mysql") {
$bad_tables = self::check_mysql_tables();
if (count($bad_tables) > 0) {
$bad_tables_fmt = [];
foreach ($bad_tables as $bt) {
array_push($bad_tables_fmt, sprintf("%s (%s)", $bt['table_name'], $bt['engine']));
}
$msg = "<p>The following tables use an unsupported MySQL engine: <b>" .
implode(", ", $bad_tables_fmt) . "</b>.</p>";
$msg .= "<p>The only supported engine on MySQL is InnoDB. MyISAM lacks functionality to run
tt-rss.
Please backup your data (via OPML) and re-import the schema before continuing.</p>
<p><b>WARNING: importing the schema would mean LOSS OF ALL YOUR DATA.</b></p>";
array_push($errors, $msg);
}
}
if (count($errors) > 0 && php_sapi_name() != "cli") {
http_response_code(503); ?>
@@ -662,9 +632,9 @@ class Config {
<?php foreach ($errors as $error) { echo self::format_error($error); } ?>
<p>You might want to check the tt-rss <a target="_blank" href="https://tt-rss.org/wiki/InstallationNotes/">wiki</a> or
<a target="_blank" href="https://community.tt-rss.org/">forums</a> for more information. Please search the forums before creating a new topic
for your question.</p>
<p>You might want to check the tt-rss <a target="_blank" href="https://github.com/tt-rss/tt-rss/wiki">wiki</a> or
<a target="_blank" href="https://github.com/tt-rss/tt-rss/discussions">discussions</a> for more information.
Please search before creating a new topic for your question.</p>
</div>
</body>
</html>

View File

@@ -35,6 +35,7 @@ class Counters {
static private function get_cat_children(int $cat_id, int $owner_uid): array {
$unread = 0;
$marked = 0;
$published = 0;
$cats = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid)
@@ -42,13 +43,14 @@ class Counters {
->find_many();
foreach ($cats as $cat) {
list ($tmp_unread, $tmp_marked) = self::get_cat_children($cat->id, $owner_uid);
list ($tmp_unread, $tmp_marked, $tmp_published) = self::get_cat_children($cat->id, $owner_uid);
$unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid);
$marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid);
$published += $tmp_published + Feeds::_get_cat_published($cat->id, $owner_uid);
}
return [$unread, $marked];
return [$unread, $marked, $published];
}
/**
@@ -75,8 +77,9 @@ class Counters {
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
@@ -86,8 +89,9 @@ class Counters {
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
@@ -98,8 +102,9 @@ class Counters {
} else {
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
@@ -109,8 +114,9 @@ class Counters {
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
@@ -121,16 +127,18 @@ class Counters {
while ($line = $sth->fetch()) {
if ($line["num_children"] > 0) {
list ($child_counter, $child_marked_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]);
list ($child_counter, $child_marked_counter, $child_published_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]);
} else {
$child_counter = 0;
$child_marked_counter = 0;
$child_published_counter = 0;
}
$cv = [
"id" => (int)$line["id"],
"kind" => "cat",
"markedcounter" => (int) $line["count_marked"] + $child_marked_counter,
"publishedcounter" => (int) $line["count_published"] + $child_published_counter,
"counter" => (int) $line["count"] + $child_counter
];
@@ -145,75 +153,40 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
private static function get_feeds(?array $feed_ids = null): array {
$span = Tracer::start(__METHOD__);
$ret = [];
$pdo = Db::pdo();
if (is_array($feed_ids) && count($feed_ids) === 0)
return $ret;
if (is_array($feed_ids)) {
if (count($feed_ids) == 0)
return [];
$feeds = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('f.id', 'f.title', 'f.last_error')
->select_many_expr([
'count' => 'SUM(CASE WHEN ue.unread THEN 1 ELSE 0 END)',
'count_marked' => 'SUM(CASE WHEN ue.marked THEN 1 ELSE 0 END)',
'count_published' => 'SUM(CASE WHEN ue.published THEN 1 ELSE 0 END)',
'last_updated' => 'SUBSTRING_FOR_DATE(f.last_updated,1,19)',
])
->join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->where('ue.owner_uid', $_SESSION['uid'])
->group_by('f.id');
$feed_ids_qmarks = arr_qmarks($feed_ids);
if (is_array($feed_ids))
$feeds->where_in('f.id', $feed_ids);
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks)
GROUP BY f.id");
$sth->execute([$_SESSION['uid'], ...$feed_ids]);
} else {
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = :uid
GROUP BY f.id");
$sth->execute(["uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
$id = $line["id"];
$last_updated = TimeHelper::make_local_datetime($line['last_updated'], false);
if (Feeds::_has_icon($id)) {
$ts = filemtime(Feeds::_get_icon_file($id));
} else {
$ts = 0;
}
// hide default un-updated timestamp i.e. 1970-01-01 (?) -fox
if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'] ?? '')) > 2)
$last_updated = '';
$cv = [
"id" => $id,
"updated" => $last_updated,
"counter" => (int) $line["count"],
"markedcounter" => (int) $line["count_marked"],
"ts" => (int) $ts
foreach ($feeds->find_many() as $feed) {
$ret[] = [
'id' => $feed->id,
'title' => truncate_string($feed->title, 30),
'error' => $feed->last_error,
'updated' => TimeHelper::make_local_datetime($feed->last_updated),
'counter' => (int) $feed->count,
'markedcounter' => (int) $feed->count_marked,
'publishedcounter' => (int) $feed->count_published,
'ts' => Feeds::_has_icon($feed->id) ? (int) filemtime(Feeds::_get_icon_file($feed->id)) : 0,
];
$cv["error"] = $line["last_error"];
$cv["title"] = truncate_string($line["title"], 30);
array_push($ret, $cv);
}
$span->end();
return $ret;
}
@@ -221,8 +194,6 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
private static function get_global(): array {
$span = Tracer::start(__METHOD__);
$ret = [
[
"id" => "global-unread",
@@ -239,8 +210,6 @@ class Counters {
"counter" => $subcribed_feeds
]);
$span->end();
return $ret;
}
@@ -248,8 +217,6 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
private static function get_virt(): array {
$span = Tracer::start(__METHOD__);
$ret = [];
foreach ([Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED,
@@ -271,31 +238,29 @@ class Counters {
if ($feed_id == Feeds::FEED_STARRED)
$cv["markedcounter"] = $auxctr;
if ($feed_id == Feeds::FEED_PUBLISHED)
$cv["publishedcounter"] = $auxctr;
array_push($ret, $cv);
}
$feeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL);
foreach (PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL) as $feed) {
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
if (is_array($feeds)) {
foreach ($feeds as $feed) {
/** @var IVirtualFeed $feed['sender'] */
/** @var Plugin&IVirtualFeed $feed['sender'] */
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
$cv = [
"id" => PluginHost::pfeed_to_feed_id($feed['id']),
"counter" => $feed['sender']->get_unread($feed['id'])
];
$cv = [
"id" => PluginHost::pfeed_to_feed_id($feed['id']),
"counter" => $feed['sender']->get_unread($feed['id'])
];
if (method_exists($feed['sender'], 'get_total'))
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
if (method_exists($feed['sender'], 'get_total'))
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
array_push($ret, $cv);
}
array_push($ret, $cv);
}
$span->end();
return $ret;
}
@@ -304,8 +269,6 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
static function get_labels(?array $label_ids = null): array {
$span = Tracer::start(__METHOD__);
$ret = [];
$pdo = Db::pdo();
@@ -320,6 +283,7 @@ class Counters {
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN u1.published = true THEN 1 ELSE 0 END) AS count_published,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
@@ -332,6 +296,7 @@ class Counters {
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN u1.published = true THEN 1 ELSE 0 END) AS count_published,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
@@ -350,13 +315,13 @@ class Counters {
"counter" => (int) $line["count_unread"],
"auxcounter" => (int) $line["total"],
"markedcounter" => (int) $line["count_marked"],
"publishedcounter" => (int) $line["count_published"],
"description" => $line["caption"]
];
array_push($ret, $cv);
}
$span->end();
return $ret;
}
}

62
classes/Crypt.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
class Crypt {
/** the only algo supported at the moment */
private const ENCRYPT_ALGO = 'xchacha20poly1305_ietf';
/** currently only generates keys using sodium_crypto_aead_chacha20poly1305_keygen() i.e. one supported Crypt::ENCRYPT_ALGO
* @return string random 256-bit (for ChaCha20-Poly1305) binary string
*/
static function generate_key() : string {
return sodium_crypto_aead_chacha20poly1305_keygen();
}
/** encrypts provided ciphertext using Config::ENCRYPTION_KEY into an encrypted object
*
* @return array{'algo': string, 'nonce': string, 'payload': string} encrypted data object containing algo, nonce, and encrypted data
*/
static function encrypt_string(string $ciphertext) : array {
$key = Config::get(Config::ENCRYPTION_KEY);
if (!$key)
throw new Exception("Crypt::encrypt_string() failed to encrypt - key is not available");
$nonce = \random_bytes(\SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$payload = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($ciphertext, '', $nonce, hex2bin($key));
if ($payload) {
$encrypted_data = [
'algo' => self::ENCRYPT_ALGO,
'nonce' => $nonce,
'payload' => $payload,
];
return $encrypted_data;
}
throw new Exception("Crypt::encrypt_string() failed to encrypt ciphertext");
}
/** decrypts payload of a valid encrypted object using Config::ENCRYPTION_KEY
*
* @param array{'algo': string, 'nonce': string, 'payload': string} $encrypted_data
*
* @return string decrypted string payload
*/
static function decrypt_string(array $encrypted_data) : string {
$key = Config::get(Config::ENCRYPTION_KEY);
if (!$key)
throw new Exception("Crypt::decrypt_string() failed to decrypt - key is not available");
// only one is supported for the time being
switch ($encrypted_data['algo']) {
case self::ENCRYPT_ALGO:
return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($encrypted_data['payload'], '', $encrypted_data['nonce'], hex2bin($key));
}
throw new Exception('Crypt::decrypt_string() failed to decrypt passed encrypted data object, unsupported algo: ' . $encrypted_data['algo']);
}
}

View File

@@ -1,20 +1,14 @@
<?php
class Db
{
/** @var Db $instance */
private static $instance;
class Db {
private static ?Db $instance = null;
/** @var PDO|null $pdo */
private $pdo;
private ?PDO $pdo = null;
function __construct() {
ORM::configure(self::get_dsn());
ORM::configure('username', Config::get(Config::DB_USER));
ORM::configure('password', Config::get(Config::DB_PASS));
ORM::configure('return_result_sets', true);
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
ORM::configure('driver_options', array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . Config::get(Config::MYSQL_CHARSET)));
}
}
/**
@@ -32,13 +26,10 @@ class Db
public static function get_dsn(): string {
$db_port = Config::get(Config::DB_PORT) ? ';port=' . Config::get(Config::DB_PORT) : '';
$db_host = Config::get(Config::DB_HOST) ? ';host=' . Config::get(Config::DB_HOST) : '';
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
$db_charset = ';charset=' . Config::get(Config::MYSQL_CHARSET);
} else {
$db_charset = '';
}
$db_sslmode = Config::get(Config::DB_SSLMODE);
return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port . $db_charset;
return 'pgsql:dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port .
";sslmode=$db_sslmode";
}
// this really shouldn't be used unless a separate PDO connection is needed
@@ -56,20 +47,10 @@ class Db
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if (Config::get(Config::DB_TYPE) == "pgsql") {
$pdo->query("set client_encoding = 'UTF-8'");
$pdo->query("set datestyle = 'ISO, european'");
$pdo->query("set TIME ZONE 0");
$pdo->query("set cpu_tuple_cost = 0.5");
} else if (Config::get(Config::DB_TYPE) == "mysql") {
$pdo->query("SET time_zone = '+0:0'");
if (Config::get(Config::MYSQL_CHARSET)) {
$pdo->query("SET NAMES " . Config::get(Config::MYSQL_CHARSET));
}
}
$pdo->query("set client_encoding = 'UTF-8'");
$pdo->query("set datestyle = 'ISO, european'");
$pdo->query("set TIME ZONE 0");
$pdo->query("set cpu_tuple_cost = 0.5");
return $pdo;
}
@@ -92,11 +73,8 @@ class Db
return self::$instance->pdo;
}
/** @deprecated usages should be replaced with `RANDOM()` */
public static function sql_random_function(): string {
if (Config::get(Config::DB_TYPE) == "mysql") {
return "RAND()";
}
return "RANDOM()";
}
}

View File

@@ -23,7 +23,7 @@ class Db_Migrations {
}
function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0): void {
$this->base_path = "$root_path/" . Config::get(Config::DB_TYPE);
$this->base_path = "$root_path/pgsql";
$this->migrations_path = $this->base_path . "/migrations";
$this->migrations_table = $migrations_table;
$this->base_is_latest = $base_is_latest;
@@ -88,9 +88,7 @@ class Db_Migrations {
$lines = $this->get_lines($version);
if (count($lines) > 0) {
// mysql doesn't support transactions for DDL statements
if (Config::get(Config::DB_TYPE) != "mysql")
$this->pdo->beginTransaction();
$this->pdo->beginTransaction();
foreach ($lines as $line) {
Debug::log($line, Debug::LOG_EXTENDED);
@@ -107,8 +105,7 @@ class Db_Migrations {
else
$this->set_version($version);
if (Config::get(Config::DB_TYPE) != "mysql")
$this->pdo->commit();
$this->pdo->commit();
Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE);
@@ -190,7 +187,7 @@ class Db_Migrations {
if (file_exists($filename)) {
$lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)),
fn($line) => strlen(trim($line)) > 0 && strpos($line, "--") !== 0);
fn($line) => strlen(trim($line)) > 0 && !str_starts_with($line, "--"));
return array_filter(explode(";", implode("", $lines)),
fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]));

View File

@@ -2,17 +2,11 @@
class Db_Prefs {
// this class is a stub for the time being (to be removed)
/**
* @return bool|int|null|string
*/
function read(string $pref_name, ?int $user_id = null, bool $die_on_error = false) {
return get_pref($pref_name, $user_id);
function read(string $pref_name, ?int $user_id = null, bool $die_on_error = false): bool|int|null|string {
return Prefs::get($pref_name, $user_id ?: $_SESSION['uid'], $_SESSION['profile'] ?? null);
}
/**
* @param mixed $value
*/
function write(string $pref_name, $value, ?int $user_id = null, bool $strip_tags = true): bool {
return set_pref($pref_name, $value, $user_id, $strip_tags);
function write(string $pref_name, mixed $value, ?int $user_id = null, bool $strip_tags = true): bool {
return Prefs::set($pref_name, $value, $user_id ?: $_SESSION['uid'], $_SESSION['profile'] ?? null, $strip_tags);
}
}

View File

@@ -77,7 +77,7 @@ class Debug {
*/
public static function map_loglevel(int $level) : int {
if (in_array($level, self::ALL_LOG_LEVELS)) {
/** @phpstan-ignore-next-line */
/** @phpstan-ignore return.type (yes it is a Debug::LOG_* value) */
return $level;
} else {
user_error("Passed invalid debug log level: $level", E_USER_WARNING);

View File

@@ -2,28 +2,20 @@
class Digest
{
static function send_headlines_digests(): void {
$span = Tracer::start(__METHOD__);
$user_limit = 15; // amount of users to process (e.g. emails to send out)
$limit = 1000; // maximum amount of headlines to include
Debug::log("Sending digests, batch of max $user_limit users, headline limit = $limit");
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "last_digest_sent < NOW() - INTERVAL '1 days'";
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "last_digest_sent < DATE_SUB(NOW(), INTERVAL 1 DAY)";
}
$pdo = Db::pdo();
$res = $pdo->query("SELECT id, login, email FROM ttrss_users
WHERE email != '' AND (last_digest_sent IS NULL OR $interval_qpart)");
WHERE email != '' AND (last_digest_sent IS NULL OR last_digest_sent < NOW() - INTERVAL '1 day')");
while ($line = $res->fetch()) {
if (get_pref(Prefs::DIGEST_ENABLE, $line['id'])) {
$preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? '');
if (Prefs::get(Prefs::DIGEST_ENABLE, $line['id'])) {
$preferred_ts = strtotime(Prefs::get(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? '');
// try to send digests within 2 hours of preferred time
if ($preferred_ts && time() >= $preferred_ts &&
@@ -32,7 +24,7 @@ class Digest
Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]);
$do_catchup = get_pref(Prefs::DIGEST_CATCHUP, $line['id']);
$do_catchup = Prefs::get(Prefs::DIGEST_CATCHUP, $line['id']);
global $tz_offset;
@@ -77,7 +69,6 @@ class Digest
}
}
$span->end();
Debug::log("All done.");
}
@@ -109,13 +100,6 @@ class Digest
$tpl_t->setVariable('TTRSS_HOST', Config::get_self_url());
$affected_ids = array();
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'";
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)";
}
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT ttrss_entries.title,
@@ -126,7 +110,7 @@ class Digest
link,
score,
content,
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
SUBSTRING_FOR_DATE(last_updated,1,19) AS last_updated
FROM
ttrss_user_entries,ttrss_entries,ttrss_feeds
LEFT JOIN
@@ -134,7 +118,7 @@ class Digest
WHERE
ref_id = ttrss_entries.id AND feed_id = ttrss_feeds.id
AND include_in_digest = true
AND $interval_qpart
AND ttrss_entries.date_updated > NOW() - INTERVAL '$days day'
AND ttrss_user_entries.owner_uid = :user_id
AND unread = true
AND score >= :min_score
@@ -156,17 +140,16 @@ class Digest
array_push($affected_ids, $line["ref_id"]);
$updated = TimeHelper::make_local_datetime($line['last_updated'], false,
$user_id);
$updated = TimeHelper::make_local_datetime($line['last_updated'], owner_uid: $user_id);
if (get_pref(Prefs::ENABLE_FEED_CATS, $user_id)) {
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $user_id)) {
$line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title'];
}
$article_labels = Article::_get_labels($line["ref_id"], $user_id);
$article_labels_formatted = "";
if (is_array($article_labels) && count($article_labels) > 0) {
if (count($article_labels) > 0) {
$article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels));
}

View File

@@ -1,10 +1,9 @@
<?php
class DiskCache implements Cache_Adapter {
/** @var Cache_Adapter $adapter */
private $adapter;
private Cache_Adapter $adapter;
/** @var array<string, DiskCache> $instances */
private static $instances = [];
private static array $instances = [];
/**
* https://stackoverflow.com/a/53662733
@@ -221,11 +220,7 @@ class DiskCache implements Cache_Adapter {
}
public function remove(string $filename): bool {
$span = Tracer::start(__METHOD__);
$span->setAttribute('file.name', $filename);
$rc = $this->adapter->remove($filename);
$span->end();
return $rc;
}
@@ -251,9 +246,6 @@ class DiskCache implements Cache_Adapter {
}
public function exists(string $filename): bool {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("DiskCache::exists: $filename");
$rc = $this->adapter->exists(basename($filename));
return $rc;
@@ -263,11 +255,7 @@ class DiskCache implements Cache_Adapter {
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
*/
public function get_size(string $filename) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('file.name', $filename);
$rc = $this->adapter->get_size(basename($filename));
$span->end();
return $rc;
}
@@ -278,11 +266,7 @@ class DiskCache implements Cache_Adapter {
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data) {
$span = Tracer::start(__METHOD__);
$rc = $this->adapter->put(basename($filename), $data);
$span->end();
return $rc;
return $this->adapter->put(basename($filename), $data);
}
/** @deprecated we can't assume cached files are local, and other storages
@@ -316,8 +300,11 @@ class DiskCache implements Cache_Adapter {
if ($this->exists($local_filename) && !$force)
return true;
$data = UrlHelper::fetch(array_merge(["url" => $url,
"max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)], $options));
$data = UrlHelper::fetch([
'url' => $url,
'max_size' => Config::get(Config::MAX_CACHE_FILE_SIZE),
...$options,
]);
if ($data)
return $this->put($local_filename, $data) > 0;
@@ -326,17 +313,12 @@ class DiskCache implements Cache_Adapter {
}
public function send(string $filename) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('file.name', $filename);
$filename = basename($filename);
if (!$this->exists($filename)) {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
$span->setAttribute('error', '404 not found');
$span->end();
return false;
}
@@ -346,8 +328,6 @@ class DiskCache implements Cache_Adapter {
if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) {
header('HTTP/1.1 304 Not Modified');
$span->setAttribute('error', '304 not modified');
$span->end();
return false;
}
@@ -365,9 +345,6 @@ class DiskCache implements Cache_Adapter {
header("Content-type: text/plain");
print "Stored file has disallowed content type ($mimetype)";
$span->setAttribute('error', '400 disallowed content type');
$span->end();
return false;
}
@@ -389,13 +366,7 @@ class DiskCache implements Cache_Adapter {
header_remove("Pragma");
$span->setAttribute('mimetype', $mimetype);
$rc = $this->adapter->send($filename);
$span->end();
return $rc;
return $this->adapter->send($filename);
}
public function get_full_path(string $filename): string {
@@ -424,13 +395,9 @@ class DiskCache implements Cache_Adapter {
// plugins work on original source URLs used before caching
// NOTE: URLs should be already absolutized because this is called after sanitize()
static public function rewrite_urls(string $str): string {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("DiskCache::rewrite_urls");
$res = trim($str);
if (!$res) {
$span->end();
return '';
}
@@ -439,13 +406,12 @@ class DiskCache implements Cache_Adapter {
$xpath = new DOMXPath($doc);
$cache = DiskCache::instance("images");
$entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])');
$need_saving = false;
foreach ($entries as $entry) {
$span->addEvent("entry: " . $entry->tagName);
$entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
foreach (array('src', 'poster') as $attr) {
if ($entry->hasAttribute($attr)) {
$url = $entry->getAttribute($attr);

View File

@@ -1,21 +1,9 @@
<?php
class FeedEnclosure {
/** @var string */
public $link;
/** @var string */
public $type;
/** @var string */
public $length;
/** @var string */
public $title;
/** @var string */
public $height;
/** @var string */
public $width;
public string $link = '';
public string $type = '';
public string $length = '';
public string $title = '';
public string $height = '';
public string $width = '';
}

View File

@@ -3,7 +3,7 @@ abstract class FeedItem {
abstract function get_id(): string;
/** @return int|false a timestamp on success, false otherwise */
abstract function get_date();
abstract function get_date(): false|int;
abstract function get_link(): string;
abstract function get_title(): string;

View File

@@ -15,7 +15,7 @@ class FeedItem_Atom extends FeedItem_Common {
/**
* @return int|false a timestamp on success, false otherwise
*/
function get_date() {
function get_date(): false|int {
$updated = $this->elem->getElementsByTagName("updated")->item(0);
if ($updated) {
@@ -43,7 +43,6 @@ class FeedItem_Atom extends FeedItem_Common {
$links = $this->elem->getElementsByTagName("link");
foreach ($links as $link) {
/** @phpstan-ignore-next-line */
if ($link->hasAttribute("href") &&
(!$link->hasAttribute("rel")
|| $link->getAttribute("rel") == "alternate"
@@ -81,6 +80,7 @@ class FeedItem_Atom extends FeedItem_Common {
$elems = $tmpxpath->query("(//*[@href]|//*[@src])");
/** @var DOMElement $elem */
foreach ($elems as $elem) {
if ($elem->hasAttribute("href")) {
$elem->setAttribute("href",
@@ -181,16 +181,14 @@ class FeedItem_Atom extends FeedItem_Common {
$encs = [];
foreach ($links as $link) {
/** @phpstan-ignore-next-line */
if ($link->hasAttribute("href") && $link->hasAttribute("rel")) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
if ($link->getAttribute("rel") == "enclosure") {
$enc = new FeedEnclosure();
$enc->type = clean($link->getAttribute("type"));
$enc->length = clean($link->getAttribute("length"));
$enc->link = clean($link->getAttribute("href"));
$enc->type = clean($link->getAttribute('type'));
$enc->length = clean($link->getAttribute('length'));
$enc->link = clean($link->getAttribute('href'));
if (!empty($base)) {
$enc->link = UrlHelper::rewrite_relative($base, $enc->link);
@@ -213,6 +211,7 @@ class FeedItem_Atom extends FeedItem_Common {
return clean($lang);
} else {
// Fall back to the language declared on the feed, if any.
/** @var DOMElement|DOMNode $child */
foreach ($this->doc->childNodes as $child) {
if (method_exists($child, "getAttributeNS")) {
return clean($child->getAttributeNS(self::NS_XML, "lang"));

View File

@@ -1,18 +1,13 @@
<?php
abstract class FeedItem_Common extends FeedItem {
/** @var DOMElement */
protected $elem;
/** @var DOMDocument */
protected $doc;
/** @var DOMXPath */
protected $xpath;
protected readonly DOMElement $elem;
protected readonly DOMDocument $doc;
protected readonly DOMXPath $xpath;
function __construct(DOMElement $elem, DOMDocument $doc, DOMXPath $xpath) {
$this->elem = $elem;
$this->xpath = $xpath;
$this->doc = $doc;
$this->xpath = $xpath;
try {
$source = $elem->getElementsByTagName("source")->item(0);
@@ -89,6 +84,7 @@ abstract class FeedItem_Common extends FeedItem {
/**
* this is common for both Atom and RSS types and deals with various 'media:' elements
*
* @see https://www.rssboard.org/media-rss
* @return array<int, FeedEnclosure>
*/
function get_enclosures(): array {
@@ -96,14 +92,14 @@ abstract class FeedItem_Common extends FeedItem {
$enclosures = $this->xpath->query("media:content", $this->elem);
/** @var DOMElement $enclosure */
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
$enc->type = clean($enclosure->getAttribute("type"));
$enc->link = clean($enclosure->getAttribute("url"));
$enc->length = clean($enclosure->getAttribute("length"));
$enc->height = clean($enclosure->getAttribute("height"));
$enc->width = clean($enclosure->getAttribute("width"));
$enc->type = clean($enclosure->getAttribute('type'));
$enc->link = clean($enclosure->getAttribute('url'));
$enc->length = clean($enclosure->getAttribute('length'));
$enc->height = clean($enclosure->getAttribute('height'));
$enc->width = clean($enclosure->getAttribute('width'));
$medium = clean($enclosure->getAttribute("medium"));
if (!$enc->type && $medium) {
@@ -119,17 +115,16 @@ abstract class FeedItem_Common extends FeedItem {
$enclosures = $this->xpath->query("media:group", $this->elem);
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
/** @var DOMElement|null */
$content = $this->xpath->query("media:content", $enclosure)->item(0);
if ($content) {
$enc->type = clean($content->getAttribute("type"));
$enc->link = clean($content->getAttribute("url"));
$enc->length = clean($content->getAttribute("length"));
$enc->height = clean($content->getAttribute("height"));
$enc->width = clean($content->getAttribute("width"));
$enc = new FeedEnclosure();
$enc->type = clean($content->getAttribute('type'));
$enc->link = clean($content->getAttribute('url'));
$enc->length = clean($content->getAttribute('length'));
$enc->height = clean($content->getAttribute('height'));
$enc->width = clean($content->getAttribute('width'));
$medium = clean($content->getAttribute("medium"));
if (!$enc->type && $medium) {
@@ -148,15 +143,15 @@ abstract class FeedItem_Common extends FeedItem {
}
}
$enclosures = $this->xpath->query("media:thumbnail", $this->elem);
$enclosures = $this->xpath->query("(.|media:content|media:group|media:group/media:content)/media:thumbnail", $this->elem);
/** @var DOMElement $enclosure */
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
$enc->type = "image/generic";
$enc->link = clean($enclosure->getAttribute("url"));
$enc->height = clean($enclosure->getAttribute("height"));
$enc->width = clean($enclosure->getAttribute("width"));
$enc->type = 'image/generic';
$enc->link = clean($enclosure->getAttribute('url'));
$enc->height = clean($enclosure->getAttribute('height'));
$enc->width = clean($enclosure->getAttribute('width'));
array_push($encs, $enc);
}
@@ -171,7 +166,7 @@ abstract class FeedItem_Common extends FeedItem {
/**
* @return false|string false on failure, otherwise string contents
*/
function subtree_or_text(DOMElement $node) {
function subtree_or_text(DOMElement $node): false|string {
if ($this->count_children($node) == 0) {
return $node->nodeValue;
} else {
@@ -201,10 +196,6 @@ abstract class FeedItem_Common extends FeedItem {
$cat = preg_replace('/[,\'\"]/', "", $cat);
if (Config::get(Config::DB_TYPE) == "mysql") {
$cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat);
}
if (mb_strlen($cat) > 250)
$cat = mb_substr($cat, 0, 250);

View File

@@ -13,7 +13,7 @@ class FeedItem_RSS extends FeedItem_Common {
/**
* @return int|false a timestamp on success, false otherwise
*/
function get_date() {
function get_date(): false|int {
$pubDate = $this->elem->getElementsByTagName("pubDate")->item(0);
if ($pubDate) {
@@ -33,8 +33,9 @@ class FeedItem_RSS extends FeedItem_Common {
function get_link(): string {
$links = $this->xpath->query("atom:link", $this->elem);
/** @var DOMElement $link */
foreach ($links as $link) {
if ($link && $link->hasAttribute("href") &&
if ($link->hasAttribute("href") &&
(!$link->hasAttribute("rel")
|| $link->getAttribute("rel") == "alternate"
|| $link->getAttribute("rel") == "standout")) {
@@ -142,12 +143,11 @@ class FeedItem_RSS extends FeedItem_Common {
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
$enc->type = clean($enclosure->getAttribute("type"));
$enc->link = clean($enclosure->getAttribute("url"));
$enc->length = clean($enclosure->getAttribute("length"));
$enc->height = clean($enclosure->getAttribute("height"));
$enc->width = clean($enclosure->getAttribute("width"));
$enc->type = clean($enclosure->getAttribute('type'));
$enc->link = clean($enclosure->getAttribute('url'));
$enc->length = clean($enclosure->getAttribute('length'));
$enc->height = clean($enclosure->getAttribute('height'));
$enc->width = clean($enclosure->getAttribute('width'));
array_push($encs, $enc);
}

View File

@@ -1,30 +1,25 @@
<?php
class FeedParser {
private DOMDocument $doc;
/** @var DOMDocument */
private $doc;
/** @var string|null */
private $error = null;
private ?string $error = null;
/** @var array<string> */
private $libxml_errors = [];
private array $libxml_errors = [];
/** @var array<FeedItem> */
private $items = [];
private array $items = [];
/** @var string|null */
private $link;
private ?string $link = null;
/** @var string|null */
private $title;
private ?string $title = null;
/** @var FeedParser::FEED_*|null */
private $type;
/** @var FeedParser::FEED_* */
private int $type;
/** @var DOMXPath|null */
private $xpath;
private ?DOMXPath $xpath = null;
const FEED_UNKNOWN = -1;
const FEED_RDF = 0;
const FEED_RSS = 1;
const FEED_ATOM = 2;
@@ -32,6 +27,9 @@ class FeedParser {
function __construct(string $data) {
libxml_use_internal_errors(true);
libxml_clear_errors();
$this->type = $this::FEED_UNKNOWN;
$this->doc = new DOMDocument();
$this->doc->loadXML($data);
@@ -48,75 +46,54 @@ class FeedParser {
}
}
}
libxml_clear_errors();
if ($this->error)
return;
$this->xpath = new DOMXPath($this->doc);
$this->xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
$this->xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#');
$this->xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/');
$this->xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
$this->xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/');
$this->xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
$this->xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/');
$this->xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0');
}
function init() : void {
$xpath = new DOMXPath($this->doc);
$xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
$xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#');
$xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/');
$xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
$xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/');
$xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
$xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/');
$xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0');
/**
* @return bool false if initialization couldn't occur (e.g. parsing error or unrecognized feed type), otherwise true
*/
function init(): bool {
if ($this->error)
return false;
$this->xpath = $xpath;
$type = $this->get_type();
$root_list = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)");
if ($type === self::FEED_UNKNOWN)
return false;
if (!empty($root_list) && $root_list->length > 0) {
$xpath = $this->xpath;
/** @var DOMElement|null $root */
$root = $root_list->item(0);
if ($root) {
switch (mb_strtolower($root->tagName)) {
case "rdf:rdf":
$this->type = $this::FEED_RDF;
break;
case "channel":
$this->type = $this::FEED_RSS;
break;
case "feed":
case "atom:feed":
$this->type = $this::FEED_ATOM;
break;
default:
$this->error ??= "Unknown/unsupported feed type";
return;
}
}
switch ($this->type) {
switch ($type) {
case $this::FEED_ATOM:
$title = $xpath->query("//atom:feed/atom:title")->item(0);
if (!$title)
$title = $xpath->query("//atom03:feed/atom03:title")->item(0);
$title = $xpath->query('//atom:feed/atom:title')->item(0)
?? $xpath->query('//atom03:feed/atom03:title')->item(0);
if ($title) {
$this->title = $title->nodeValue;
}
$link = $xpath->query("//atom:feed/atom:link[not(@rel)]")->item(0);
if (!$link)
$link = $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0);
if (!$link)
$link = $xpath->query("//atom03:feed/atom03:link[not(@rel)]")->item(0);
if (!$link)
$link = $xpath->query("//atom03:feed/atom03:link[@rel='alternate']")->item(0);
/** @var DOMElement|null $link */
if ($link && $link->hasAttributes()) {
$this->link = $link->getAttribute("href");
}
$link = $xpath->query('//atom:feed/atom:link[not(@rel)]')->item(0)
?? $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0)
?? $xpath->query('//atom03:feed/atom03:link[not(@rel)]')->item(0)
?? $xpath->query("//atom03:feed/atom03:link[@rel='alternate']")->item(0);
if ($link?->getAttribute('href'))
$this->link = $link->getAttribute('href');
$articles = $xpath->query("//atom:entry");
@@ -174,16 +151,15 @@ class FeedParser {
}
break;
}
if ($this->title) $this->title = trim($this->title);
if ($this->link) $this->link = trim($this->link);
} else {
$this->error ??= "Unknown/unsupported feed type";
return;
}
if ($this->title)
$this->title = trim($this->title);
if ($this->link)
$this->link = trim($this->link);
return true;
}
/** @deprecated use Errors::format_libxml_error() instead */
@@ -201,6 +177,33 @@ class FeedParser {
return $this->libxml_errors;
}
/**
* @return FeedParser::FEED_*
*/
function get_type(): int {
if ($this->type !== self::FEED_UNKNOWN || $this->error)
return $this->type;
$root_list = $this->xpath->query('(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)');
if ($root_list && $root_list->length > 0) {
/** @var DOMElement $root */
$root = $root_list->item(0);
$this->type = match (mb_strtolower($root->tagName)) {
'rdf:rdf' => self::FEED_RDF,
'channel' => self::FEED_RSS,
'feed', 'atom:feed' => self::FEED_ATOM,
default => self::FEED_UNKNOWN,
};
}
if ($this->type === self::FEED_UNKNOWN)
$this->error ??= 'Unknown/unsupported feed type';
return $this->type;
}
function get_link() : string {
return clean($this->link ?? '');
}
@@ -222,6 +225,7 @@ class FeedParser {
case $this::FEED_ATOM:
$links = $this->xpath->query("//atom:feed/atom:link");
/** @var DOMElement $link */
foreach ($links as $link) {
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
array_push($rv, clean(trim($link->getAttribute('href'))));
@@ -231,6 +235,7 @@ class FeedParser {
case $this::FEED_RSS:
$links = $this->xpath->query("//atom:link");
/** @var DOMElement $link */
foreach ($links as $link) {
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
array_push($rv, clean(trim($link->getAttribute('href'))));

File diff suppressed because it is too large Load Diff

View File

@@ -8,23 +8,27 @@ class Handler_Public extends Handler {
int $limit, int $offset, string $search, string $view_mode = "",
string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = ""): void {
$note_style = "background-color : #fff7d5;
border-width : 1px; ".
"padding : 5px; border-style : dashed; border-color : #e7d796;".
"margin-bottom : 1em; color : #9a8c59;";
// fail early if the requested format isn't recognized
if (!in_array($format, ['atom', 'json'])) {
header('Content-Type: text/plain; charset=utf-8');
print "Unknown format: $format.";
return;
}
if (!$limit) $limit = 60;
$note_style = 'color: #9a8c59; background-color: #fff7d5; '
. 'border: 1px dashed #e7d796; padding: 5px; margin-bottom: 1em;';
if (!$limit)
$limit = 60;
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query($order);
if (!$override_order) {
$override_order = "date_entered DESC, updated DESC";
if ($feed == Feeds::FEED_PUBLISHED && !$is_cat) {
$override_order = "last_published DESC";
} else if ($feed == Feeds::FEED_STARRED && !$is_cat) {
$override_order = "last_marked DESC";
}
$override_order = match (true) {
$feed == Feeds::FEED_PUBLISHED && !$is_cat => 'last_published DESC',
$feed == Feeds::FEED_STARRED && !$is_cat => 'last_marked DESC',
default => 'date_entered DESC, updated DESC',
};
}
$params = array(
@@ -42,8 +46,9 @@ class Handler_Public extends Handler {
);
if (!$is_cat && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) {
$user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
// TODO: _ENABLED_PLUGINS is profile-specific, so use of the default profile's plugins here should
// be called out in the docs, and/or access key stuff (see 'rss()') should also consider the profile
$user_plugins = Prefs::get(Prefs::_ENABLED_PLUGINS, $owner_uid);
$tmppluginhost = new PluginHost();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
@@ -54,8 +59,6 @@ class Handler_Public extends Handler {
PluginHost::feed_to_pfeed_id((int)$feed));
if ($handler) {
// 'get_headlines' is implemented by the plugin.
// @phpstan-ignore-next-line
$qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id((int)$feed), $params);
} else {
user_error("Failed to find handler for plugin feed ID: $feed", E_USER_ERROR);
@@ -76,7 +79,8 @@ class Handler_Public extends Handler {
"/public.php?op=rss&id=$feed&key=" .
Feeds::_get_access_key($feed, false, $owner_uid);
if (!$feed_site_url) $feed_site_url = Config::get_self_url();
if (!$feed_site_url)
$feed_site_url = Config::get_self_url();
if ($format == 'atom') {
$tpl = new Templator();
@@ -84,10 +88,12 @@ class Handler_Public extends Handler {
$tpl->readTemplateFromFile("generated_feed.txt");
$tpl->setVariable('FEED_TITLE', $feed_title, true);
$tpl->setVariable('FEED_UPDATED', date('c'), true);
$tpl->setVariable('VERSION', Config::get_version(), true);
$tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true);
$tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true);
while ($line = $result->fetch()) {
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
@@ -120,8 +126,7 @@ class Handler_Public extends Handler {
$content = DiskCache::rewrite_urls($content);
if ($line['note']) {
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" .
$content;
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" . $content;
$tpl->setVariable('ARTICLE_NOTE', htmlspecialchars($line['note']), true);
}
@@ -148,7 +153,7 @@ class Handler_Public extends Handler {
foreach ($enclosures as $e) {
$type = htmlspecialchars($e['content_type']);
$url = htmlspecialchars($e['content_url']);
$length = $e['duration'] ? $e['duration'] : 1;
$length = $e['duration'] ?: 1;
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', $url, true);
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', $type, true);
@@ -181,14 +186,14 @@ class Handler_Public extends Handler {
}
print $tmp;
} else if ($format == 'json') {
} else { // $format == 'json'
$feed = array();
$feed['title'] = $feed_title;
$feed['feed_url'] = $feed_self_url;
$feed['self_url'] = Config::get_self_url();
$feed['articles'] = [];
$feed = [
'title' => $feed_title,
'feed_url' => $feed_self_url,
'self_url' => Config::get_self_url(),
'articles' => [],
];
while ($line = $result->fetch()) {
@@ -207,30 +212,26 @@ class Handler_Public extends Handler {
},
$line, $feed, $is_cat, $owner_uid);
$article = array();
$article['id'] = $line['link'];
$article['link'] = $line['link'];
$article['title'] = $line['title'];
$article['excerpt'] = $line["content_preview"];
$article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, null, $line["id"]);
$article['updated'] = date('c', strtotime($line["updated"] ?? ''));
if (!empty($line['note'])) $article['note'] = $line['note'];
if (!empty($line['author'])) $article['author'] = $line['author'];
$article['source'] = [
'link' => $line['site_url'] ? $line["site_url"] : Config::get_self_url(),
'title' => $line['feed_title'] ?? $feed_title
$article = [
'id' => $line['link'],
'link' => $line['link'],
'title' => $line['title'],
'content' => Sanitizer::sanitize($line['content'], false, $owner_uid, $feed_site_url, null, $line['id']),
'updated' => date('c', strtotime($line['updated'] ?? '')),
'source' => [
'link' => $line['site_url'] ?: Config::get_self_url(),
'title' => $line['feed_title'] ?? $feed_title,
],
];
if (count($line["tags"]) > 0) {
$article['tags'] = array();
if (!empty($line['note']))
$article['note'] = $line['note'];
foreach ($line["tags"] as $tag) {
array_push($article['tags'], $tag);
}
}
if (!empty($line['author']))
$article['author'] = $line['author'];
if (count($line['tags']) > 0)
$article['tags'] = $line['tags'];
$enclosures = Article::_get_enclosures($line["id"]);
@@ -238,23 +239,20 @@ class Handler_Public extends Handler {
$article['enclosures'] = array();
foreach ($enclosures as $e) {
$type = $e['content_type'];
$url = $e['content_url'];
$length = $e['duration'];
array_push($article['enclosures'], array("url" => $url, "type" => $type, "length" => $length));
$article['enclosures'][] = [
'url' => $e['content_url'],
'type' => $e['content_type'],
'length' => $e['duration'],
];
}
}
array_push($feed['articles'], $article);
}
header("Content-Type: text/json; charset=utf-8");
header("Content-Type: application/json; charset=utf-8");
print json_encode($feed);
} else {
header("Content-Type: text/plain; charset=utf-8");
print "Unknown format: $format.";
}
}
@@ -321,7 +319,7 @@ class Handler_Public extends Handler {
header("Location: " . $redirect_url);
} else {
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
}
@@ -361,20 +359,6 @@ class Handler_Public extends Handler {
header('HTTP/1.1 403 Forbidden');
}
function updateTask(): void {
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
}
function housekeepingTask(): void {
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING);
}
function globalUpdateFeeds(): void {
RPC::updaterandomfeed_real();
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
}
function login(): void {
if (!Config::get(Config::SINGLE_USER_MODE)) {
@@ -432,6 +416,13 @@ class Handler_Public extends Handler {
}
function forgotpass(): void {
if (Config::get(Config::DISABLE_LOGIN_FORM) || !str_contains(Config::get(Config::PLUGINS), "auth_internal")) {
header($_SERVER["SERVER_PROTOCOL"]." 403 Forbidden");
echo "Forbidden.";
return;
}
startup_gettext();
session_start();
@@ -447,15 +438,23 @@ class Handler_Public extends Handler {
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<?php
echo stylesheet_tag("themes/light.css");
echo javascript_tag("lib/dojo/dojo.js");
echo javascript_tag("lib/dojo/tt-rss-layer.js");
echo javascript_tag("js/common.js");
echo javascript_tag("js/utility.js");
?>
<?= Config::get_override_links() ?>
</head>
<body class='flat ttrss_utility'>
<body class='flat ttrss_utility css_loading'>
<div class='container'>
<script type="text/javascript">
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
const __default_light_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_LIGHT_THEME), 'themes/light.css') ?>";
const __default_dark_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_DARK_THEME), 'themes/night.css') ?>";
</script>
<script type="text/javascript">
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
@@ -464,6 +463,19 @@ class Handler_Public extends Handler {
});
});
</script>
<style type="text/css">
@media (prefers-color-scheme: dark) {
body {
background : #303030;
}
}
body.css_loading * {
display : none;
}
</style>
<?php
print "<h1>".__("Password recovery")."</h1>";
@@ -763,6 +775,11 @@ class Handler_Public extends Handler {
$cache = DiskCache::instance($cache_dir);
if ($cache->exists($filename)) {
$size = $cache->get_size($filename);
if ($size && $size > 0)
header("Content-Length: $size");
$cache->send($filename);
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
@@ -809,17 +826,17 @@ class Handler_Public extends Handler {
$plugin->$method();
} else {
user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
} else {
user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: text/json");
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]);
}
}

View File

@@ -197,7 +197,7 @@ class Labels
/**
* @return false|int false if the check for an existing label failed, otherwise the number of rows inserted (1 on success)
*/
static function create(string $caption, ?string $fg_color = '', ?string $bg_color = '', ?int $owner_uid = null) {
static function create(string $caption, ?string $fg_color = '', ?string $bg_color = '', ?int $owner_uid = null): false|int {
if (!$owner_uid) $owner_uid = $_SESSION['uid'];

View File

@@ -1,10 +1,8 @@
<?php
class Logger {
/** @var Logger|null */
private static $instance;
private static ?Logger $instance = null;
/** @var Logger_Adapter|null */
private $adapter;
private ?Logger_Adapter $adapter = null;
const LOG_DEST_SQL = "sql";
const LOG_DEST_STDOUT = "stdout";
@@ -57,19 +55,12 @@ class Logger {
}
function __construct() {
switch (Config::get(Config::LOG_DESTINATION)) {
case self::LOG_DEST_SQL:
$this->adapter = new Logger_SQL();
break;
case self::LOG_DEST_SYSLOG:
$this->adapter = new Logger_Syslog();
break;
case self::LOG_DEST_STDOUT:
$this->adapter = new Logger_Stdout();
break;
default:
$this->adapter = null;
}
$this->adapter = match (Config::get(Config::LOG_DESTINATION)) {
self::LOG_DEST_SQL => new Logger_SQL(),
self::LOG_DEST_SYSLOG => new Logger_Syslog(),
self::LOG_DEST_STDOUT => new Logger_Stdout(),
default => null,
};
if ($this->adapter && !implements_interface($this->adapter, "Logger_Adapter"))
user_error("Adapter for LOG_DESTINATION: " . Config::LOG_DESTINATION . " does not implement required interface.", E_USER_ERROR);

View File

@@ -6,7 +6,7 @@ class Mailer {
* @param array<string, mixed> $params
* @return bool|int bool if the default mail function handled the request, otherwise an int as described in Mailer#mail()
*/
function mail(array $params) {
function mail(array $params): bool|int {
$to_name = $params["to_name"] ?? "";
$to_address = $params["to_address"];

View File

@@ -8,9 +8,9 @@ class OPML extends Handler_Protected {
}
/**
* @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void if $owner_uid is missing
* @return bool|int|null false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or null if $owner_uid is missing
*/
function export() {
function export(): bool|int|null {
$output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d"));
$include_settings = $_REQUEST["include_settings"] == "1";
$owner_uid = $_SESSION["uid"];
@@ -20,33 +20,6 @@ class OPML extends Handler_Protected {
return $rc;
}
function import(): void {
$owner_uid = $_SESSION["uid"];
header('Content-Type: text/html; charset=utf-8');
print "<html>
<head>
".stylesheet_tag("themes/light.css")."
<title>".__("OPML Utility")."</title>
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>
</head>
<body class='claro ttrss_utility'>
<h1>".__('OPML Utility')."</h1><div class='content'>";
Feeds::_add_cat("Imported feeds", $owner_uid);
$this->opml_notice(__("Importing OPML..."));
$this->opml_import($owner_uid);
print "<br><form method=\"GET\" action=\"prefs.php\">
<input type=\"submit\" value=\"".__("Return to preferences")."\">
</form>";
print "</div></body></html>";
}
// Export
private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true): string {
@@ -126,10 +99,10 @@ class OPML extends Handler_Protected {
}
/**
* @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void if $owner_uid is missing
* @return bool|int|null false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or null if $owner_uid is missing
*/
function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false) {
if (!$owner_uid) return;
function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false): bool|int|null {
if (!$owner_uid) return null;
if (!$file_output)
if (!isset($_REQUEST["debug"])) {
@@ -209,6 +182,7 @@ class OPML extends Handler_Protected {
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
$tmp_line["feed"] = Feeds::_get_title(
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
$owner_uid,
$cat_filter);
} else {
$tmp_line["feed"] = "";
@@ -217,16 +191,16 @@ class OPML extends Handler_Protected {
$match = [];
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
if (strpos($feed_id, "CAT:") === 0) {
if (str_starts_with($feed_id, "CAT:")) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::_get_cat_title($feed_id), true, false]);
array_push($match, [Feeds::_get_cat_title($feed_id, $owner_uid), true, false]);
} else {
array_push($match, [0, true, true]);
}
} else {
if ($feed_id) {
array_push($match, [Feeds::_get_title((int)$feed_id), false, false]);
array_push($match, [Feeds::_get_title((int)$feed_id, $owner_uid), false, false]);
} else {
array_push($match, [0, false, true]);
}
@@ -363,6 +337,9 @@ class OPML extends Handler_Protected {
}
}
/**
* @todo support passing in $profile so 'update.php --opml-import' can import prefs to a user profile
*/
private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest): void {
$attrs = $node->attributes;
$pref_name = $attrs->getNamedItem('pref-name')->nodeValue;
@@ -373,7 +350,7 @@ class OPML extends Handler_Protected {
$this->opml_notice(T_sprintf("Setting preference key %s to %s",
$pref_name, $pref_value), $nest);
set_pref($pref_name, $pref_value, $owner_uid);
Prefs::set($pref_name, $pref_value, $owner_uid, $_SESSION['profile'] ?? null);
}
}
@@ -394,14 +371,10 @@ class OPML extends Handler_Protected {
//print "F: $title, $inverse, $enabled, $match_any_rule";
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2 (match_any_rule,enabled,inverse,title,owner_uid)
VALUES (?, ?, ?, ?, ?)");
VALUES (?, ?, ?, ?, ?) RETURNING id");
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $owner_uid]);
$sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE
owner_uid = ?");
$sth->execute([$owner_uid]);
$row = $sth->fetch();
$filter_id = $row['id'];
@@ -587,19 +560,12 @@ class OPML extends Handler_Protected {
$dst_cat_id = $cat_id;
}
switch ($cat_title) {
case "tt-rss-prefs":
$this->opml_import_preference($node, $owner_uid, $nest+1);
break;
case "tt-rss-labels":
$this->opml_import_label($node, $owner_uid, $nest+1);
break;
case "tt-rss-filters":
$this->opml_import_filter($node, $owner_uid, $nest+1);
break;
default:
$this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1);
}
match ($cat_title) {
'tt-rss-prefs' => $this->opml_import_preference($node, $owner_uid, $nest+1),
'tt-rss-labels' => $this->opml_import_label($node, $owner_uid, $nest+1),
'tt-rss-filters' => $this->opml_import_filter($node, $owner_uid, $nest+1),
default => $this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1),
};
}
}
}
@@ -607,10 +573,10 @@ class OPML extends Handler_Protected {
/** $filename is optional; assumes HTTP upload with $_FILES otherwise */
/**
* @return bool|void false on failure, true if successful, void if $owner_uid is missing
* @return bool|null false on failure, true if successful, null if $owner_uid is missing
*/
function opml_import(int $owner_uid, string $filename = "") {
if (!$owner_uid) return;
function opml_import(int $owner_uid, string $filename = ""): ?bool {
if (!$owner_uid) return null;
if (!$filename) {
if ($_FILES['opml_file']['error'] != 0) {
@@ -644,16 +610,8 @@ class OPML extends Handler_Protected {
$doc = new DOMDocument();
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(false);
}
$loaded = $doc->load($tmp_file);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
}
// only remove temporary i.e. HTTP uploaded files
if (!$filename)
unlink($tmp_file);

View File

@@ -76,7 +76,7 @@ abstract class Plugin {
*
* @return string */
function __($msgid) {
/** @var Plugin $this -- this is a strictly template-related hack */
// this is a strictly template-related hack
return _dgettext(PluginHost::object_to_domain($this), $msgid);
}
@@ -87,7 +87,7 @@ abstract class Plugin {
*
* @return string */
function _ngettext($singular, $plural, $number) {
/** @var Plugin $this -- this is a strictly template-related hack */
// this is a strictly template-related hack
return _dngettext(PluginHost::object_to_domain($this), $singular, $plural, $number);
}
@@ -557,18 +557,18 @@ abstract class Plugin {
return -1;
}
/** Invoked when filter is triggered on an article, may be used to implement logging for filters
* NOTE: $article_filters should be renamed $filter_actions because that's what this is
/**
* Invoked when filtering is triggered on an article. May be used to implement logging for filters, etc.
* @param int $feed_id
* @param int $owner_uid
* @param array<string,mixed> $article
* @param array<string,mixed> $matched_filters
* @param array<string,string|bool|int> $matched_rules
* @param array<string,string> $article_filters
* @param array<int, array{'type': string, 'param': string}> $article_filter_actions An array of filter actions from matched filters
* @return void
* @see PluginHost::HOOK_FILTER_TRIGGERED
*/
function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) {
function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filter_actions) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
@@ -713,4 +713,26 @@ abstract class Plugin {
return false;
}
/** Invoked after passed article IDs were either marked (i.e. starred) or unmarked.
*
* **Note** resulting state of the articles is not passed to this function (because
* tt-rss may do invert operation on ID range), you will need to get this from the database.
* @param array<int> $article_ids ref_ids
* @return void
*/
function hook_articles_mark_toggled(array $article_ids) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Invoked after passed article IDs were either published or unpublished.
*
* **Note** resulting state of the articles is not passed to this function (because
* tt-rss may do invert operation on ID range), you will need to get this from the database.
*
* @param array<int> $article_ids ref_ids
* @return void
*/
function hook_articles_publish_toggled(array $article_ids) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
}

View File

@@ -23,8 +23,8 @@ class PluginHost {
/** @var array<string, array<string, mixed>> plugin name -> (potential profile array) -> key -> value */
private array $storage = [];
/** @var array<int, array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>> */
private array $feeds = [];
/** @var array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}> */
private array $special_feeds = [];
/** @var array<string, Plugin> API method name, Plugin sender */
private array $api_methods = [];
@@ -38,6 +38,8 @@ class PluginHost {
private static ?PluginHost $instance = null;
private ?Scheduler $scheduler = null;
const API_VERSION = 2;
const PUBLIC_METHOD_DELIMITER = "--";
@@ -143,9 +145,6 @@ class PluginHost {
/** @see Plugin::hook_format_article() */
const HOOK_FORMAT_ARTICLE = "hook_format_article";
/** @see Plugin::hook_format_article_cdm() */
const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm";
/** @see Plugin::hook_feed_basic_info() */
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info";
@@ -202,6 +201,12 @@ class PluginHost {
/** @see Plugin::hook_validate_session() */
const HOOK_VALIDATE_SESSION = "hook_validate_session";
/** @see Plugin::hook_articles_mark_toggled() */
const HOOK_ARTICLES_MARK_TOGGLED = "hook_articles_mark_toggled";
/** @see Plugin::hook_articles_publish_toggled() */
const HOOK_ARTICLES_PUBLISH_TOGGLED = "hook_articles_publish_toggled";
const KIND_ALL = 1;
const KIND_SYSTEM = 2;
const KIND_USER = 3;
@@ -212,6 +217,7 @@ class PluginHost {
function __construct() {
$this->pdo = Db::pdo();
$this->scheduler = new Scheduler('PluginHost Scheduler');
$this->storage = [];
}
@@ -342,16 +348,8 @@ class PluginHost {
*/
function chain_hooks_callback(string $hook, Closure $callback, &...$args): void {
$method = strtolower((string)$hook);
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("chain_hooks_callback: $hook");
foreach ($this->get_hooks((string)$hook) as $plugin) {
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
//$p_span = Tracer::start("$hook - " . get_class($plugin));
$span->addEvent("$hook - " . get_class($plugin));
try {
if ($callback($plugin->$method(...$args), $plugin))
break;
@@ -360,11 +358,7 @@ class PluginHost {
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
//$p_span->end();
}
//$span->end();
}
/**
@@ -398,7 +392,7 @@ class PluginHost {
* @param PluginHost::HOOK_* $type
*/
function del_hook(string $type, Plugin $sender): void {
if (is_array($this->hooks[$type])) {
if (array_key_exists($type, $this->hooks)) {
foreach (array_keys($this->hooks[$type]) as $prio) {
$key = array_search($sender, $this->hooks[$type][$prio]);
@@ -430,9 +424,6 @@ class PluginHost {
* @param PluginHost::KIND_* $kind
*/
function load_all(int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
$plugins = [...(glob("plugins/*") ?: []), ...(glob("plugins.local/*") ?: [])];
$plugins = array_filter($plugins, "is_dir");
$plugins = array_map("basename", $plugins);
@@ -440,27 +431,24 @@ class PluginHost {
asort($plugins);
$this->load(join(",", $plugins), (int)$kind, $owner_uid, $skip_init);
$span->end();
}
/**
* @param PluginHost::KIND_* $kind
*/
function load(string $classlist, int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
$plugins = explode(",", $classlist);
$this->owner_uid = (int) $owner_uid;
if ($this->owner_uid) {
$this->set_scheduler_name("PluginHost Scheduler for UID $owner_uid");
}
foreach ($plugins as $class) {
$class = trim($class);
$class_file = strtolower(basename(clean($class)));
$span->addEvent("$class_file: load");
// try system plugin directory first
$file = Config::get_self_dir() . "/plugins/$class_file/init.php";
@@ -485,8 +473,6 @@ class PluginHost {
}
$_SESSION["safe_mode"] = 1;
$span->setAttribute('error', 'plugin is blacklisted');
continue;
}
@@ -497,8 +483,6 @@ class PluginHost {
} catch (Error $err) {
user_error($err, E_USER_WARNING);
$span->setAttribute('error', $err);
continue;
}
@@ -508,8 +492,6 @@ class PluginHost {
if ($plugin_api < self::API_VERSION) {
user_error("Plugin $class is not compatible with current API version (need: " . self::API_VERSION . ", got: $plugin_api)", E_USER_WARNING);
$span->setAttribute('error', 'plugin is not compatible with API version');
continue;
}
@@ -518,8 +500,6 @@ class PluginHost {
_bind_textdomain_codeset($class, "UTF-8");
}
$span->addEvent("$class_file: initialize");
try {
switch ($kind) {
case $this::KIND_SYSTEM:
@@ -549,7 +529,6 @@ class PluginHost {
}
$this->load_data();
$span->end();
}
function is_system(Plugin $plugin): bool {
@@ -581,7 +560,7 @@ class PluginHost {
/**
* @return false|Plugin false if the handler couldn't be found, otherwise the Plugin/handler
*/
function lookup_handler(string $handler, string $method) {
function lookup_handler(string $handler, string $method): false|Plugin {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
@@ -610,7 +589,7 @@ class PluginHost {
/**
* @return false|Plugin false if the command couldn't be found, otherwise the registered Plugin
*/
function lookup_command(string $command) {
function lookup_command(string $command): false|Plugin {
$command = "-" . strtolower($command);
if (array_key_exists($command, $this->commands)) {
@@ -620,7 +599,7 @@ class PluginHost {
}
}
/** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}>> command type -> details array */
/** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */
function get_commands() {
return $this->commands;
}
@@ -638,17 +617,12 @@ class PluginHost {
}
private function load_data(): void {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent('load plugin data');
if ($this->owner_uid && !$this->data_loaded && Config::get_schema_version() > 100) {
$sth = $this->pdo->prepare("SELECT name, content FROM ttrss_plugin_storage
WHERE owner_uid = ?");
$sth->execute([$this->owner_uid]);
while ($line = $sth->fetch()) {
$span->addEvent($line["name"] . ': unserialize');
$this->storage[$line["name"]] = unserialize($line["content"]);
}
@@ -658,9 +632,6 @@ class PluginHost {
private function save_data(string $plugin): void {
if ($this->owner_uid) {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent(__METHOD__ . ": $plugin");
if (!$this->pdo_data)
$this->pdo_data = Db::instance()->pdo_connect();
@@ -769,7 +740,7 @@ class PluginHost {
* @param array<int|string, mixed> $default_value
* @return array<int|string, mixed>
*/
function get_array(Plugin $sender, string $name, array $default_value = []) {
function get_array(Plugin $sender, string $name, array $default_value = []): array {
$tmp = $this->get($sender, $name);
if (!is_array($tmp)) $tmp = $default_value;
@@ -798,36 +769,51 @@ class PluginHost {
}
}
// Plugin feed functions are *EXPERIMENTAL*!
/**
* Add a special (plugin-provided) feed
*
* @param int $cat_id only -1 (Feeds::CATEGORY_SPECIAL) is supported
* @return false|int false if the feed wasn't added (e.g. $cat_id wasn't Feeds::CATEGORY_SPECIAL),
* otherwise an integer "feed ID" that might change between executions
*/
function add_feed(int $cat_id, string $title, string $icon, Plugin $sender): false|int {
if ($cat_id !== Feeds::CATEGORY_SPECIAL)
return false;
// cat_id: only -1 (Feeds::CATEGORY_SPECIAL) is supported (Special)
function add_feed(int $cat_id, string $title, string $icon, Plugin $sender): int {
$id = count($this->special_feeds);
if (empty($this->feeds[$cat_id]))
$this->feeds[$cat_id] = [];
$id = count($this->feeds[$cat_id]);
array_push($this->feeds[$cat_id],
['id' => $id, 'title' => $title, 'sender' => $sender, 'icon' => $icon]);
$this->special_feeds[] = [
'id' => $id,
'title' => $title,
'sender' => $sender,
'icon' => $icon,
];
return $id;
}
/**
* Get special (plugin-provided) feeds
*
* @param int $cat_id only -1 (Feeds::CATEGORY_SPECIAL) is supported
* @return array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>
*/
function get_feeds(int $cat_id) {
return $this->feeds[$cat_id] ?? [];
return $cat_id === Feeds::CATEGORY_SPECIAL ? $this->special_feeds : [];
}
// convert feed_id (e.g. -129) to pfeed_id first
/**
* Get the Plugin handling a specific virtual feed.
*
* Convert feed_id (e.g. -129) to pfeed_id first.
*
* @return (Plugin&IVirtualFeed)|null a Plugin that implements IVirtualFeed, otherwise null
*/
function get_feed_handler(int $pfeed_id): ?Plugin {
foreach ($this->feeds as $cat) {
foreach ($cat as $feed) {
if ($feed['id'] == $pfeed_id) {
return $feed['sender'];
}
foreach ($this->special_feeds as $feed) {
if ($feed['id'] == $pfeed_id) {
/** @var Plugin&IVirtualFeed $feed['sender'] */
return implements_interface($feed['sender'], 'IVirtualFeed') ? $feed['sender'] : null;
}
}
return null;
@@ -881,14 +867,12 @@ class PluginHost {
*/
function get_method_url(Plugin $sender, string $method, array $params = []): string {
return Config::get_self_url() . "/backend.php?" .
http_build_query(
array_merge(
[
"op" => "pluginhandler",
"plugin" => strtolower(get_class($sender)),
"method" => $method
],
$params));
http_build_query([
'op' => 'pluginhandler',
'plugin' => strtolower(get_class($sender)),
'method' => $method,
...$params,
]);
}
// shortcut syntax (disabled for now)
@@ -910,12 +894,10 @@ class PluginHost {
function get_public_method_url(Plugin $sender, string $method, array $params = []): ?string {
if ($sender->is_public_method($method)) {
return Config::get_self_url() . "/public.php?" .
http_build_query(
array_merge(
[
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
],
$params));
http_build_query([
'op' => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
...$params,
]);
}
user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private.");
return null;
@@ -931,4 +913,31 @@ class PluginHost {
$ref = new ReflectionClass(get_class($plugin));
return basename(dirname(dirname($ref->getFileName()))) == "plugins.local";
}
/**
* This exposes sheduled tasks functionality to plugins. For system plugins, tasks registered here are
* executed (if due) during housekeeping. For user plugins, tasks are only run after any feeds owned by
* the user have been processed in an update batch (which means user is not inactive).
*
* This behaviour mirrors that of `HOOK_HOUSE_KEEPING` for user plugins.
*
* @see Scheduler::add_scheduled_task()
* @see Plugin::hook_house_keeping()
*/
function add_scheduled_task(Plugin $sender, string $task_name, string $cron_expression, Closure $callback): bool {
if ($this->is_system($sender))
$task_name = get_class($sender) . ':' . $task_name;
else
$task_name = get_class($sender) . ':' . $task_name . ':' . $this->owner_uid;
return $this->scheduler->add_scheduled_task($task_name, $cron_expression, $callback);
}
function run_due_tasks() : void {
$this->scheduler->run_due_tasks();
}
private function set_scheduler_name(string $name) : void {
$this->scheduler->set_name($name);
}
}

View File

@@ -15,12 +15,8 @@ class Pref_Feeds extends Handler_Protected {
* @return array<int, string>
*/
public static function get_ts_languages(): array {
if (Config::get(Config::DB_TYPE) == 'pgsql') {
return array_map('ucfirst',
array_column(ORM::for_table('pg_ts_config')->select('cfgname')->find_array(), 'cfgname'));
}
return [];
return array_map('ucfirst',
array_column(ORM::for_table('pg_ts_config')->select('cfgname')->find_array(), 'cfgname'));
}
function renameCat(): void {
@@ -76,11 +72,15 @@ class Pref_Feeds extends Handler_Protected {
}
$feeds_obj = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->left_outer_join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->select_expr('COUNT(ue.int_id) AS num_articles')
->where(['cat_id' => $cat_id, 'owner_uid' => $_SESSION['uid']])
->order_by_asc('order_id')
->order_by_asc('title');
->order_by_asc('title')
->group_by('id');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
@@ -96,7 +96,10 @@ class Pref_Feeds extends Handler_Protected {
'unread' => -1,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'param' => T_sprintf(
_ngettext("(%d article / %s)", "(%d articles / %s)", $feed->num_articles),
$feed->num_articles,
TimeHelper::make_local_datetime($feed->last_updated)),
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
@@ -112,6 +115,7 @@ class Pref_Feeds extends Handler_Protected {
* @return array<string, array<int|string, mixed>|string>
*/
function _makefeedtree(): array {
$profile = $_SESSION['profile'] ?? null;
if (clean($_REQUEST['mode'] ?? 0) != 2)
$search = $_SESSION["prefs_feed_search"] ?? "";
@@ -125,7 +129,7 @@ class Pref_Feeds extends Handler_Protected {
$root['param'] = 0;
$root['type'] = 'category';
$enable_cats = get_pref(Prefs::ENABLE_FEED_CATS);
$enable_cats = Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $profile);
if (clean($_REQUEST['mode'] ?? 0) == 2) {
@@ -175,7 +179,7 @@ class Pref_Feeds extends Handler_Protected {
ttrss_labels2 WHERE owner_uid = ? ORDER by caption");
$sth->execute([$_SESSION['uid']]);
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $profile)) {
$cat = $this->feedlist_init_cat(Feeds::CATEGORY_LABELS);
} else {
$cat['items'] = [];
@@ -240,8 +244,7 @@ class Pref_Feeds extends Handler_Protected {
/**
* Uncategorized is a special case.
*
* Define a minimal array shape to help PHPStan with the type of $cat['items']
* @var array{items: array<int, array<string, mixed>>} $cat
* @var array{id: string, bare_id: int, auxcounter: int, name: string, items: array<int, array<string, mixed>>, type: 'category', checkbox: bool, unread: int, child_unread: int}
*/
$cat = [
'id' => 'CAT:0',
@@ -256,12 +259,16 @@ class Pref_Feeds extends Handler_Protected {
];
$feeds_obj = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->left_outer_join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->select_expr('COUNT(ue.int_id) AS num_articles')
->where('owner_uid', $_SESSION['uid'])
->where_null('cat_id')
->order_by_asc('order_id')
->order_by_asc('title');
->order_by_asc('title')
->group_by('id');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
@@ -276,7 +283,10 @@ class Pref_Feeds extends Handler_Protected {
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'param' => T_sprintf(
_ngettext("(%d article / %s)", "(%d articles / %s)", $feed->num_articles),
$feed->num_articles,
TimeHelper::make_local_datetime($feed->last_updated)),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
@@ -293,11 +303,15 @@ class Pref_Feeds extends Handler_Protected {
} else {
$feeds_obj = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->left_outer_join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->select_expr('COUNT(ue.int_id) AS num_articles')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('order_id')
->order_by_asc('title');
->order_by_asc('title')
->group_by('id');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
@@ -312,7 +326,10 @@ class Pref_Feeds extends Handler_Protected {
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'param' => T_sprintf(
_ngettext("(%d article / %s)", "(%d articles / %s)", $feed->num_articles),
$feed->num_articles,
TimeHelper::make_local_datetime($feed->last_updated)),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
@@ -385,7 +402,7 @@ class Pref_Feeds extends Handler_Protected {
if ($item['_reference']) {
if (strpos($id, "FEED") === 0) {
if (str_starts_with($id, "FEED")) {
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
@@ -396,7 +413,7 @@ class Pref_Feeds extends Handler_Protected {
$feed->cat_id = ($item_id != "root" && $bare_item_id) ? $bare_item_id : null;
$feed->save();
}
} else if (strpos($id, "CAT:") === 0) {
} else if (str_starts_with($id, "CAT:")) {
$this->process_category_order($data_map, $item['_reference'], $item_id,
$nest_level+1);
@@ -523,6 +540,8 @@ class Pref_Feeds extends Handler_Protected {
global $purge_intervals;
global $update_intervals;
$profile = $_SESSION['profile'] ?? null;
$feed_id = (int)clean($_REQUEST["id"]);
$row = ORM::for_table('ttrss_feeds')
@@ -537,13 +556,14 @@ class Pref_Feeds extends Handler_Protected {
ob_end_clean();
$row["icon"] = Feeds::_get_icon($feed_id);
$row["auth_pass"] = Feeds::decrypt_feed_pass($row["auth_pass"]);
$local_update_intervals = $update_intervals;
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[get_pref(Prefs::DEFAULT_UPDATE_INTERVAL)]);
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[Prefs::get(Prefs::DEFAULT_UPDATE_INTERVAL, $_SESSION['uid'])]);
if (Config::get(Config::FORCE_ARTICLE_PURGE) == 0) {
$local_purge_intervals = $purge_intervals;
$default_purge_interval = get_pref(Prefs::PURGE_OLD_DAYS);
$default_purge_interval = Prefs::get(Prefs::PURGE_OLD_DAYS, $_SESSION['uid']);
if ($default_purge_interval > 0)
$local_purge_intervals[0] .= " " . T_nsprintf('(%d day)', '(%d days)', $default_purge_interval, $default_purge_interval);
@@ -560,7 +580,7 @@ class Pref_Feeds extends Handler_Protected {
print json_encode([
"feed" => $row,
"cats" => [
"enabled" => get_pref(Prefs::ENABLE_FEED_CATS),
"enabled" => Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $profile),
"select" => \Controls\select_feeds_cats("cat_id", $row["cat_id"]),
],
"plugin_data" => $plugin_data,
@@ -573,8 +593,8 @@ class Pref_Feeds extends Handler_Protected {
"access_level" => $user->access_level
],
"lang" => [
"enabled" => Config::get(Config::DB_TYPE) == "pgsql",
"default" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE),
"enabled" => true,
"default" => Prefs::get(Prefs::DEFAULT_SEARCH_LANGUAGE, $_SESSION['uid'], $profile),
"all" => $this::get_ts_languages(),
]
]);
@@ -593,10 +613,10 @@ class Pref_Feeds extends Handler_Protected {
$feed_ids = clean($_REQUEST["ids"]);
$local_update_intervals = $update_intervals;
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[get_pref(Prefs::DEFAULT_UPDATE_INTERVAL)]);
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[Prefs::get(Prefs::DEFAULT_UPDATE_INTERVAL, $_SESSION['uid'])]);
$local_purge_intervals = $purge_intervals;
$default_purge_interval = get_pref(Prefs::PURGE_OLD_DAYS);
$default_purge_interval = Prefs::get(Prefs::PURGE_OLD_DAYS, $_SESSION['uid']);
if ($default_purge_interval > 0)
$local_purge_intervals[0] .= " " . T_sprintf("(%d days)", $default_purge_interval);
@@ -621,7 +641,7 @@ class Pref_Feeds extends Handler_Protected {
<div dojoType="dijit.layout.TabContainer" style="height : 450px">
<div dojoType="dijit.layout.ContentPane" title="<?= __('General') ?>">
<section>
<?php if (get_pref(Prefs::ENABLE_FEED_CATS)) { ?>
<?php if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) { ?>
<fieldset>
<label><?= __('Place in category:') ?></label>
<?= \Controls\select_feeds_cats("cat_id", null, ['disabled' => '1']) ?>
@@ -629,13 +649,11 @@ class Pref_Feeds extends Handler_Protected {
</fieldset>
<?php } ?>
<?php if (Config::get(Config::DB_TYPE) == "pgsql") { ?>
<fieldset>
<label><?= __('Language:') ?></label>
<?= \Controls\select_tag("feed_language", "", $this::get_ts_languages(), ["disabled"=> 1]) ?>
<?= $this->_batch_toggle_checkbox("feed_language") ?>
</fieldset>
<?php } ?>
</section>
<hr/>
@@ -723,6 +741,11 @@ class Pref_Feeds extends Handler_Protected {
$feed_language = clean($_POST["feed_language"] ?? "");
$key = Config::get(Config::ENCRYPTION_KEY);
if ($key && $auth_pass)
$auth_pass = base64_encode(serialize(Crypt::encrypt_string($auth_pass)));
if (!$batch) {
/* $sth = $this->pdo->prepare("SELECT feed_url FROM ttrss_feeds WHERE id = ?");
@@ -828,7 +851,7 @@ class Pref_Feeds extends Handler_Protected {
break;
case "cat_id":
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) {
$qpart = "cat_id = ?";
$qparams = $cat_id ? [$cat_id] : [null];
}
@@ -938,7 +961,7 @@ class Pref_Feeds extends Handler_Protected {
</div>
</div>
<?php if (get_pref(Prefs::ENABLE_FEED_CATS)) { ?>
<?php if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) { ?>
<div dojoType="fox.form.DropDownButton">
<span><?= __('Categories') ?></span>
<div dojoType="dijit.Menu" style="display: none">
@@ -1077,13 +1100,10 @@ class Pref_Feeds extends Handler_Protected {
* @return array<string, mixed>
*/
private function feedlist_init_cat(int $cat_id): array {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent(__METHOD__ . ": $cat_id");
return [
'id' => 'CAT:' . $cat_id,
'items' => array(),
'name' => Feeds::_get_cat_title($cat_id),
'name' => Feeds::_get_cat_title($cat_id, $_SESSION['uid']),
'type' => 'category',
'unread' => -1, //(int) Feeds::_get_cat_unread($cat_id);
'bare_id' => $cat_id,
@@ -1094,11 +1114,8 @@ class Pref_Feeds extends Handler_Protected {
* @return array<string, mixed>
*/
private function feedlist_init_feed(int $feed_id, ?string $title = null, bool $unread = false, string $error = '', string $updated = ''): array {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent(__METHOD__ . ": $feed_id");
if (!$title)
$title = Feeds::_get_title($feed_id, false);
$title = Feeds::_get_title($feed_id, $_SESSION['uid']);
if ($unread === false)
$unread = Feeds::_get_counters($feed_id, false, true);
@@ -1118,12 +1135,6 @@ class Pref_Feeds extends Handler_Protected {
function inactiveFeeds(): void {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "NOW() - INTERVAL '3 months'";
} else {
$interval_qpart = "DATE_SUB(NOW(), INTERVAL 3 MONTH)";
}
$inactive_feeds = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('f.id', 'f.title', 'f.site_url', 'f.feed_url')
@@ -1135,7 +1146,7 @@ class Pref_Feeds extends Handler_Protected {
"(SELECT MAX(ttrss_entries.updated)
FROM ttrss_entries
JOIN ttrss_user_entries ON ttrss_entries.id = ttrss_user_entries.ref_id
WHERE ttrss_user_entries.feed_id = f.id) < $interval_qpart")
WHERE ttrss_user_entries.feed_id = f.id) < NOW() - INTERVAL '3 months'")
->group_by('f.title')
->group_by('f.id')
->group_by('f.site_url')
@@ -1144,7 +1155,7 @@ class Pref_Feeds extends Handler_Protected {
->find_array();
foreach ($inactive_feeds as $inactive_feed) {
$inactive_feed['last_article'] = TimeHelper::make_local_datetime($inactive_feed['last_article'], false);
$inactive_feed['last_article'] = TimeHelper::make_local_datetime($inactive_feed['last_article']);
}
print json_encode($inactive_feeds);
@@ -1203,7 +1214,7 @@ class Pref_Feeds extends Handler_Protected {
function batchSubscribe(): void {
print json_encode([
"enable_cats" => (int)get_pref(Prefs::ENABLE_FEED_CATS),
"enable_cats" => (int)Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null),
"cat_select" => \Controls\select_feeds_cats("cat")
]);
}
@@ -1273,7 +1284,7 @@ class Pref_Feeds extends Handler_Protected {
]);
print json_encode([
"title" => Feeds::_get_title($feed_id, $is_cat),
"title" => Feeds::_get_title($feed_id, $_SESSION['uid'], $is_cat),
"link" => $link
]);
}

View File

@@ -10,6 +10,22 @@ class Pref_Filters extends Handler_Protected {
const PARAM_ACTIONS = [self::ACTION_TAG, self::ACTION_SCORE,
self::ACTION_LABEL, self::ACTION_PLUGIN, self::ACTION_REMOVE_TAG];
const MAX_ACTIONS_TO_DISPLAY = 3;
/** @var array<int,array<mixed>> $action_descriptions */
private array $action_descriptions = [];
function before(string $method) : bool {
$descriptions = ORM::for_table("ttrss_filter_actions")->find_array();
foreach ($descriptions as $desc) {
$this->action_descriptions[$desc['id']] = $desc;
}
return parent::before($method);
}
function csrf_ignore(string $method): bool {
$csrf_ignored = array("index", "getfiltertree", "savefilterorder");
@@ -54,122 +70,173 @@ class Pref_Filters extends Handler_Protected {
$offset = (int) clean($_REQUEST["offset"]);
$limit = (int) clean($_REQUEST["limit"]);
$filter = array();
$filter["enabled"] = true;
$filter["match_any_rule"] = checkbox_to_sql_bool($_REQUEST["match_any_rule"] ?? false);
$filter["inverse"] = checkbox_to_sql_bool($_REQUEST["inverse"] ?? false);
$filter["rules"] = array();
$filter["actions"] = array("dummy-action");
$res = $this->pdo->query("SELECT id,name FROM ttrss_filter_types");
// catchall fake filter which includes all rules
$filter = [
'enabled' => true,
'match_any_rule' => checkbox_to_sql_bool($_REQUEST['match_any_rule'] ?? false),
'inverse' => checkbox_to_sql_bool($_REQUEST['inverse'] ?? false),
'rules' => [],
'actions' => ['dummy-action'],
];
/** @var array<int, string> */
$filter_types = [];
while ($line = $res->fetch()) {
$filter_types[$line["id"]] = $line["name"];
foreach (ORM::for_table('ttrss_filter_types')->find_many() as $filter_type) {
$filter_types[$filter_type->id] = $filter_type->name;
}
$scope_qparts = array();
$scope_qparts = [];
$rctr = 0;
/** @var string $rule_json */
foreach (clean($_REQUEST['rule']) as $rule_json) {
/** @var array{reg_exp: string, filter_type: int, feed_id: array<int, int|string>, name: string, inverse?: bool}|null */
$rule = json_decode($rule_json, true);
/** @var string $r */
foreach (clean($_REQUEST["rule"]) AS $r) {
/** @var array{'reg_exp': string, 'filter_type': int, 'feed_id': array<int, int|string>, 'name': string}|null */
$rule = json_decode($r, true);
if ($rule && $rctr < 5) {
$rule["type"] = $filter_types[$rule["filter_type"]];
unset($rule["filter_type"]);
if (is_array($rule)) {
$rule['type'] = $filter_types[$rule['filter_type']];
$rule['inverse'] ??= false;
array_push($filter['rules'], $rule);
$scope_inner_qparts = [];
/** @var int|string $feed_id may be a category string (e.g. 'CAT:7') or feed ID int */
foreach ($rule["feed_id"] as $feed_id) {
if (strpos("$feed_id", "CAT:") === 0) {
if (str_starts_with("$feed_id", "CAT:")) {
$cat_id = (int) substr("$feed_id", 4);
array_push($scope_inner_qparts, "cat_id = " . $cat_id);
if ($cat_id > 0)
array_push($scope_inner_qparts, "cat_id = " . $cat_id);
else
array_push($scope_inner_qparts, "cat_id IS NULL");
} else if (is_numeric($feed_id) && $feed_id > 0) {
array_push($scope_inner_qparts, "feed_id = " . (int)$feed_id);
}
}
if (count($scope_inner_qparts) > 0) {
array_push($scope_qparts, "(" . implode(" OR ", $scope_inner_qparts) . ")");
}
array_push($filter["rules"], $rule);
++$rctr;
} else {
break;
if (count($scope_inner_qparts) > 0)
array_push($scope_qparts, '(' . implode(' OR ', $scope_inner_qparts) . ')');
}
}
if (count($scope_qparts) == 0) $scope_qparts = ["true"];
$query = ORM::for_table('ttrss_entries')
->table_alias('e')
->select_many('e.title', 'e.content', 'e.date_entered', 'e.link', 'e.author', 'ue.tag_cache',
['feed_id' => 'f.id', 'feed_title' => 'f.title', 'cat_id' => 'fc.id'])
->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue')
->left_outer_join('ttrss_feeds', ['f.id', '=', 'ue.feed_id'], 'f')
->left_outer_join('ttrss_feed_categories', ['fc.id', '=', 'f.cat_id'], 'fc')
->where('ue.owner_uid', $_SESSION['uid'])
->order_by_desc('e.date_entered')
->limit($limit)
->offset($offset);
$glue = $filter['match_any_rule'] ? " OR " : " AND ";
$scope_qpart = join($glue, $scope_qparts);
if (count($scope_qparts) > 0)
$query->where_raw(join($filter['match_any_rule'] ? ' OR ' : ' AND ', $scope_qparts));
/** @phpstan-ignore-next-line */
if (!$scope_qpart) $scope_qpart = "true";
$entries = $query->find_array();
$rv = array();
$rv = [
'pre_filtering_count' => count($entries),
'items' => [],
];
//while ($found < $limit && $offset < $limit * 1000 && time() - $started < ini_get("max_execution_time") * 0.7) {
foreach ($entries as $entry) {
$sth = $this->pdo->prepare("SELECT ttrss_entries.id,
ttrss_entries.title,
ttrss_feeds.id AS feed_id,
ttrss_feeds.title AS feed_title,
ttrss_feed_categories.id AS cat_id,
content,
date_entered,
link,
author,
tag_cache
FROM
ttrss_entries, ttrss_user_entries
LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)
LEFT JOIN ttrss_feed_categories ON (ttrss_feeds.cat_id = ttrss_feed_categories.id)
WHERE
ref_id = ttrss_entries.id AND
($scope_qpart) AND
ttrss_user_entries.owner_uid = ?
ORDER BY date_entered DESC LIMIT $limit OFFSET $offset");
// temporary filter which will be used to compare against returned article
$feed_filter = $filter;
$feed_filter['rules'] = [];
$sth->execute([$_SESSION['uid']]);
// only add rules which match result from specific feed or category ID or rules matching all feeds
// @phpstan-ignore foreach.emptyArray
foreach ($filter['rules'] as $rule) {
foreach ($rule['feed_id'] as $rule_feed) {
if (($rule_feed === 'CAT:0' && $entry['cat_id'] === null) || // rule matches Uncategorized
$rule_feed === 'CAT:' . $entry['cat_id'] || // rule matches category
(int)$rule_feed === $entry['feed_id'] || // rule matches feed
$rule_feed === '0') { // rule matches all feeds
while ($line = $sth->fetch()) {
$feed_filter['rules'][] = $rule;
}
}
}
$rc = RSSUtils::get_article_filters(array($filter), $line['title'], $line['content'], $line['link'],
$line['author'], explode(",", $line['tag_cache']));
$matched_rules = [];
if (count($rc) > 0) {
$entry_tags = explode(",", $entry['tag_cache']);
$line["content_preview"] = truncate_string(strip_tags($line["content"]), 200, '&hellip;');
$article_filter_actions = RSSUtils::eval_article_filters([$feed_filter], $entry['title'], $entry['content'], $entry['link'],
$entry['author'], $entry_tags, $matched_rules);
$excerpt_length = 100;
if (count($article_filter_actions) > 0) {
$content_preview = "";
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
function ($result) use (&$line) {
$line = $result;
},
$line, $excerpt_length);
$matches = [];
$rules = [];
$content_preview = $line["content_preview"];
$entry_title = $entry["title"];
$tmp = "<li><span class='title'>" . $line["title"] . "</span><br/>" .
"<span class='feed'>" . $line['feed_title'] . "</span>, <span class='date'>" . mb_substr($line["date_entered"], 0, 16) . "</span>" .
"<div class='preview text-muted'>" . $content_preview . "</div>" .
"</li>";
// technically only one rule may match *here* because we're testing a single (fake) filter defined above
// let's keep this forward-compatible in case we'll want to return multiple rules for whatever reason
foreach ($matched_rules as $rule) {
$can_highlight_content = false;
$can_highlight_title = false;
array_push($rv, $tmp);
$rule_regexp_match = mb_substr(strip_tags($rule['regexp_matches'][0]), 0, 200);
$matches[] = $rule_regexp_match;
$rules[] = self::_get_rule_name($rule, '');
if (in_array($rule['type'], ['content', 'both'])) {
// also stripping [\r\n\t] to match what's done for content in RSSUtils#eval_article_filters()
$entry_content_text = strip_tags(preg_replace("/[\r\n\t]/", "", $entry["content"]));
$match_index = mb_strpos($entry_content_text, $rule_regexp_match);
$content_preview = truncate_string(mb_substr($entry_content_text, $match_index), 200);
if ($match_index > 0)
$content_preview = '&hellip;' . $content_preview;
} else if ($rule['type'] == 'link') {
$content_preview = $entry['link'];
} else if ($rule['type'] == 'author') {
$content_preview = $entry['author'];
} else if ($rule['type'] == 'tag') {
$content_preview = '<i class="material-icons">label_outline</i> ' . implode(', ', $entry_tags);
} else {
$content_preview = "&mdash;";
}
switch ($rule['type']) {
case "both":
$can_highlight_title = true;
$can_highlight_content = true;
break;
case "title":
$can_highlight_title = true;
break;
case "content":
case "link":
case "author":
case "tag":
$can_highlight_content = true;
break;
}
if ($can_highlight_content)
$content_preview = Sanitizer::highlight_words_str($content_preview, $matches);
if ($can_highlight_title)
$entry_title = Sanitizer::highlight_words_str($entry_title, $matches);
}
$rv['items'][] = [
'title' => $entry_title,
'feed_title' => $entry['feed_title'],
'date' => mb_substr($entry['date_entered'], 0, 16),
'content_preview' => $content_preview,
'rules' => $rules
];
}
}
@@ -177,148 +244,108 @@ class Pref_Filters extends Handler_Protected {
}
private function _get_rules_list(int $filter_id): string {
$sth = $this->pdo->prepare("SELECT reg_exp,
inverse,
match_on,
feed_id,
cat_id,
cat_filter,
ttrss_filter_types.description AS field
FROM
ttrss_filters2_rules, ttrss_filter_types
WHERE
filter_id = ? AND filter_type = ttrss_filter_types.id
ORDER BY reg_exp");
$sth->execute([$filter_id]);
$rules = ORM::for_table('ttrss_filters2_rules')
->table_alias('r')
->join('ttrss_filter_types', ['r.filter_type', '=', 't.id'], 't')
->where('filter_id', $filter_id)
->select_many(['r.*', 'field' => 't.description'])
->find_many();
$rv = "";
while ($line = $sth->fetch()) {
foreach ($rules as $rule) {
if ($rule->match_on) {
$feeds = json_decode($rule->match_on, true);
$feeds_fmt = [];
if ($line["match_on"]) {
$feeds = json_decode($line["match_on"], true);
$feeds_fmt = [];
foreach ($feeds as $feed_id) {
foreach ($feeds as $feed_id) {
if (str_starts_with($feed_id, "CAT:")) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id, $_SESSION['uid']));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id, $_SESSION['uid']));
else
array_push($feeds_fmt, __("All feeds"));
}
}
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
else
array_push($feeds_fmt, __("All feeds"));
}
}
$where = implode(", ", $feeds_fmt);
$where = implode(", ", $feeds_fmt);
} else {
$where = $rule->cat_filter ?
Feeds::_get_cat_title($rule->cat_id ?? 0, $_SESSION['uid']) :
($rule->feed_id ?
Feeds::_get_title($rule->feed_id, $_SESSION['uid']) : __("All feeds"));
}
} else {
$inverse_class = $rule->inverse ? "inverse" : "";
$where = $line["cat_filter"] ?
Feeds::_get_cat_title($line["cat_id"] ?? 0) :
($line["feed_id"] ?
Feeds::_get_title($line["feed_id"]) : __("All feeds"));
}
# $where = $line["cat_id"] . "/" . $line["feed_id"];
$inverse = $line["inverse"] ? "inverse" : "";
$rv .= "<li class='$inverse'>" . T_sprintf("%s on %s in %s %s",
htmlspecialchars($line["reg_exp"]),
$line["field"],
$rv .= "<li class='$inverse_class'>" . T_sprintf("%s on %s in %s %s",
htmlspecialchars($rule->reg_exp),
$rule->field,
$where,
$line["inverse"] ? __("(inverse)") : "") . "</li>";
$rule->inverse ? __("(inverse)") : "") . "</li>";
}
return $rv;
}
function getfiltertree(): void {
$root = array();
$root['id'] = 'root';
$root['name'] = __('Filters');
$root['enabled'] = true;
$root['items'] = array();
$root = [
'id' => 'root',
'name' => __('Filters'),
'enabled' => true,
'items' => []
];
$filter_search = ($_SESSION["prefs_filter_search"] ?? "");
$sth = $this->pdo->prepare("SELECT *,
(SELECT action_param FROM ttrss_filters2_actions
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS action_param,
(SELECT action_id FROM ttrss_filters2_actions
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS action_id,
(SELECT description FROM ttrss_filter_actions
WHERE id = (SELECT action_id FROM ttrss_filters2_actions
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1)) AS action_name,
(SELECT reg_exp FROM ttrss_filters2_rules
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS reg_exp
FROM ttrss_filters2 WHERE
owner_uid = ? ORDER BY order_id, title");
$sth->execute([$_SESSION['uid']]);
$filters = ORM::for_table('ttrss_filters2')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc(['order_id', 'title'])
->find_many();
$folder = array();
$folder['items'] = array();
$folder = [
'items' => []
];
while ($line = $sth->fetch()) {
foreach ($filters as $filter) {
$details = $this->_get_details($filter->id);
$name = $this->_get_name($line["id"]);
if ($filter_search &&
mb_stripos($filter->title, $filter_search) === false &&
!ORM::for_table('ttrss_filters2_rules')
->where('filter_id', $filter->id)
->where_raw('LOWER(reg_exp) LIKE LOWER(?)', ["%$filter_search%"])
->find_one()) {
$match_ok = false;
if ($filter_search) {
if (mb_strpos($line['title'], $filter_search) !== false) {
$match_ok = true;
}
$rules_sth = $this->pdo->prepare("SELECT reg_exp
FROM ttrss_filters2_rules WHERE filter_id = ?");
$rules_sth->execute([$line['id']]);
while ($rule_line = $rules_sth->fetch()) {
if (mb_strpos($rule_line['reg_exp'], $filter_search) !== false) {
$match_ok = true;
break;
}
}
continue;
}
if ($line['action_id'] == self::ACTION_LABEL) {
$label_sth = $this->pdo->prepare("SELECT fg_color, bg_color
FROM ttrss_labels2 WHERE caption = ? AND
owner_uid = ?");
$label_sth->execute([$line['action_param'], $_SESSION['uid']]);
$item = [
'id' => 'FILTER:' . $filter->id,
'bare_id' => $filter->id,
'bare_name' => $details['title'],
'name' => $details['title_summary'],
'param' => $details['actions_summary'],
'checkbox' => false,
'last_triggered' => $filter->last_triggered ? TimeHelper::make_local_datetime($filter->last_triggered) : null,
'enabled' => sql_bool_to_bool($filter->enabled),
'rules' => $this->_get_rules_list($filter->id)
];
if ($label_row = $label_sth->fetch()) {
//$fg_color = $label_row["fg_color"];
$bg_color = $label_row["bg_color"];
$name[1] = "<i class=\"material-icons\" style='color : $bg_color; margin-right : 4px'>label</i>" . $name[1];
}
}
$filter = array();
$filter['id'] = 'FILTER:' . $line['id'];
$filter['bare_id'] = $line['id'];
$filter['name'] = $name[0];
$filter['param'] = $name[1];
$filter['checkbox'] = false;
$filter['last_triggered'] = $line["last_triggered"] ? TimeHelper::make_local_datetime($line["last_triggered"], false) : null;
$filter['enabled'] = sql_bool_to_bool($line["enabled"]);
$filter['rules'] = $this->_get_rules_list($line['id']);
if (!$filter_search || $match_ok) {
array_push($folder['items'], $filter);
}
array_push($folder['items'], $item);
}
$root['items'] = $folder['items'];
$fl = array();
$fl['identifier'] = 'id';
$fl['label'] = 'name';
$fl['items'] = array($root);
$fl = [
'identifier' => 'id',
'label' => 'name',
'items' => [$root]
];
print json_encode($fl);
}
@@ -422,7 +449,7 @@ class Pref_Filters extends Handler_Protected {
/**
* @param array<string, mixed>|null $rule
*/
private function _get_rule_name(?array $rule = null): string {
private function _get_rule_name(?array $rule = null, string $format = 'html'): string {
if (!$rule) $rule = json_decode(clean($_REQUEST["rule"]), true);
$feeds = $rule["feed_id"];
@@ -432,12 +459,12 @@ class Pref_Filters extends Handler_Protected {
foreach ($feeds as $feed_id) {
if (strpos($feed_id, "CAT:") === 0) {
if (str_starts_with($feed_id, "CAT:")) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id, $_SESSION['uid']));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id, $_SESSION['uid']));
else
array_push($feeds_fmt, __("All feeds"));
}
@@ -457,49 +484,45 @@ class Pref_Filters extends Handler_Protected {
$inverse = isset($rule["inverse"]) ? "inverse" : "";
return "<span class='filterRule $inverse'>" .
T_sprintf("%s on %s in %s %s", htmlspecialchars($rule["reg_exp"]),
"<span class='field'>$filter_type</span>", "<span class='feed'>$feed</span>", isset($rule["inverse"]) ? __("(inverse)") : "") . "</span>";
}
if ($format === 'html')
return "<span class='filterRule $inverse'>" .
T_sprintf("%s on %s in %s %s", htmlspecialchars($rule["reg_exp"]),
"<span class='field'>$filter_type</span>", "<span class='feed'>$feed</span>", isset($rule["inverse"]) ? __("(inverse)") : "") . "</span>";
else
return T_sprintf("%s on %s in %s %s", $rule["reg_exp"],
$filter_type, $feed, isset($rule["inverse"]) ? __("(inverse)") : "");
}
function printRuleName(): void {
print $this->_get_rule_name(json_decode(clean($_REQUEST["rule"]), true));
}
/**
* @param array<string, mixed>|null $action
* @param array<string,mixed>|ArrayAccess<string, mixed>|null $action
*/
private function _get_action_name(?array $action = null): string {
private function _get_action_name(array|ArrayAccess|null $action = null): string {
if (!$action) {
return "";
}
$sth = $this->pdo->prepare("SELECT description FROM
ttrss_filter_actions WHERE id = ?");
$sth->execute([(int)$action["action_id"]]);
$title = __($this->action_descriptions[$action['action_id']]['description']) ??
T_sprintf('Unknown action: %d', $action['action_id']);
$title = "";
if ($action["action_id"] == self::ACTION_PLUGIN) {
list ($pfclass, $pfaction) = explode(":", $action["action_param"]);
if ($row = $sth->fetch()) {
$filter_actions = PluginHost::getInstance()->get_filter_actions();
$title = __($row["description"]);
if ($action["action_id"] == self::ACTION_PLUGIN) {
list ($pfclass, $pfaction) = explode(":", $action["action_param"]);
$filter_actions = PluginHost::getInstance()->get_filter_actions();
foreach ($filter_actions as $fclass => $factions) {
foreach ($factions as $faction) {
if ($pfaction == $faction["action"] && $pfclass == $fclass) {
$title .= ": " . $fclass . ": " . $faction["description"];
break;
}
foreach ($filter_actions as $fclass => $factions) {
foreach ($factions as $faction) {
if ($pfaction == $faction["action"] && $pfclass == $fclass) {
$title .= ": " . $fclass . ": " . $faction["description"];
break;
}
}
} else if (in_array($action["action_id"], self::PARAM_ACTIONS)) {
$title .= ": " . $action["action_param"];
}
} else if (in_array($action["action_id"], self::PARAM_ACTIONS)) {
$title .= ": " . $action["action_param"];
}
return $title;
@@ -541,6 +564,25 @@ class Pref_Filters extends Handler_Protected {
$sth->execute([...$ids, $_SESSION['uid']]);
}
private function _clone_rules_and_actions(int $filter_id, ?int $src_filter_id = null): bool {
$sth = $this->pdo->prepare('INSERT INTO ttrss_filters2_rules
(filter_id, reg_exp, inverse, filter_type, feed_id, cat_id, match_on, cat_filter)
SELECT :filter_id, reg_exp, inverse, filter_type, feed_id, cat_id, match_on, cat_filter
FROM ttrss_filters2_rules
WHERE filter_id = :src_filter_id');
if (!$sth->execute(['filter_id' => $filter_id, 'src_filter_id' => $src_filter_id]))
return false;
$sth = $this->pdo->prepare('INSERT INTO ttrss_filters2_actions
(filter_id, action_id, action_param)
SELECT :filter_id, action_id, action_param
FROM ttrss_filters2_actions
WHERE filter_id = :src_filter_id');
return $sth->execute(['filter_id' => $filter_id, 'src_filter_id' => $src_filter_id]);
}
private function _save_rules_and_actions(int $filter_id): void {
$sth = $this->pdo->prepare("DELETE FROM ttrss_filters2_rules WHERE filter_id = ?");
@@ -623,11 +665,24 @@ class Pref_Filters extends Handler_Protected {
}
}
function add(): void {
$enabled = checkbox_to_sql_bool($_REQUEST["enabled"] ?? false);
$match_any_rule = checkbox_to_sql_bool($_REQUEST["match_any_rule"] ?? false);
$title = clean($_REQUEST["title"]);
$inverse = checkbox_to_sql_bool($_REQUEST["inverse"] ?? false);
/**
* @param null|array{'src_filter_id': int, 'title': string, 'enabled': 0|1, 'match_any_rule': 0|1, 'inverse': 0|1} $props
*/
function add(?array $props = null): void {
if ($props === null) {
$src_filter_id = null;
$title = clean($_REQUEST['title']);
$enabled = checkbox_to_sql_bool($_REQUEST['enabled'] ?? false);
$match_any_rule = checkbox_to_sql_bool($_REQUEST['match_any_rule'] ?? false);
$inverse = checkbox_to_sql_bool($_REQUEST['inverse'] ?? false);
} else {
// see checkbox_to_sql_bool() for 0 vs false justification
$src_filter_id = $props['src_filter_id'];
$title = clean($props['title']);
$enabled = $props['enabled'];
$match_any_rule = $props['match_any_rule'];
$inverse = $props['inverse'];
}
$this->pdo->beginTransaction();
@@ -635,22 +690,44 @@ class Pref_Filters extends Handler_Protected {
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2
(owner_uid, match_any_rule, enabled, title, inverse) VALUES
(?, ?, ?, ?, ?)");
(?, ?, ?, ?, ?) RETURNING id");
$sth->execute([$_SESSION['uid'], $match_any_rule, $enabled, $title, $inverse]);
$sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2
WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
if ($row = $sth->fetch()) {
$filter_id = $row['id'];
$this->_save_rules_and_actions($filter_id);
if ($src_filter_id === null)
$this->_save_rules_and_actions($filter_id);
else
$this->_clone_rules_and_actions($filter_id, $src_filter_id);
}
$this->pdo->commit();
}
function clone(): void {
/** @var array<int, int> */
$src_filter_ids = array_map('intval', array_filter(explode(',', clean($_REQUEST['ids'] ?? ''))));
$new_filter_title = count($src_filter_ids) === 1 ? clean($_REQUEST['new_filter_title'] ?? null) : null;
$src_filters = ORM::for_table('ttrss_filters2')
->where('owner_uid', $_SESSION['uid'])
->where_id_in($src_filter_ids)
->find_many();
foreach ($src_filters as $src_filter) {
// see checkbox_to_sql_bool() for 0+1 justification
$this->add([
'src_filter_id' => $src_filter->id,
'title' => $new_filter_title ?? sprintf(__('Clone of %s'), $src_filter->title),
'enabled' => 0,
'match_any_rule' => $src_filter->match_any_rule ? 1 : 0,
'inverse' => $src_filter->inverse ? 1 : 0,
]);
}
}
function index(): void {
if (array_key_exists("search", $_REQUEST)) {
$filter_search = clean($_REQUEST["search"]);
@@ -685,6 +762,8 @@ class Pref_Filters extends Handler_Protected {
<button dojoType="dijit.form.Button" onclick="return Filters.edit()">
<?= __('Create filter') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').cloneSelectedFilters()">
<?= __('Clone') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').joinSelectedFilters()">
<?= __('Combine') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').removeSelectedFilters()">
@@ -714,7 +793,6 @@ class Pref_Filters extends Handler_Protected {
}
function editrule(): void {
/** @var array<int, int|string> */
$feed_ids = explode(",", clean($_REQUEST["ids"]));
print json_encode([
@@ -723,58 +801,86 @@ class Pref_Filters extends Handler_Protected {
}
/**
* @return array<int, string>
* @return array{'title': string, 'title_summary': string, 'actions_summary': string}
*/
private function _get_name(int $id): array {
private function _get_details(int $id): array {
$sth = $this->pdo->prepare(
"SELECT title,match_any_rule,f.inverse AS inverse,COUNT(DISTINCT r.id) AS num_rules,COUNT(DISTINCT a.id) AS num_actions
FROM ttrss_filters2 AS f LEFT JOIN ttrss_filters2_rules AS r
ON (r.filter_id = f.id)
LEFT JOIN ttrss_filters2_actions AS a
ON (a.filter_id = f.id) WHERE f.id = ? GROUP BY f.title, f.match_any_rule, f.inverse");
$sth->execute([$id]);
$filter = ORM::for_table("ttrss_filters2")
->table_alias('f')
->select('f.title')
->select('f.match_any_rule')
->select('f.inverse')
->select_expr('COUNT(DISTINCT r.id)', 'num_rules')
->select_expr('COUNT(DISTINCT a.id)', 'num_actions')
->left_outer_join('ttrss_filters2_rules', ['r.filter_id', '=', 'f.id'], 'r')
->left_outer_join('ttrss_filters2_actions', ['a.filter_id', '=', 'f.id'], 'a')
->where('f.id', $id)
->group_by_expr('f.title, f.match_any_rule, f.inverse')
->find_one();
if ($row = $sth->fetch()) {
$title = $filter->title ?: __('[No caption]');
$title_summary = [
sprintf(
_ngettext("%s (%d rule)", "%s (%d rules)", (int) $filter->num_rules),
$title,
$filter->num_rules)];
$title = $row["title"];
$num_rules = $row["num_rules"];
$num_actions = $row["num_actions"];
$match_any_rule = $row["match_any_rule"];
$inverse = $row["inverse"];
if ($filter->match_any_rule) array_push($title_summary, __("matches any rule"));
if ($filter->inverse) array_push($title_summary, __("inverse"));
if (!$title) $title = __("[No caption]");
$actions = ORM::for_table("ttrss_filters2_actions")
->where("filter_id", $id)
->order_by_asc('id')
->find_many();
$title = sprintf(_ngettext("%s (%d rule)", "%s (%d rules)", (int) $num_rules), $title, $num_rules);
/** @var array<string> $actions_summary */
$actions_summary = [];
$cumulative_score = 0;
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
WHERE filter_id = ? ORDER BY id LIMIT 1");
$sth->execute([$id]);
$actions = "";
if ($line = $sth->fetch()) {
$actions = $this->_get_action_name($line);
$num_actions -= 1;
// we're going to show a summary adjustment so we skip individual score action descriptions here
foreach ($actions as $action) {
if ($action->action_id == self::ACTION_SCORE) {
$cumulative_score += (int) $action->action_param;
continue;
}
if ($match_any_rule) $title .= " (" . __("matches any rule") . ")";
if ($inverse) $title .= " (" . __("inverse") . ")";
if ($num_actions > 0)
$actions = sprintf(_ngettext("%s (+%d action)", "%s (+%d actions)", (int) $num_actions), $actions, $num_actions);
return [$title, $actions];
array_push($actions_summary, "<li>" . self::_get_action_name($action) . "</li>");
}
return [];
// inject a fake action description using cumulative filter score
if ($cumulative_score != 0) {
array_unshift($actions_summary,
"<li>" . self::_get_action_name(["action_id" => self::ACTION_SCORE, "action_param" => $cumulative_score]) . "</li>");
}
if (count($actions_summary) > self::MAX_ACTIONS_TO_DISPLAY) {
$actions_not_shown = count($actions_summary) - self::MAX_ACTIONS_TO_DISPLAY;
$actions_summary = array_slice($actions_summary, 0, self::MAX_ACTIONS_TO_DISPLAY);
array_push($actions_summary,
"<li class='text-muted'><em>" . sprintf(_ngettext("(+%d action)", "(+%d actions)", $actions_not_shown), $actions_not_shown)) . "</em></li>";
}
return [
'title' => $title,
'title_summary' => implode(', ', $title_summary),
'actions_summary' => implode('', $actions_summary),
];
}
function join(): void {
/** @var array<int, int> */
$ids = array_map("intval", explode(",", clean($_REQUEST["ids"])));
// fail early if any provided filter IDs aren't owned by the current user
$unowned_filter_count = ORM::for_table('ttrss_filters2')
->where_in('id', $ids)
->where_not_equal('owner_uid', $_SESSION['uid'])
->count();
if ($unowned_filter_count)
return;
if (count($ids) > 1) {
$base_id = array_shift($ids);
$ids_qmarks = arr_qmarks($ids);
@@ -877,7 +983,7 @@ class Pref_Filters extends Handler_Protected {
}
}
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) {
if (!$root_id) $root_id = null;

View File

@@ -1,6 +1,4 @@
<?php
use chillerlan\QRCode;
class Pref_Prefs extends Handler_Protected {
/** @var array<Prefs::*, array<int, string>> */
private array $pref_help = [];
@@ -177,7 +175,7 @@ class Pref_Prefs extends Handler_Protected {
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if (implements_interface($authenticator, "IAuthModule2")) {
/** @var IAuthModule2 $authenticator */
/** @var Plugin&IAuthModule2 $authenticator */
print format_notice($authenticator->change_password($_SESSION["uid"], $old_pw, $new_pw));
} else {
print "ERROR: ".format_error("Function not supported by authentication module.");
@@ -185,6 +183,8 @@ class Pref_Prefs extends Handler_Protected {
}
function saveconfig(): void {
$profile = $_SESSION['profile'] ?? null;
$boolean_prefs = explode(",", clean($_POST["boolean_prefs"]));
foreach ($boolean_prefs as $pref) {
@@ -199,7 +199,7 @@ class Pref_Prefs extends Handler_Protected {
switch ($pref_name) {
case Prefs::DIGEST_PREFERRED_TIME:
if (get_pref(Prefs::DIGEST_PREFERRED_TIME) != $value) {
if (Prefs::get(Prefs::DIGEST_PREFERRED_TIME, $_SESSION['uid']) != $value) {
$sth = $this->pdo->prepare("UPDATE ttrss_users SET
last_digest_sent = NULL WHERE id = ?");
@@ -207,14 +207,10 @@ class Pref_Prefs extends Handler_Protected {
}
break;
case Prefs::USER_LANGUAGE:
if (!$need_reload) $need_reload = $_SESSION["language"] != $value;
break;
case Prefs::USER_CSS_THEME:
if (!$need_reload) $need_reload = get_pref($pref_name) != $value;
case Prefs::USER_LANGUAGE:
if (!$need_reload) $need_reload = Prefs::get($pref_name, $_SESSION['uid'], $profile) != $value;
break;
case Prefs::BLACKLISTED_TAGS:
$cats = FeedItem_Common::normalize_categories(explode(",", $value));
asort($cats);
@@ -223,7 +219,7 @@ class Pref_Prefs extends Handler_Protected {
}
if (Prefs::is_valid($pref_name)) {
Prefs::set($pref_name, $value, $_SESSION["uid"], $_SESSION["profile"] ?? null);
Prefs::set($pref_name, $value, $_SESSION['uid'], $profile);
}
}
@@ -465,7 +461,7 @@ class Pref_Prefs extends Handler_Protected {
} else {
print "<img src=".($this->_get_otp_qrcode_img()).">";
print "<img src='{$this->_get_otp_qrcode_img()}' style='width: 25%'>";
print_notice("You will need to generate app passwords for API clients if you enable OTP.");
@@ -595,10 +591,6 @@ class Pref_Prefs extends Handler_Protected {
continue;
}
if ($pref_name == Prefs::DEFAULT_SEARCH_LANGUAGE && Config::get(Config::DB_TYPE) != "pgsql") {
continue;
}
if (isset($prefs_available[$pref_name])) {
$item = $prefs_available[$pref_name];
@@ -655,7 +647,7 @@ class Pref_Prefs extends Handler_Protected {
<?= \Controls\button_tag(\Controls\icon("palette") . " " . __("Customize"), "",
["onclick" => "Helpers.Prefs.customizeCSS()"]) ?>
<?= \Controls\button_tag(\Controls\icon("open_in_new") . " " . __("More themes..."), "",
["class" => "alt-info", "onclick" => "window.open(\"https://tt-rss.org/Themes/\")"]) ?>
["class" => "alt-info", "onclick" => "window.open(\"https://github.com/tt-rss/tt-rss/wiki/Themes\")"]) ?>
<?php
@@ -721,7 +713,7 @@ class Pref_Prefs extends Handler_Protected {
print \Controls\button_tag(\Controls\icon("help") . " " . __("More info..."), "", [
"class" => "alt-info",
"onclick" => "window.open('https://tt-rss.org/wiki/SSL%20Certificate%20Authentication')"]);
"onclick" => "window.open('https://github.com/tt-rss/tt-rss/wiki/SSL-Certificate-Authentication')"]);
} else if ($pref_name == Prefs::DIGEST_PREFERRED_TIME) {
print "<input dojoType=\"dijit.form.ValidationTextBox\"
@@ -802,7 +794,7 @@ class Pref_Prefs extends Handler_Protected {
function getPluginsList(): void {
$system_enabled = array_map("trim", explode(",", (string)Config::get(Config::PLUGINS)));
$user_enabled = array_map("trim", explode(",", get_pref(Prefs::_ENABLED_PLUGINS)));
$user_enabled = array_map('trim', explode(',', Prefs::get(Prefs::_ENABLED_PLUGINS, $_SESSION['uid'], $_SESSION['profile'] ?? null)));
$tmppluginhost = new PluginHost();
$tmppluginhost->load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true);
@@ -886,7 +878,7 @@ class Pref_Prefs extends Handler_Protected {
print_error(
T_sprintf("The following plugins use per-feed content hooks. This may cause excessive data usage and origin server load resulting in a ban of your instance: <b>%s</b>" ,
implode(", ", array_map(fn($plugin) => get_class($plugin), $feed_handlers))
) . " (<a href='https://tt-rss.org/wiki/FeedHandlerPlugins' target='_blank'>".__("More info...")."</a>)"
) . " (<a href='https://github.com/tt-rss/tt-rss/wiki/Feed-Handler-Plugins' target='_blank'>".__("More info...")."</a>)"
);
}
?> -->
@@ -898,7 +890,7 @@ class Pref_Prefs extends Handler_Protected {
</div>
<div dojoType="dijit.layout.ContentPane" region="bottom">
<button dojoType='dijit.form.Button' class="alt-info pull-right" onclick='window.open("https://tt-rss.org/Plugins/")'>
<button dojoType='dijit.form.Button' class="alt-info pull-right" onclick='window.open("https://github.com/tt-rss/tt-rss/wiki/Plugins")'>
<i class='material-icons'>help</i>
<?= __("More info") ?>
</button>
@@ -976,7 +968,7 @@ class Pref_Prefs extends Handler_Protected {
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
/** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */
/** @var Auth_Internal|null $authenticator -- this is only here to make check_password() visible to static analyzer */
if ($authenticator->check_password($_SESSION["uid"], $password)) {
if (UserHelper::enable_otp($_SESSION["uid"], $otp_check)) {
print "OK";
@@ -991,7 +983,7 @@ class Pref_Prefs extends Handler_Protected {
function otpdisable(): void {
$password = clean($_REQUEST["password"]);
/** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */
/** @var Auth_Internal|null $authenticator -- this is only here to make check_password() visible to static analyzer */
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if ($authenticator->check_password($_SESSION["uid"], $password)) {
@@ -1031,7 +1023,7 @@ class Pref_Prefs extends Handler_Protected {
function setplugins(): void {
$plugins = array_filter($_REQUEST["plugins"] ?? [], 'clean');
set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins));
Prefs::set(Prefs::_ENABLED_PLUGINS, implode(',', $plugins), $_SESSION['uid'], $_SESSION['profile'] ?? null);
}
function _get_plugin_version(Plugin $plugin): string {
@@ -1095,9 +1087,19 @@ class Pref_Prefs extends Handler_Protected {
2 => ["pipe", "w"], // STDERR
];
$proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir);
// TODO: clean up handling main+master
$proc = proc_open("git fetch -q origin -a && git log HEAD..origin/main --oneline", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv = [
"stdout" => stream_get_contents($pipes[1]),
"stderr" => stream_get_contents($pipes[2]),
"git_status" => proc_close($proc),
];
$rv["need_update"] = !empty($rv["stdout"]);
} else {
$proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir);
$rv = [
"stdout" => stream_get_contents($pipes[1]),
"stderr" => stream_get_contents($pipes[2]),
@@ -1127,12 +1129,25 @@ class Pref_Prefs extends Handler_Protected {
2 => ["pipe", "w"], // STDERR
];
$proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir);
// TODO: clean up handling main+master
$proc = proc_open("git fetch origin -a && git log HEAD..origin/main --oneline && git pull --ff-only origin main", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv["stdout"] = stream_get_contents($pipes[1]);
$rv["stderr"] = stream_get_contents($pipes[2]);
$rv["git_status"] = proc_close($proc);
$rv = [
'stdout' => stream_get_contents($pipes[1]),
'stderr' => stream_get_contents($pipes[2]),
'git_status' => proc_close($proc),
];
} else {
$proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv = [
'stdout' => stream_get_contents($pipes[1]),
'stderr' => stream_get_contents($pipes[2]),
'git_status' => proc_close($proc),
];
}
}
}
@@ -1282,9 +1297,12 @@ class Pref_Prefs extends Handler_Protected {
* @return array<int, array{'name': string, 'description': string, 'topics': array<int, string>, 'html_url': string, 'clone_url': string, 'last_update': string}>
*/
private function _get_available_plugins(): array {
// TODO: Get this working again. https://tt-rss.org/plugins.json won't exist after 2025-11-01 (probably).
if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) {
$content = json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true);
// $content = json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true);
$content = false;
/** @phpstan-ignore if.alwaysFalse (intentionally disabling for now) */
if ($content) {
return $content;
}
@@ -1316,14 +1334,8 @@ class Pref_Prefs extends Handler_Protected {
function updateLocalPlugins(): void {
if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) {
$plugins = explode(",", $_REQUEST["plugins"] ?? "");
if ($plugins !== false) {
$plugins = array_filter($plugins, 'strlen');
}
$plugins = array_filter(explode(",", $_REQUEST["plugins"] ?? ""), "strlen");
$root_dir = Config::get_self_dir();
$rv = [];
if ($plugins) {
@@ -1356,7 +1368,7 @@ class Pref_Prefs extends Handler_Protected {
}
function customizeCSS(): void {
$value = get_pref(Prefs::USER_STYLESHEET);
$value = Prefs::get(Prefs::USER_STYLESHEET, $_SESSION['uid'], $_SESSION['profile'] ?? null);
$value = str_replace("<br/>", "\n", $value);
print json_encode(["value" => $value]);
@@ -1526,10 +1538,10 @@ class Pref_Prefs extends Handler_Protected {
<?= htmlspecialchars($pass["title"]) ?>
</td>
<td class='text-muted'>
<?= TimeHelper::make_local_datetime($pass['created'], false) ?>
<?= TimeHelper::make_local_datetime($pass['created']) ?>
</td>
<td class='text-muted'>
<?= TimeHelper::make_local_datetime($pass['last_used'], false) ?>
<?= TimeHelper::make_local_datetime($pass['last_used']) ?>
</td>
</tr>
<?php } ?>

View File

@@ -28,6 +28,41 @@ class Pref_System extends Handler_Administrative {
print json_encode(['rc' => $rc, 'error' => $mailer->error()]);
}
function getscheduledtasks(): void {
?>
<table width='100%' class='event-log'>
<tr>
<th><?= __("Task name") ?></th>
<th><?= __("Schedule") ?></th>
<th><?= __("Last executed") ?></th>
<th><?= __("Duration (seconds)") ?></th>
<th><?= __("Return code") ?></th>
</tr>
<?php
$task_records = ORM::for_table('ttrss_scheduled_tasks')
->order_by_asc(['last_cron_expression', 'task_name'])
->find_many();
foreach ($task_records as $task) {
$row_style = $task->last_rc === 0 ? 'text-success' : 'text-error';
?>
<tr>
<td class="<?= $row_style ?>"><?= $task->task_name ?></td>
<td><?= $task->last_cron_expression ?></td>
<td><?= TimeHelper::make_local_datetime($task->last_run) ?></td>
<td><?= $task->last_duration ?></td>
<td><?= $task->last_rc ?></td>
</tr>
<?php
}
?>
</table>
<?php
}
function getphpinfo(): void {
ob_start();
phpinfo();
@@ -38,16 +73,11 @@ class Pref_System extends Handler_Administrative {
}
private function _log_viewer(int $page, int $severity): void {
$errno_values = [];
switch ($severity) {
case E_USER_ERROR:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR ];
break;
case E_USER_WARNING:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
break;
}
$errno_values = match ($severity) {
E_USER_ERROR => [E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR],
E_USER_WARNING => [E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED],
default => [],
};
if (count($errno_values) > 0) {
$errno_qmarks = arr_qmarks($errno_values);
@@ -148,7 +178,7 @@ class Pref_System extends Handler_Administrative {
<td class='errstr'><?= $line["errstr"] . "\n" . $line["context"] ?></td>
<td class='login'><?= $line["login"] ?></td>
<td class='timestamp'>
<?= TimeHelper::make_local_datetime($line["created_at"], false) ?>
<?= TimeHelper::make_local_datetime($line['created_at']) ?>
</td>
</tr>
<?php } ?>
@@ -210,6 +240,17 @@ class Pref_System extends Handler_Administrative {
</form>
</div>
</div>
<div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">alarm</i> <?= __('Scheduled tasks') ?>'>
<script type='dojo/method' event='onSelected' args='evt'>
if (this.domNode.querySelector('.loading'))
window.setTimeout(() => {
xhr.post("backend.php", {op: 'Pref_System', method: 'getscheduledtasks'}, (reply) => {
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
});
}, 200);
</script>
<span class='loading'><?= __("Loading, please wait...") ?></span>
</div>
<div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">info</i> <?= __('PHP Information') ?>'>
<script type='dojo/method' event='onSelected' args='evt'>
if (this.domNode.querySelector('.loading'))

View File

@@ -6,7 +6,7 @@ class Pref_Users extends Handler_Administrative {
function edit(): void {
$user = ORM::for_table('ttrss_users')
->select_expr("id,login,access_level,email,full_name,otp_enabled")
->select_many('id', 'login', 'access_level', 'email', 'full_name', 'otp_enabled')
->find_one((int)$_REQUEST["id"])
->as_array();
@@ -23,32 +23,25 @@ class Pref_Users extends Handler_Administrative {
function userdetails(): void {
$id = (int) clean($_REQUEST["id"]);
$sth = $this->pdo->prepare("SELECT login,
".SUBSTRING_FOR_DATE."(last_login,1,16) AS last_login,
access_level,
(SELECT COUNT(int_id) FROM ttrss_user_entries
WHERE owner_uid = id) AS stored_articles,
".SUBSTRING_FOR_DATE."(created,1,16) AS created
FROM ttrss_users
WHERE id = ?");
$sth->execute([$id]);
$user = ORM::for_table('ttrss_users')
->table_alias('u')
->select_many('u.login', 'u.access_level')
->select_many_expr([
'created' => 'SUBSTRING_FOR_DATE(u.created,1,16)',
'last_login' => 'SUBSTRING_FOR_DATE(u.last_login,1,16)',
'stored_articles' => '(SELECT COUNT(ue.int_id) FROM ttrss_user_entries ue WHERE ue.owner_uid = u.id)',
])
->find_one($id);
if ($row = $sth->fetch()) {
if ($user) {
$created = TimeHelper::make_local_datetime($user->created);
$last_login = TimeHelper::make_local_datetime($user->last_login);
$last_login = TimeHelper::make_local_datetime(
$row["last_login"], true);
$created = TimeHelper::make_local_datetime(
$row["created"], true);
$stored_articles = $row["stored_articles"];
$sth = $this->pdo->prepare("SELECT COUNT(id) as num_feeds FROM ttrss_feeds
WHERE owner_uid = ?");
$sth->execute([$id]);
$row = $sth->fetch();
$num_feeds = $row["num_feeds"];
$user_owned_feeds = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'site_url')
->where('owner_uid', $id)
->order_by_expr('LOWER(title)')
->find_many();
?>
@@ -64,31 +57,25 @@ class Pref_Users extends Handler_Administrative {
<fieldset>
<label><?= __('Subscribed feeds') ?>:</label>
<?= $num_feeds ?>
<?= count($user_owned_feeds) ?>
</fieldset>
<fieldset>
<label><?= __('Stored articles') ?>:</label>
<?= $stored_articles ?>
<?= $user->stored_articles ?>
</fieldset>
<?php
$sth = $this->pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$id]);
?>
<ul class="panel panel-scrollable list list-unstyled">
<?php while ($row = $sth->fetch()) { ?>
<?php foreach ($user_owned_feeds as $feed) { ?>
<li>
<?php
$icon_url = Feeds::_get_icon_url($row['id'], 'images/blank_icon.gif');
$icon_url = Feeds::_get_icon_url($feed->id, 'images/blank_icon.gif');
?>
<img class="icon" src="<?= htmlspecialchars($icon_url) ?>">
<a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>">
<?= htmlspecialchars($row["title"]) ?>
<a target="_blank" href="<?= htmlspecialchars($feed->site_url) ?>">
<?= htmlspecialchars($feed->title) ?>
</a>
</li>
<?php } ?>
@@ -136,14 +123,9 @@ class Pref_Users extends Handler_Administrative {
foreach ($ids as $id) {
if ($id != $_SESSION["uid"] && $id != 1) {
$sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE owner_uid = ?");
$sth->execute([$id]);
$sth = $this->pdo->prepare("DELETE FROM ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$id]);
$sth = $this->pdo->prepare("DELETE FROM ttrss_users WHERE id = ?");
$sth->execute([$id]);
ORM::for_table('ttrss_tags')->where('owner_uid', $id)->delete_many();
ORM::for_table('ttrss_feeds')->where('owner_uid', $id)->delete_many();
ORM::for_table('ttrss_users')->where('id', $id)->delete_many();
}
}
}
@@ -282,8 +264,8 @@ class Pref_Users extends Handler_Administrative {
</td>
<td><?= $access_level_names[$user["access_level"]] ?></td>
<td><?= $user["num_feeds"] ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["created"], false) ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["last_login"], false) ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user['created']) ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user['last_login']) ?></td>
</tr>
<?php } ?>
</table>

View File

@@ -144,14 +144,12 @@ class Prefs {
Prefs::_PREFS_MIGRATED
];
/** @var Prefs|null */
private static $instance;
private static ?Prefs $instance = null;
/** @var array<string, bool|int|string> */
private $cache = [];
private array $cache = [];
/** @var PDO */
private $pdo;
private ?PDO $pdo = null;
public static function get_instance() : Prefs {
if (self::$instance == null)
@@ -164,10 +162,7 @@ class Prefs {
return isset(self::_DEFAULTS[$pref_name]);
}
/**
* @return bool|int|null|string
*/
static function get_default(string $pref_name) {
static function get_default(string $pref_name): bool|int|null|string {
if (self::is_valid($pref_name))
return self::_DEFAULTS[$pref_name][0];
else
@@ -193,14 +188,14 @@ class Prefs {
/**
* @return array<int, array<string, bool|int|null|string>>
*/
static function get_all(int $owner_uid, ?int $profile_id = null) {
static function get_all(int $owner_uid, ?int $profile_id = null): array {
return self::get_instance()->_get_all($owner_uid, $profile_id);
}
/**
* @return array<int, array<string, bool|int|null|string>>
*/
private function _get_all(int $owner_uid, ?int $profile_id = null) {
private function _get_all(int $owner_uid, ?int $profile_id = null): array {
$rv = [];
$ref = new ReflectionClass(get_class($this));
@@ -247,17 +242,11 @@ class Prefs {
}
}
/**
* @return bool|int|null|string
*/
static function get(string $pref_name, int $owner_uid, ?int $profile_id = null) {
static function get(string $pref_name, int $owner_uid, ?int $profile_id = null): bool|int|null|string {
return self::get_instance()->_get($pref_name, $owner_uid, $profile_id);
}
/**
* @return bool|int|null|string
*/
private function _get(string $pref_name, int $owner_uid, ?int $profile_id) {
private function _get(string $pref_name, int $owner_uid, ?int $profile_id): bool|int|null|string {
if (isset(self::_DEFAULTS[$pref_name])) {
if (!$profile_id || in_array($pref_name, self::_PROFILE_BLACKLIST)) $profile_id = null;
@@ -298,34 +287,22 @@ class Prefs {
return isset($this->cache[$cache_key]);
}
/**
* @return bool|int|null|string
*/
private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id) {
private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id): bool|int|null|string {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
return $this->cache[$cache_key] ?? null;
}
/**
* @param bool|int|string $value
*/
private function _set_cache(string $pref_name, $value, int $owner_uid, ?int $profile_id): void {
private function _set_cache(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id): void {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
$this->cache[$cache_key] = $value;
}
/**
* @param bool|int|string $value
*/
static function set(string $pref_name, $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
static function set(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
return self::get_instance()->_set($pref_name, $value, $owner_uid, $profile_id);
}
/**
* @param bool|int|string $value
*/
private function _set(string $pref_name, $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
private function _set(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
if (!$profile_id) $profile_id = null;
if ($profile_id && in_array($pref_name, self::_PROFILE_BLACKLIST))

View File

@@ -23,7 +23,7 @@ class RPC extends Handler_Protected {
for ($i = 0; $i < $l10n->total; $i++) {
if (isset($l10n->table_originals[$i * 2 + 2]) && $orig = $l10n->get_original_string($i)) {
if(strpos($orig, "\000") !== false) { // Plural forms
if(str_contains($orig, "\000")) { // Plural forms
$key = explode(chr(0), $orig);
$rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular
@@ -42,8 +42,9 @@ class RPC extends Handler_Protected {
function togglepref(): void {
$key = clean($_REQUEST["key"]);
set_pref($key, !get_pref($key));
$value = get_pref($key);
$profile = $_SESSION['profile'] ?? null;
Prefs::set($key, !Prefs::get($key, $_SESSION['uid'], $profile), $_SESSION['uid'], $profile);
$value = Prefs::get($key, $_SESSION['uid'], $profile);
print json_encode(array("param" =>$key, "value" => $value));
}
@@ -53,7 +54,7 @@ class RPC extends Handler_Protected {
$key = clean($_REQUEST['key']);
$value = $_REQUEST['value'];
set_pref($key, $value, $_SESSION["uid"], $key != 'USER_STYLESHEET');
Prefs::set($key, $value, $_SESSION['uid'], $_SESSION['profile'] ?? null, $key != 'USER_STYLESHEET');
print json_encode(array("param" =>$key, "value" => $value));
}
@@ -68,6 +69,8 @@ class RPC extends Handler_Protected {
$sth->execute([$mark, $id, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, [$id]);
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@@ -79,8 +82,6 @@ class RPC extends Handler_Protected {
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute([...$ids, $_SESSION['uid']]);
Article::_purge_orphans();
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@@ -94,6 +95,8 @@ class RPC extends Handler_Protected {
$sth->execute([$pub, $id, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$id]);
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@@ -106,8 +109,6 @@ class RPC extends Handler_Protected {
}
function getAllCounters(): void {
$span = Tracer::start(__METHOD__);
@$seq = (int) $_REQUEST['seq'];
$feed_id_count = (int) ($_REQUEST["feed_id_count"] ?? -1);
@@ -126,7 +127,8 @@ class RPC extends Handler_Protected {
else
$label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? []));
$counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ?
$counters = is_array($feed_ids)
&& !Prefs::get(Prefs::DISABLE_CONDITIONAL_COUNTERS, $_SESSION['uid'], $_SESSION['profile'] ?? null) ?
Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
$reply = [
@@ -134,7 +136,6 @@ class RPC extends Handler_Protected {
'seq' => $seq
];
$span->end();
print json_encode($reply);
}
@@ -176,8 +177,6 @@ class RPC extends Handler_Protected {
}
function sanityCheck(): void {
$span = Tracer::start(__METHOD__);
$_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false);
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
@@ -209,8 +208,6 @@ class RPC extends Handler_Protected {
} else {
print Errors::to_json($error, $error_params);
}
$span->end();
}
/*function completeLabels() {
@@ -248,110 +245,11 @@ class RPC extends Handler_Protected {
function setWidescreen(): void {
$wide = (int) clean($_REQUEST["wide"]);
set_pref(Prefs::WIDESCREEN_MODE, $wide);
Prefs::set(Prefs::WIDESCREEN_MODE, $wide, $_SESSION['uid'], $_SESSION['profile'] ?? null);
print json_encode(["wide" => $wide]);
}
static function updaterandomfeed_real(): void {
$span = Tracer::start(__METHOD__);
$default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL);
// Test if the feed need a update (update interval exceded).
if (Config::get(Config::DB_TYPE) == "pgsql") {
$update_limit_qpart = "AND ((
update_interval = 0
AND (p.value IS NULL OR p.value != '-1')
AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL)
) OR (
update_interval > 0
AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL)
) OR (
update_interval >= 0
AND (p.value IS NULL OR p.value != '-1')
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
))";
} else {
$update_limit_qpart = "AND ((
update_interval = 0
AND (p.value IS NULL OR p.value != '-1')
AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE)
) OR (
update_interval > 0
AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE)
) OR (
update_interval >= 0
AND (p.value IS NULL OR p.value != '-1')
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
))";
}
// Test if feed is currently being updated by another process.
if (Config::get(Config::DB_TYPE) == "pgsql") {
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')";
} else {
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))";
}
$random_qpart = Db::sql_random_function();
$pdo = Db::pdo();
// we could be invoked from public.php with no active session
if (!empty($_SESSION["uid"])) {
$owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]);
} else {
$owner_check_qpart = "";
}
$query = "SELECT f.feed_url,f.id
FROM
ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON
(p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL')
WHERE
f.owner_uid = u.id AND
u.access_level NOT IN (".sprintf("%d, %d", UserHelper::ACCESS_LEVEL_DISABLED, UserHelper::ACCESS_LEVEL_READONLY).")
$owner_check_qpart
$update_limit_qpart
$updstart_thresh_qpart
ORDER BY $random_qpart LIMIT 30";
$res = $pdo->query($query);
$num_updated = 0;
$tstart = time();
while ($line = $res->fetch()) {
$feed_id = $line["id"];
if (time() - $tstart < ini_get("max_execution_time") * 0.7) {
RSSUtils::update_rss_feed($feed_id, true);
++$num_updated;
} else {
break;
}
}
// Purge orphans and cleanup tags
Article::_purge_orphans();
//cleanup_tags(14, 50000);
if ($num_updated > 0) {
print json_encode(array("message" => "UPDATE_COUNTERS",
"num_updated" => $num_updated));
} else {
print json_encode(array("message" => "NOTHING_TO_UPDATE"));
}
$span->end();
}
function updaterandomfeed(): void {
self::updaterandomfeed_real();
}
/**
* @param array<int, int> $ids
*/
@@ -374,6 +272,8 @@ class RPC extends Handler_Protected {
}
$sth->execute([...$ids, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $ids);
}
/**
@@ -398,11 +298,11 @@ class RPC extends Handler_Protected {
}
$sth->execute([...$ids, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $ids);
}
function log(): void {
$span = Tracer::start(__METHOD__);
$msg = clean($_REQUEST['msg'] ?? "");
$file = basename(clean($_REQUEST['file'] ?? ""));
$line = (int) clean($_REQUEST['line'] ?? 0);
@@ -414,13 +314,9 @@ class RPC extends Handler_Protected {
echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
}
$span->end();
}
function checkforupdates(): void {
$span = Tracer::start(__METHOD__);
$rv = ["changeset" => [], "plugins" => []];
$version = Config::get_version(false);
@@ -428,9 +324,12 @@ class RPC extends Handler_Protected {
$git_timestamp = $version["timestamp"] ?? false;
$git_commit = $version["commit"] ?? false;
// TODO: Get this working again. https://tt-rss.org/version.json won't exist after 2025-11-01 (probably).
if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && $git_timestamp) {
$content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
// $content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
$content = false;
/** @phpstan-ignore if.alwaysFalse (intentionally disabling for now) */
if ($content) {
$content = json_decode($content, true);
@@ -446,8 +345,6 @@ class RPC extends Handler_Protected {
$rv["plugins"] = Pref_Prefs::_get_updated_plugins();
}
$span->end();
print json_encode($rv);
}
@@ -455,8 +352,7 @@ class RPC extends Handler_Protected {
* @return array<string, mixed>
*/
private function _make_init_params(): array {
$span = Tracer::start(__METHOD__);
$profile = $_SESSION['profile'] ?? null;
$params = array();
foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS,
@@ -465,21 +361,21 @@ class RPC extends Handler_Protected {
Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::HIDE_READ_SHOWS_SPECIAL,
Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS, Prefs::CDM_ENABLE_GRID] as $param) {
$params[strtolower($param)] = (int) get_pref($param);
$params[strtolower($param)] = (int) Prefs::get($param, $_SESSION['uid'], $profile);
}
$params["safe_mode"] = !empty($_SESSION["safe_mode"]);
$params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES);
$params["icons_url"] = Config::get_self_url() . '/public.php';
$params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME);
$params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE);
$params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT);
$params["default_view_order_by"] = get_pref(Prefs::_DEFAULT_VIEW_ORDER_BY);
$params["default_view_mode"] = Prefs::get(Prefs::_DEFAULT_VIEW_MODE, $_SESSION['uid'], $profile);
$params["default_view_limit"] = (int) Prefs::get(Prefs::_DEFAULT_VIEW_LIMIT, $_SESSION['uid'], $profile);
$params["default_view_order_by"] = Prefs::get(Prefs::_DEFAULT_VIEW_ORDER_BY, $_SESSION['uid'], $profile);
$params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false);
$params["is_default_pw"] = UserHelper::is_default_password();
$params["label_base_index"] = LABEL_BASE_INDEX;
$theme = get_pref(Prefs::USER_CSS_THEME);
$theme = Prefs::get(Prefs::USER_CSS_THEME, $_SESSION['uid'], $profile);
$params["theme"] = theme_exists($theme) ? $theme : "";
$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
@@ -501,16 +397,13 @@ class RPC extends Handler_Protected {
$params["max_feed_id"] = (int) $max_feed_id;
$params["num_feeds"] = (int) $num_feeds;
$params["hotkeys"] = $this->get_hotkeys_map();
$params["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE);
$params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE);
$params["widescreen"] = (int) Prefs::get(Prefs::WIDESCREEN_MODE, $_SESSION['uid'], $profile);
$params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif");
$params["icon_oval"] = $this->image_to_base64("images/oval.svg");
$params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg");
$params["icon_blank"] = $this->image_to_base64("images/blank_icon.gif");
$params["labels"] = Labels::get_all($_SESSION["uid"]);
$span->end();
return $params;
}
@@ -530,8 +423,6 @@ class RPC extends Handler_Protected {
* @return array<string, mixed>
*/
static function _make_runtime_info(): array {
$span = Tracer::start(__METHOD__);
$data = array();
$pdo = Db::pdo();
@@ -546,21 +437,16 @@ class RPC extends Handler_Protected {
$data["max_feed_id"] = (int) $max_feed_id;
$data["num_feeds"] = (int) $num_feeds;
$data['cdm_expanded'] = get_pref(Prefs::CDM_EXPANDED);
$data['cdm_expanded'] = Prefs::get(Prefs::CDM_EXPANDED, $_SESSION['uid'], $_SESSION['profile'] ?? null);
$data["labels"] = Labels::get_all($_SESSION["uid"]);
if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN) {
if (Config::get(Config::DB_TYPE) == 'pgsql') {
$log_interval = "created_at > NOW() - interval '1 hour'";
} else {
$log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)";
}
$sth = $pdo->prepare("SELECT COUNT(id) AS cid
FROM ttrss_error_log
WHERE
errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
$log_interval AND
created_at > NOW() - INTERVAL '1 hour' AND
errstr NOT LIKE '%Returning bool from comparison function is deprecated%' AND
errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'");
$sth->execute();
@@ -597,8 +483,6 @@ class RPC extends Handler_Protected {
}
}
$span->end();
return $data;
}
@@ -798,7 +682,7 @@ class RPC extends Handler_Protected {
if (!empty($omap[$action])) {
foreach ($omap[$action] as $sequence) {
if (strpos($sequence, "|") !== false) {
if (str_contains($sequence, "|")) {
$sequence = substr($sequence,
strpos($sequence, "|")+1,
strlen($sequence));
@@ -809,16 +693,11 @@ class RPC extends Handler_Protected {
if (strlen($keys[$i]) > 1) {
$tmp = '';
foreach (str_split($keys[$i]) as $c) {
switch ($c) {
case '*':
$tmp .= __('Shift') . '+';
break;
case '^':
$tmp .= __('Ctrl') . '+';
break;
default:
$tmp .= $c;
}
$tmp .= match ($c) {
'*' => __('Shift') . '+',
'^' => __('Ctrl') . '+',
default => $c,
};
}
$keys[$i] = $tmp;
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@ class Sanitizer {
$entries = $xpath->query('//*');
foreach ($entries as $entry) {
/** @var DOMElement $entry */
if (!in_array($entry->nodeName, $allowed_elements)) {
$entry->parentNode->removeChild($entry);
}
@@ -18,11 +20,11 @@ class Sanitizer {
foreach ($entry->attributes as $attr) {
if (strpos($attr->nodeName, 'on') === 0) {
if (str_starts_with($attr->nodeName, 'on')) {
array_push($attrs_to_remove, $attr);
}
if (strpos($attr->nodeName, "data-") === 0) {
if (str_starts_with($attr->nodeName, 'data-')) {
array_push($attrs_to_remove, $attr);
}
@@ -57,18 +59,76 @@ class Sanitizer {
return parse_url(Config::get_self_url(), PHP_URL_SCHEME) == 'https';
}
/** @param array<string> $words */
public static function highlight_words_str(string $str, array $words) : string {
$doc = new DOMDocument();
if ($doc->loadHTML('<?xml encoding="UTF-8"><span>' . $str . '</span>')) {
$xpath = new DOMXPath($doc);
if (self::highlight_words($doc, $xpath, $words)) {
$res = $doc->saveHTML();
/* strip everything outside of <body>...</body> */
$res_frag = array();
if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
return $res_frag[1];
} else {
return $res;
}
}
}
return $str;
}
/** @param array<string> $words */
public static function highlight_words(DOMDocument &$doc, DOMXPath $xpath, array $words) : bool {
$rv = false;
foreach ($words as $word) {
// http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph
$elements = $xpath->query("//*/text()");
foreach ($elements as $child) {
$fragment = $doc->createDocumentFragment();
$text = $child->textContent;
while (($pos = mb_stripos($text, $word)) !== false) {
$fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos)));
$word = mb_substr($text, (int)$pos, mb_strlen($word));
$highlight = $doc->createElement('span');
$highlight->appendChild(new DOMText($word));
$highlight->setAttribute('class', 'highlight');
$fragment->appendChild($highlight);
$text = mb_substr($text, $pos + mb_strlen($word));
}
if (!empty($text)) $fragment->appendChild(new DOMText($text));
$child->parentNode->replaceChild($fragment, $child);
$rv = true;
}
}
return $rv;
}
/**
* @param array<int, string>|null $highlight_words Words to highlight in the HTML output.
*
* @return false|string The HTML, or false if an error occurred.
*/
public static function sanitize(string $str, ?bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null) {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("Sanitizer::sanitize");
public static function sanitize(string $str, ?bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null): false|string {
if (!$owner && isset($_SESSION["uid"]))
$owner = $_SESSION["uid"];
$profile = isset($_SESSION['uid']) && $owner == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
$res = trim($str); if (!$res) return '';
$doc = new DOMDocument();
@@ -81,6 +141,7 @@ class Sanitizer {
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
if ($entry->hasAttribute('href')) {
@@ -117,8 +178,7 @@ class Sanitizer {
}
if ($entry->hasAttribute('src') &&
($owner && get_pref(Prefs::STRIP_IMAGES, $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) {
($owner && Prefs::get(Prefs::STRIP_IMAGES, $owner, $profile)) || $force_remove_images || ($_SESSION['bw_limit'] ?? false)) {
$p = $doc->createElement('p');
$a = $doc->createElement('a');
@@ -143,6 +203,8 @@ class Sanitizer {
}
$entries = $xpath->query('//iframe');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
if (!self::iframe_whitelisted($entry)) {
$entry->setAttribute('sandbox', 'allow-scripts');
@@ -194,34 +256,8 @@ class Sanitizer {
$div->appendChild($entry);
}
if (is_array($highlight_words)) {
foreach ($highlight_words as $word) {
// http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph
$elements = $xpath->query("//*/text()");
foreach ($elements as $child) {
$fragment = $doc->createDocumentFragment();
$text = $child->textContent;
while (($pos = mb_stripos($text, $word)) !== false) {
$fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos)));
$word = mb_substr($text, (int)$pos, mb_strlen($word));
$highlight = $doc->createElement('span');
$highlight->appendChild(new DOMText($word));
$highlight->setAttribute('class', 'highlight');
$fragment->appendChild($highlight);
$text = mb_substr($text, $pos + mb_strlen($word));
}
if (!empty($text)) $fragment->appendChild(new DOMText($text));
$child->parentNode->replaceChild($fragment, $child);
}
}
}
if (is_array($highlight_words))
self::highlight_words($doc, $xpath, $highlight_words);
$res = $doc->saveHTML();

171
classes/Scheduler.php Normal file
View File

@@ -0,0 +1,171 @@
<?php
class Scheduler {
private static ?Scheduler $instance = null;
const TASK_RC_EXCEPTION = -100;
const DEFAULT_NAME = 'Default Scheduler';
/** @var array<string, mixed> */
private array $scheduled_tasks = [];
private string $name;
function __construct(string $name = self::DEFAULT_NAME) {
$this->set_name($name);
if ($name === self::DEFAULT_NAME) {
$this->add_scheduled_task('purge_orphaned_scheduled_tasks', '@weekly',
function() {
return $this->purge_orphaned_tasks();
}
);
}
}
public static function getInstance(): Scheduler {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
/** Sets specific identifier for this instance of Scheduler used in debug logging */
public function set_name(string $name) : void {
$this->name = $name;
}
/**
* Adds a backend scheduled task which will be executed by updater (if due) during housekeeping.
*
* The granularity is not strictly guaranteed, housekeeping is invoked several times per hour
* depending on how fast feed batch was processed, but no more than once per minute.
*
* Tasks do not run in user context. Task names may not overlap. Plugins should register tasks
* via PluginHost methods (to be implemented later).
*
* Tasks should return an integer value (return code) which is stored in the database, a value of
* 0 is considered successful.
*
* @param string $task_name unique name for this task, plugins should prefix this with plugin name
* @param string $cron_expression schedule for this task in cron format
* @param Closure $callback task code that gets executed
*/
function add_scheduled_task(string $task_name, string $cron_expression, Closure $callback) : bool {
$task_name = strtolower($task_name);
if (isset($this->scheduled_tasks[$task_name])) {
user_error("[$this->name] Attempted to override already registered scheduled task $task_name", E_USER_WARNING);
return false;
} else {
try {
$cron = new Cron\CronExpression($cron_expression);
} catch (InvalidArgumentException $e) {
user_error("[$this->name] Attempt to register scheduled task $task_name failed: " . $e->getMessage(), E_USER_WARNING);
return false;
}
$this->scheduled_tasks[$task_name] = [
"cron" => $cron,
"callback" => $callback,
];
return true;
}
}
/**
* Execute scheduled tasks which are due to run and record last run timestamps.
*/
function run_due_tasks() : void {
Debug::log("[$this->name] Processing all scheduled tasks...");
$tasks_succeeded = 0;
$tasks_failed = 0;
foreach ($this->scheduled_tasks as $task_name => $task) {
$task_record = ORM::for_table('ttrss_scheduled_tasks')
->where('task_name', $task_name)
->find_one();
if ($task_record)
$last_run = $task_record->last_run;
else
$last_run = '1970-01-01 00:00';
// because we don't schedule tasks every minute, we assume that task is due if its
// next estimated run based on previous timestamp is in the past
if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) {
Debug::log("=> Scheduled task $task_name is due, executing...");
$task_started = time();
try {
$rc = (int) $task['callback']();
} catch (Exception $e) {
user_error("[$this->name] Scheduled task $task_name failed with exception: " . $e->getMessage(), E_USER_WARNING);
$rc = self::TASK_RC_EXCEPTION;
}
$task_duration = time() - $task_started;
if ($rc === 0) {
++$tasks_succeeded;
Debug::log("<= Scheduled task $task_name has finished in $task_duration seconds.");
} else {
$tasks_failed++;
Debug::log("!! Scheduled task $task_name has failed with RC: $rc after $task_duration seconds.");
}
if ($task_record) {
$task_record->last_run = Db::NOW();
$task_record->last_duration = $task_duration;
$task_record->last_rc = $rc;
$task_record->last_cron_expression = $task['cron']->getExpression();
$task_record->save();
} else {
$task_record = ORM::for_table('ttrss_scheduled_tasks')->create();
$task_record->set([
'task_name' => $task_name,
'last_duration' => $task_duration,
'last_rc' => $rc,
'last_run' => Db::NOW(),
'last_cron_expression' => $task['cron']->getExpression()
]);
$task_record->save();
}
}
}
Debug::log("[$this->name] Processing scheduled tasks finished with $tasks_succeeded tasks succeeded and $tasks_failed tasks failed.");
}
/**
* Purge records of scheduled tasks that aren't currently registered
* and haven't ran for a long time.
*
* @return int 0 if successful, 1 on failure
*/
private function purge_orphaned_tasks(): int {
if (!$this->scheduled_tasks) {
Debug::log(__METHOD__ . ' was invoked before scheduled tasks have been registered. This should never happen.');
return 1;
}
$result = ORM::for_table('ttrss_scheduled_tasks')
->where_not_in('task_name', array_keys($this->scheduled_tasks))
->where_raw("last_run < NOW() - INTERVAL '5 weeks'")
->delete_many();
if ($result) {
$deleted_count = ORM::get_last_statement()->rowCount();
if ($deleted_count)
Debug::log("Purged {$deleted_count} orphaned scheduled tasks.");
}
return $result ? 0 : 1;
}
}

View File

@@ -9,7 +9,7 @@ class Sessions implements \SessionHandlerInterface {
private string $session_name;
public function __construct() {
$this->session_expire = min(2147483647 - time() - 1, max(Config::get(Config::SESSION_COOKIE_LIFETIME), 86400));
$this->session_expire = min(2147483647 - time() - 1, Config::get(Config::SESSION_COOKIE_LIFETIME));
$this->session_name = Config::get(Config::SESSION_NAME);
}
@@ -29,11 +29,11 @@ class Sessions implements \SessionHandlerInterface {
}
/**
* Extend the validity of the PHP session cookie (if it exists)
* Extend the validity of the PHP session cookie (if it exists) and is persistent (expire > 0)
* @return bool Whether the new cookie was set successfully
*/
public function extend_session(): bool {
if (isset($_COOKIE[$this->session_name])) {
if (isset($_COOKIE[$this->session_name]) && $this->session_expire > 0) {
return setcookie($this->session_name,
$_COOKIE[$this->session_name],
time() + $this->session_expire,
@@ -53,17 +53,22 @@ class Sessions implements \SessionHandlerInterface {
return true;
}
/**
* @todo set return type to string|false, and remove ReturnTypeWillChange, when min supported is PHP 8
* @return string|false
*/
#[\ReturnTypeWillChange]
public function read(string $id) {
public function read(string $id): false|string {
$sth = Db::pdo()->prepare('SELECT data FROM ttrss_sessions WHERE id=?');
$sth->execute([$id]);
if ($row = $sth->fetch()) {
return base64_decode($row['data']);
$data = base64_decode($row['data']);
if (Config::get(Config::ENCRYPTION_KEY)) {
$unserialized_data = @unserialize($data); // avoid leaking plaintext session via error message
if ($unserialized_data !== false)
return Crypt::decrypt_string($unserialized_data);
}
// if encryption key is missing or session data is not in serialized format, assume plaintext data and return as-is
return $data;
}
$expire = time() + $this->session_expire;
@@ -74,7 +79,12 @@ class Sessions implements \SessionHandlerInterface {
}
public function write(string $id, string $data): bool {
if (Config::get(Config::ENCRYPTION_KEY))
$data = serialize(Crypt::encrypt_string($data));
$data = base64_encode($data);
$expire = time() + $this->session_expire;
$sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?');
@@ -95,11 +105,9 @@ class Sessions implements \SessionHandlerInterface {
}
/**
* @todo set return type to int|false, and remove ReturnTypeWillChange, when min supported is PHP 8
* @return int|false the number of deleted sessions on success, or false on failure
*/
#[\ReturnTypeWillChange]
public function gc(int $max_lifetime) {
public function gc(int $max_lifetime): false|int {
$result = Db::pdo()->query('DELETE FROM ttrss_sessions WHERE expire < ' . time());
return $result === false ? false : $result->rowCount();
}

View File

@@ -5,7 +5,7 @@ class Templator extends MiniTemplator {
/* this reads tt-rss template from templates.local/ or templates/ if only base filename is given */
function readTemplateFromFile ($fileName) {
if (strpos($fileName, "/") === false) {
if (!str_contains($fileName, "/")) {
$fileName = basename($fileName);

View File

@@ -2,29 +2,39 @@
class TimeHelper {
static function smart_date_time(int $timestamp, int $tz_offset = 0, ?int $owner_uid = null, bool $eta_min = false): string {
// i.e. if the Unix epoch
if ($timestamp - $tz_offset === 0)
return __('Never');
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
if ($eta_min && time() + $tz_offset - $timestamp < 3600) {
return T_sprintf("%d min", date("i", time() + $tz_offset - $timestamp));
} else if (date("Y.m.d", $timestamp) == date("Y.m.d", time() + $tz_offset)) {
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
if (strpos((strtolower($format)), "a") === false)
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
if (!str_contains((strtolower($format)), "a"))
return date("G:i", $timestamp);
else
return date("g:i a", $timestamp);
} else if (date("Y", $timestamp) == date("Y", time() + $tz_offset)) {
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
return date($format, $timestamp);
} else {
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
return date($format, $timestamp);
}
}
static function make_local_datetime(?string $timestamp, bool $long, ?int $owner_uid = null,
/**
* @param bool $long Whether to display the datetime in a 'long' format. Only used if $no_smart_dt is true.
*/
static function make_local_datetime(?string $timestamp, bool $long = false, ?int $owner_uid = null,
bool $no_smart_dt = false, bool $eta_min = false): string {
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
if (!$timestamp) $timestamp = '1970-01-01 0:00';
global $utc_tz;
@@ -37,7 +47,7 @@ class TimeHelper {
# We store date in UTC internally
$dt = new DateTime($timestamp, $utc_tz);
$user_tz_string = get_pref(Prefs::USER_TIMEZONE, $owner_uid);
$user_tz_string = Prefs::get(Prefs::USER_TIMEZONE, $owner_uid);
if ($user_tz_string != 'Automatic') {
@@ -59,9 +69,9 @@ class TimeHelper {
$tz_offset, $owner_uid, $eta_min);
} else {
if ($long)
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
else
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
return date($format, $user_timestamp);
}

View File

@@ -1,216 +0,0 @@
<?php
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\API\Trace\SpanContextInterface;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\TraceFlags;
use OpenTelemetry\API\Trace\TraceStateInterface;
use OpenTelemetry\Context\ContextInterface;
use OpenTelemetry\Context\ContextKey;
use OpenTelemetry\Context\ContextKeyInterface;
use OpenTelemetry\Context\ImplicitContextKeyedInterface;
use OpenTelemetry\Context\ScopeInterface;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SDK\Trace\Sampler\AlwaysOnSampler;
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SemConv\ResourceAttributes;
class DummyContextInterface implements ContextInterface {
/** @var DummyContextInterface */
private static $instance;
public function __construct() {
self::$instance = $this;
}
/** @phpstan-ignore-next-line */
public static function createKey(string $key): ContextKeyInterface { return new ContextKey(); }
public static function getCurrent(): ContextInterface { return self::$instance; }
public function activate(): ScopeInterface { return new DummyScopeInterface(); }
public function with(ContextKeyInterface $key, $value): ContextInterface { return $this; }
public function withContextValue(ImplicitContextKeyedInterface $value): ContextInterface { return $this; }
public function get(ContextKeyInterface $key) { return new ContextKey(); }
}
class DummySpanContextInterface implements SpanContextInterface {
/** @var DummySpanContextInterface $instance */
private static $instance;
public function __construct() {
self::$instance = $this;
}
public static function createFromRemoteParent(string $traceId, string $spanId, int $traceFlags = TraceFlags::DEFAULT, ?TraceStateInterface $traceState = null): SpanContextInterface { return self::$instance; }
public static function getInvalid(): SpanContextInterface { return self::$instance; }
public static function create(string $traceId, string $spanId, int $traceFlags = TraceFlags::DEFAULT, ?TraceStateInterface $traceState = null): SpanContextInterface { return self::$instance; }
public function getTraceId(): string { return ""; }
public function getTraceIdBinary(): string { return ""; }
public function getSpanId(): string { return ""; }
public function getSpanIdBinary(): string { return ""; }
public function getTraceFlags(): int { return 0; }
public function getTraceState(): ?TraceStateInterface { return null; }
public function isValid(): bool { return false; }
public function isRemote(): bool { return false; }
public function isSampled(): bool { return false; }
}
class DummyScopeInterface implements ScopeInterface {
public function detach(): int { return 0; }
}
class DummySpanInterface implements SpanInterface {
/** @var DummySpanInterface $instance */
private static $instance;
public function __construct() {
self::$instance = $this;
}
public static function fromContext(ContextInterface $context): SpanInterface { return self::$instance; }
public static function getCurrent(): SpanInterface { return self::$instance; }
public static function getInvalid(): SpanInterface { return self::$instance; }
public static function wrap(SpanContextInterface $spanContext): SpanInterface { return self::$instance; }
public function getContext(): SpanContextInterface { return new DummySpanContextInterface(); }
public function isRecording(): bool { return false; }
/** @phpstan-ignore-next-line */
public function setAttribute(string $key, $value): SpanInterface { return self::$instance; }
/** @phpstan-ignore-next-line */
public function setAttributes(iterable $attributes): SpanInterface { return self::$instance; }
/** @phpstan-ignore-next-line */
public function addEvent(string $name, iterable $attributes = [], ?int $timestamp = null): SpanInterface { return $this; }
/** @phpstan-ignore-next-line */
public function recordException(Throwable $exception, iterable $attributes = []): SpanInterface { return $this; }
public function updateName(string $name): SpanInterface { return $this; }
public function setStatus(string $code, ?string $description = null): SpanInterface { return $this; }
public function end(?int $endEpochNanos = null): void { }
public function activate(): ScopeInterface { return new DummyScopeInterface(); }
public function storeInContext(ContextInterface $context): ContextInterface { return new DummyContextInterface(); }
}
class Tracer {
/** @var Tracer $instance */
private static $instance = null;
/** @var OpenTelemetry\SDK\Trace\TracerProviderInterface $tracerProvider */
private $tracerProvider = null;
/** @var OpenTelemetry\API\Trace\TracerInterface $tracer */
private $tracer = null;
public function __construct() {
$OPENTELEMETRY_ENDPOINT = Config::get(Config::OPENTELEMETRY_ENDPOINT);
if ($OPENTELEMETRY_ENDPOINT) {
$transport = (new OtlpHttpTransportFactory())->create($OPENTELEMETRY_ENDPOINT, 'application/x-protobuf');
$exporter = new SpanExporter($transport);
$resource = ResourceInfoFactory::emptyResource()->merge(
ResourceInfo::create(Attributes::create(
[ResourceAttributes::SERVICE_NAME => Config::get(Config::OPENTELEMETRY_SERVICE)]
), ResourceAttributes::SCHEMA_URL),
);
$this->tracerProvider = TracerProvider::builder()
->addSpanProcessor(new SimpleSpanProcessor($exporter))
->setResource($resource)
->setSampler(new ParentBased(new AlwaysOnSampler()))
->build();
$this->tracer = $this->tracerProvider->getTracer('io.opentelemetry.contrib.php');
$context = TraceContextPropagator::getInstance()->extract(getallheaders());
$span = $this->tracer->spanBuilder($_SESSION['name'] ?? 'not logged in')
->setParent($context)
->setSpanKind(SpanKind::KIND_SERVER)
->setAttribute('php.request', json_encode($_REQUEST))
->setAttribute('php.server', json_encode($_SERVER))
->setAttribute('php.session', json_encode($_SESSION ?? []))
->startSpan();
$scope = $span->activate();
register_shutdown_function(function() use ($span, $scope) {
$span->end();
$scope->detach();
$this->tracerProvider->shutdown();
});
}
}
/**
* @param string $name
* @return OpenTelemetry\API\Trace\SpanInterface
*/
private function _start(string $name) {
if ($this->tracer != null) {
$span = $this->tracer
->spanBuilder($name)
->setSpanKind(SpanKind::KIND_SERVER)
->startSpan();
$span->activate();
} else {
$span = new DummySpanInterface();
}
return $span;
}
/**
* @param string $name
* @return OpenTelemetry\API\Trace\SpanInterface
*/
public static function start(string $name) {
return self::get_instance()->_start($name);
}
public static function get_instance() : Tracer {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
}

View File

@@ -65,7 +65,7 @@ class UrlHelper {
$rel_url,
string $owner_element = "",
string $owner_attribute = "",
string $content_type = "") {
string $content_type = ""): false|string {
$rel_parts = parse_url($rel_url);
@@ -86,7 +86,7 @@ class UrlHelper {
return self::validate($rel_url);
// protocol-relative URL (rare but they exist)
} else if (strpos($rel_url, "//") === 0) {
} else if (str_starts_with($rel_url, "//")) {
return self::validate("https:" . $rel_url);
// allow some extra schemes for A href
} else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) &&
@@ -118,10 +118,10 @@ class UrlHelper {
// 1. absolute relative path (/test.html) = no-op, proceed as is
// 2. dotslash relative URI (./test.html) - strip "./", append base path
if (strpos($rel_parts['path'], './') === 0) {
if (str_starts_with($rel_parts['path'], './')) {
$rel_parts['path'] = $base_path . substr($rel_parts['path'], 2);
// 3. anything else relative (test.html) - append dirname() of base path
} else if (strpos($rel_parts['path'], '/') !== 0) {
} else if (!str_starts_with($rel_parts['path'], '/')) {
$rel_parts['path'] = $base_path . $rel_parts['path'];
}
@@ -136,12 +136,12 @@ class UrlHelper {
/** extended filtering involves validation for safe ports and loopback
* @return false|string false if something went wrong, otherwise the URL string
*/
static function validate(string $url, bool $extended_filtering = false) {
static function validate(string $url, bool $extended_filtering = false): false|string {
$url = clean($url);
# fix protocol-relative URLs
if (strpos($url, "//") === 0)
if (str_starts_with($url, "//"))
$url = "https:" . $url;
$tokens = parse_url($url);
@@ -191,19 +191,15 @@ class UrlHelper {
if (!in_array($tokens['port'] ?? '', [80, 443, '']))
return false;
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0)
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1'
|| str_starts_with($tokens['host'], '127.'))
return false;
}
return $url;
}
/**
* @return false|string
*/
static function resolve_redirects(string $url, int $timeout) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
static function resolve_redirects(string $url, int $timeout): false|string {
$client = self::get_client();
try {
@@ -218,14 +214,11 @@ class UrlHelper {
],
]);
} catch (Exception $ex) {
$span->setAttribute('error', (string) $ex);
$span->end();
return false;
}
// If a history header value doesn't exist there was no redirection and the original URL is fine.
$history_header = $response->getHeader(GuzzleHttp\RedirectMiddleware::HISTORY_HEADER);
$span->end();
return ($history_header ? end($history_header) : $url);
}
@@ -235,11 +228,9 @@ class UrlHelper {
*/
// TODO: max_size currently only works for CURL transfers
// TODO: multiple-argument way is deprecated, first parameter is a hash now
public static function fetch($options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false,
public static function fetch(array|string $options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false,
4: $post_query = false, 5: $timeout = false, 6: $timestamp = 0, 7: $useragent = false, 8: $encoding = false,
9: $auth_type = "basic" */) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
9: $auth_type = "basic" */): false|string {
self::$fetch_last_error = "";
self::$fetch_last_error_code = -1;
@@ -299,19 +290,18 @@ class UrlHelper {
if (!$url) {
self::$fetch_last_error = 'Requested URL failed extended validation.';
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
$url_host = parse_url($url, PHP_URL_HOST);
$ip_addr = gethostbyname($url_host);
// this skip is needed for integration tests, please don't enable in production
if (!getenv('__URLHELPER_ALLOW_LOOPBACK')) {
$url_host = parse_url($url, PHP_URL_HOST);
$ip_addr = gethostbyname($url_host);
if (!$ip_addr || strpos($ip_addr, '127.') === 0) {
self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
if (!$ip_addr || str_starts_with($ip_addr, '127.')) {
self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
return false;
}
}
$req_options = [
@@ -392,8 +382,6 @@ class UrlHelper {
} catch (\LengthException $ex) {
// Either 'Content-Length' indicated the download limit would be exceeded, or the transfer actually exceeded the download limit.
self::$fetch_last_error = $ex->getMessage();
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
} catch (GuzzleHttp\Exception\GuzzleException $ex) {
self::$fetch_last_error = $ex->getMessage();
@@ -407,13 +395,12 @@ class UrlHelper {
// to attempt compatibility with unusual configurations.
if ($login && $pass && self::$fetch_last_error_code === 403 && $auth_type !== 'any') {
$options['auth_type'] = 'any';
$span->end();
return self::fetch($options);
}
self::$fetch_last_content_type = $ex->getResponse()->getHeaderLine('content-type');
if ($type && strpos(self::$fetch_last_content_type, "$type") === false)
if ($type && !str_contains(self::$fetch_last_content_type, "$type"))
self::$fetch_last_error_content = (string) $ex->getResponse()->getBody();
} elseif (array_key_exists('errno', $ex->getHandlerContext())) {
$errno = (int) $ex->getHandlerContext()['errno'];
@@ -424,15 +411,11 @@ class UrlHelper {
if (($errno === \CURLE_WRITE_ERROR || $errno === \CURLE_BAD_CONTENT_ENCODING) &&
$ex->getRequest()->getHeaderLine('accept-encoding') !== 'none') {
$options['encoding'] = 'none';
$span->end();
return self::fetch($options);
}
}
}
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
@@ -449,44 +432,41 @@ class UrlHelper {
// This shouldn't be necessary given the checks that occur during potential redirects, but we'll do it anyway.
if (!self::validate(self::$fetch_effective_url, true)) {
self::$fetch_last_error = "URL received after redirection failed extended validation.";
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
// @phpstan-ignore argument.type (prior validation ensures the host value exists)
self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST));
if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, '127.') === 0) {
self::$fetch_last_error = 'URL hostname received after redirection failed to resolve or resolved to a loopback address (' .
self::$fetch_effective_ip_addr . ')';
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
// this skip is needed for integration tests, please don't enable in production
if (!getenv('__URLHELPER_ALLOW_LOOPBACK')) {
if (!self::$fetch_effective_ip_addr || str_starts_with(self::$fetch_effective_ip_addr, '127.')) {
self::$fetch_last_error = 'URL hostname received after redirection failed to resolve or resolved to a loopback address (' .
self::$fetch_effective_ip_addr . ')';
return false;
}
}
$body = (string) $response->getBody();
if (!$body) {
self::$fetch_last_error = 'Successful response, but no content was received.';
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
$span->end();
return $body;
}
/**
* @return false|string false if the provided URL didn't match expected patterns, otherwise the video ID string
*/
public static function url_to_youtube_vid(string $url) {
public static function url_to_youtube_vid(string $url): false|string {
$url = str_replace("youtube.com", "youtube-nocookie.com", $url);
$regexps = [
"/\/\/www\.youtube-nocookie\.com\/v\/([\w-]+)/",
"/\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)/",
"/\/\/www\.youtube-nocookie\.com\/watch?v=([\w-]+)/",
"/\/\/www\.youtube-nocookie\.com\/watch\?v=([\w-]+)/",
"/\/\/youtu.be\/([\w-]+)/",
];

View File

@@ -50,7 +50,7 @@ class UserHelper {
*/
public static function map_access_level(int $level) : int {
if (in_array($level, self::ACCESS_LEVELS)) {
/** @phpstan-ignore-next-line */
/** @phpstan-ignore return.type (yes it is a UserHelper::ACCESS_LEVEL_* value) */
return $level;
} else {
user_error("Passed invalid user access level: $level", E_USER_WARNING);
@@ -125,10 +125,6 @@ class UserHelper {
if (empty($_SESSION["csrf_token"]))
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
if (Config::get_schema_version() >= 120) {
$_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $owner_uid);
}
}
static function load_user_plugins(int $owner_uid, ?PluginHost $pluginhost = null): void {
@@ -136,7 +132,8 @@ class UserHelper {
if (!$pluginhost) $pluginhost = PluginHost::getInstance();
if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) {
$plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
$plugins = Prefs::get(Prefs::_ENABLED_PLUGINS, $owner_uid, $profile);
$pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid);
@@ -184,15 +181,13 @@ class UserHelper {
$_SESSION["last_login_update"] = time();
}
if ($_SESSION["uid"]) {
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
}
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
}
}
static function print_user_stylesheet(): void {
$value = get_pref(Prefs::USER_STYLESHEET);
$value = Prefs::get(Prefs::USER_STYLESHEET, $_SESSION['uid'], $_SESSION['profile'] ?? null);
if ($value) {
print "<style type='text/css' id='user_css_style'>";
@@ -367,7 +362,6 @@ class UserHelper {
/**
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
* @return bool
* @throws PDOException
* @throws Exception
*/
@@ -380,31 +374,19 @@ class UserHelper {
*
* @return false|string False if the password couldn't be hashed, otherwise the hash string.
*/
static function hash_password(string $pass, string $salt, string $algo = self::HASH_ALGOS[0]) {
$pass_hash = "";
static function hash_password(string $pass, string $salt, string $algo = self::HASH_ALGOS[0]): false|string {
$pass_hash = match ($algo) {
self::HASH_ALGO_SHA1 => sha1($pass),
self::HASH_ALGO_SHA1X => sha1("$salt:$pass"),
self::HASH_ALGO_MODE2, self::HASH_ALGO_SSHA256 => hash('sha256', $salt . $pass),
self::HASH_ALGO_SSHA512 => hash('sha512', $salt . $pass),
default => null,
};
switch ($algo) {
case self::HASH_ALGO_SHA1:
$pass_hash = sha1($pass);
break;
case self::HASH_ALGO_SHA1X:
$pass_hash = sha1("$salt:$pass");
break;
case self::HASH_ALGO_MODE2:
case self::HASH_ALGO_SSHA256:
$pass_hash = hash('sha256', $salt . $pass);
break;
case self::HASH_ALGO_SSHA512:
$pass_hash = hash('sha512', $salt . $pass);
break;
default:
user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR);
}
if ($pass_hash === null)
user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR);
if ($pass_hash)
return "$algo:$pass_hash";
else
return false;
return $pass_hash ? "$algo:$pass_hash" : false;
}
/**
@@ -495,7 +477,6 @@ class UserHelper {
/**
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
* @param string $password password to compare hash against
* @return bool
*/
static function user_has_password(?int $owner_uid, string $password) : bool {
if ($owner_uid) {
@@ -503,7 +484,6 @@ class UserHelper {
return $authenticator->check_password($owner_uid, $password);
} else {
/** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if ($authenticator &&

View File

@@ -9,7 +9,7 @@
{
"name": "j4mie/idiorm",
"type": "vcs",
"url": "https://dev.tt-rss.org/fox/idiorm.git"
"url": "https://github.com/tt-rss/tt-rss-idiorm.git"
}
],
"autoload": {
@@ -21,17 +21,16 @@
]
},
"require": {
"spomky-labs/otphp": "^10.0",
"chillerlan/php-qrcode": "^4.3.3",
"spomky-labs/otphp": "^11.3",
"chillerlan/php-qrcode": "^5.0.3",
"mervick/material-design-icons": "^2.2",
"j4mie/idiorm": "dev-master",
"open-telemetry/exporter-otlp": "^1.0",
"php-http/guzzle7-adapter": "^1.0",
"soundasleep/html2text": "^2.1",
"guzzlehttp/guzzle": "^7.0"
"guzzlehttp/guzzle": "^7.0",
"dragonmantank/cron-expression": "^3.4"
},
"require-dev": {
"phpstan/phpstan": "1.10.3",
"phpstan/phpstan": "2.1.30",
"phpunit/phpunit": "9.5.16",
"phpunit/php-code-coverage": "^9.2"
}

1622
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,6 @@
etc.
See this page for more information: https://tt-rss.org/wiki/GlobalConfig
See this page for more information: https://github.com/tt-rss/tt-rss/wiki/Global-Config
*/

View File

@@ -221,7 +221,7 @@ function _color_hue2rgb(float $m1, float $m2, float $h): int {
* @return array{0: int, 1: int, 2: int}
*/
function _color_unpack(string $hex, bool $normalize = false): array {
$hex = strpos($hex, '#') !== 0 ? _resolve_htmlcolor($hex) : substr($hex, 1);
$hex = str_starts_with($hex, '#') ? substr($hex, 1) : _resolve_htmlcolor($hex);
if (strlen($hex) == 4) {
$hex = $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3];

View File

@@ -46,7 +46,7 @@
*/
function input_tag(string $name, string $value, string $type = "text", array $attributes = [], string $id = ""): string {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='dijit.form.TextBox'" : "";
$dojo_type = str_contains($attributes_str, "dojoType") ? "" : "dojoType='dijit.form.TextBox'";
return "<input name=\"".htmlspecialchars($name)."\" $dojo_type ".attributes_to_string($attributes)." id=\"".htmlspecialchars($id)."\"
type=\"$type\" value=\"".htmlspecialchars($value)."\">";
@@ -56,21 +56,21 @@
* @param array<string, mixed> $attributes
*/
function number_spinner_tag(string $name, string $value, array $attributes = [], string $id = ""): string {
return input_tag($name, $value, "text", array_merge(["dojoType" => "dijit.form.NumberSpinner"], $attributes), $id);
return input_tag($name, $value, 'text', ['dojoType' => 'dijit.form.NumberSpinner', ...$attributes], $id);
}
/**
* @param array<string, mixed> $attributes
*/
function submit_tag(string $value, array $attributes = []): string {
return button_tag($value, "submit", array_merge(["class" => "alt-primary"], $attributes));
return button_tag($value, 'submit', ['class' => 'alt-primary', ...$attributes]);
}
/**
* @param array<string, mixed> $attributes
*/
function cancel_dialog_tag(string $value, array $attributes = []): string {
return button_tag($value, "", array_merge(["onclick" => "App.dialogOf(this).hide()"], $attributes));
return button_tag($value, '', ['onclick' => 'App.dialogOf(this).hide()', ...$attributes]);
}
/**
@@ -81,13 +81,12 @@
}
/**
* @param mixed $value
* @param array<int|string, string> $values
* @param array<string, mixed> $attributes
*/
function select_tag(string $name, $value, array $values, array $attributes = [], string $id = ""): string {
function select_tag(string $name, mixed $value, array $values, array $attributes = [], string $id = ""): string {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
$dojo_type = str_contains($attributes_str, "dojoType") ? "" : "dojoType='fox.form.Select'";
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";
@@ -116,7 +115,7 @@
*/
function select_hash(string $name, $value, array $values, array $attributes = [], string $id = ""): string {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
$dojo_type = str_contains($attributes_str, "dojoType") ? "" : "dojoType='fox.form.Select'";
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";

View File

@@ -5,15 +5,13 @@
*/
function stylesheet_tag(string $filename, array $attributes = []): string {
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"href" => "$filename?" . filemtime($filename),
"rel" => "stylesheet",
"type" => "text/css",
"data-orig-href" => $filename
],
$attributes));
$attributes_str = \Controls\attributes_to_string([
'href' => "$filename?" . filemtime($filename),
'rel' => 'stylesheet',
'type' => 'text/css',
'data-orig-href' => $filename,
...$attributes,
]);
return "<link $attributes_str/>\n";
}
@@ -22,14 +20,12 @@ function stylesheet_tag(string $filename, array $attributes = []): string {
* @param array<string, mixed> $attributes
*/
function javascript_tag(string $filename, array $attributes = []): string {
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"src" => "$filename?" . filemtime($filename),
"type" => "text/javascript",
"charset" => "utf-8"
],
$attributes));
$attributes_str = \Controls\attributes_to_string([
'src' => "$filename?" . filemtime($filename),
'type' => 'text/javascript',
'charset' => 'utf-8',
...$attributes,
]);
return "<script $attributes_str></script>\n";
}

View File

@@ -2,40 +2,38 @@
/**
* @param array<int, array<string, mixed>> $trace
*/
function format_backtrace($trace): string {
function format_backtrace(array $trace): string {
$rv = "";
$idx = 1;
if (is_array($trace)) {
foreach ($trace as $e) {
if (isset($e["file"]) && isset($e["line"])) {
$fmt_args = [];
foreach ($trace as $e) {
if (isset($e["file"]) && isset($e["line"])) {
$fmt_args = [];
if (is_array($e["args"] ?? false)) {
foreach ($e["args"] as $a) {
if (is_object($a)) {
array_push($fmt_args, "{" . get_class($a) . "}");
} else if (is_array($a)) {
array_push($fmt_args, "[" . truncate_string(json_encode($a), 256, "...")) . "]";
} else if (is_resource($a)) {
array_push($fmt_args, truncate_string(get_resource_type($a), 256, "..."));
} else if (is_string($a)) {
array_push($fmt_args, truncate_string($a, 256, "..."));
}
if (is_array($e["args"] ?? false)) {
foreach ($e["args"] as $a) {
if (is_object($a)) {
array_push($fmt_args, "{" . get_class($a) . "}");
} else if (is_array($a)) {
array_push($fmt_args, "[" . truncate_string(json_encode($a), 256, "...")) . "]";
} else if (is_resource($a)) {
array_push($fmt_args, truncate_string(get_resource_type($a), 256, "..."));
} else if (is_string($a)) {
array_push($fmt_args, truncate_string($a, 256, "..."));
}
}
$filename = str_replace(dirname(__DIR__) . "/", "", $e["file"]);
$rv .= sprintf("%d. %s(%s): %s(%s)\n",
$idx,
$filename,
$e["line"],
$e["function"],
implode(", ", $fmt_args));
$idx++;
}
$filename = str_replace(dirname(__DIR__) . "/", "", $e["file"]);
$rv .= sprintf("%d. %s(%s): %s(%s)\n",
$idx,
$filename,
$e["line"],
$e["function"],
implode(", ", $fmt_args));
$idx++;
}
}
@@ -43,18 +41,19 @@ function format_backtrace($trace): string {
}
function ttrss_error_handler(int $errno, string $errstr, string $file, int $line): bool {
// return true in order to avoid default error handling by PHP
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
if (error_reporting() == 0 || !$errno) return true;
} else {
if (!(error_reporting() & $errno)) return true;
}
// return true in order to avoid default error handling by PHP
if (!(error_reporting() & $errno)) return true;
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
$context = format_backtrace(debug_backtrace());
$errstr = truncate_middle($errstr, 16384, " (...) ");
if (php_sapi_name() == 'cli' && class_exists("Debug")) {
Debug::log("!! Exception: $errstr ($file:$line)");
Debug::log($context);
}
if (class_exists("Logger"))
return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
else
@@ -76,8 +75,16 @@ function ttrss_fatal_handler(): bool {
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
if (php_sapi_name() == 'cli' && class_exists("Debug")) {
Debug::log("!! Fatal error: $errstr ($file:$line)");
Debug::log($context);
}
if (class_exists("Logger"))
return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
if (php_sapi_name() == 'cli')
exit(1);
}
return false;

View File

@@ -5,21 +5,14 @@
/** @deprecated by Config::SCHEMA_VERSION */
define('SCHEMA_VERSION', Config::SCHEMA_VERSION);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
}
libxml_use_internal_errors(true);
// separate test because this is included before sanity checks
if (function_exists("mb_internal_encoding")) mb_internal_encoding("UTF-8");
date_default_timezone_set('UTC');
if (defined('E_DEPRECATED')) {
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
} else {
error_reporting(E_ALL & ~E_NOTICE);
}
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
ini_set('display_errors', "false");
ini_set('display_startup_errors', "false");
@@ -30,27 +23,20 @@
require_once "autoload.php";
if (Config::get(Config::DB_TYPE) == "pgsql") {
define('SUBSTRING_FOR_DATE', 'SUBSTRING_FOR_DATE');
} else {
define('SUBSTRING_FOR_DATE', 'SUBSTRING');
}
/** @deprecated use the 'SUBSTRING_FOR_DATE' string directly */
const SUBSTRING_FOR_DATE = 'SUBSTRING_FOR_DATE';
/**
* @return bool|int|null|string
*
* @deprecated by Prefs::get()
*/
function get_pref(string $pref_name, ?int $owner_uid = null) {
function get_pref(string $pref_name, ?int $owner_uid = null): bool|int|null|string {
return Prefs::get($pref_name, $owner_uid ? $owner_uid : $_SESSION["uid"], $_SESSION["profile"] ?? null);
}
/**
* @param bool|int|string $value
*
* @deprecated by Prefs::set()
*/
function set_pref(string $pref_name, $value, ?int $owner_uid = null, bool $strip_tags = true): bool {
function set_pref(string $pref_name, bool|int|string $value, ?int $owner_uid = null, bool $strip_tags = true): bool {
return Prefs::set($pref_name, $value, $owner_uid ? $owner_uid : $_SESSION["uid"], $_SESSION["profile"] ?? null, $strip_tags);
}
@@ -88,6 +74,7 @@
"uk_UA" => "Українська",
"sv_SE" => "Svenska",
"fi_FI" => "Suomi",
"ta" => "Tamil",
"tr_TR" => "Türkçe");
return $t;
@@ -123,31 +110,29 @@
// create a list like "en" => 0.8
$langs = array_combine($lang_parse[1], $lang_parse[4]);
if (is_array($langs)) {
// set default to 1 for any without q factor
foreach ($langs as $lang => $val) {
if ($val === '') $langs[$lang] = 1;
}
// set default to 1 for any without q factor
foreach ($langs as $lang => $val) {
if ($val === '') $langs[$lang] = 1;
}
// sort list based on value
arsort($langs, SORT_NUMERIC);
// sort list based on value
arsort($langs, SORT_NUMERIC);
foreach (array_keys($langs) as $lang) {
$lang = strtolower($lang);
foreach (array_keys($langs) as $lang) {
$lang = strtolower($lang);
foreach ($valid_langs as $vlang => $vlocale) {
if ($vlang == $lang) {
$selected_locale = $vlocale;
break 2;
}
foreach ($valid_langs as $vlang => $vlocale) {
if ($vlang == $lang) {
$selected_locale = $vlocale;
break 2;
}
}
}
}
}
if (!empty($_SESSION["uid"]) && get_schema_version() >= 120) {
$pref_locale = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]);
if (!empty($_SESSION["uid"]) && Config::get_schema_version() >= 120) {
$pref_locale = Prefs::get(Prefs::USER_LANGUAGE, $_SESSION["uid"], $_SESSION["profile"] ?? null);
if (!empty($pref_locale) && $pref_locale != 'auto') {
$selected_locale = $pref_locale;
@@ -179,7 +164,7 @@
*
* @return array<string, mixed>|string
*/
function get_version() {
function get_version(): array|string {
return Config::get_version();
}
@@ -200,7 +185,7 @@
* @return int
* @throws PDOException
*/
function getFeedUnread($feed, bool $is_cat = false): int {
function getFeedUnread(int|string $feed, bool $is_cat = false): int {
return Feeds::_get_counters($feed, $is_cat, true, $_SESSION["uid"]);
}
@@ -211,7 +196,7 @@
*
* @return false|string The HTML, or false if an error occurred.
*/
function sanitize(string $str, bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null) {
function sanitize(string $str, bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null): false|string {
return Sanitizer::sanitize($str, $force_remove_images, $owner, $site_url, $highlight_words, $article_id);
}
@@ -219,9 +204,9 @@
* @deprecated by UrlHelper::fetch()
*
* @param array<string, bool|int|string>|string $params
* @return bool|string false if something went wrong, otherwise string contents
* @return false|string false if something went wrong, otherwise string contents
*/
function fetch_file_contents($params) {
function fetch_file_contents(array|string $params): false|string {
return UrlHelper::fetch($params);
}
@@ -243,9 +228,9 @@
/**
* @deprecated by UrlHelper::validate()
*
* @return bool|string false if something went wrong, otherwise the URL string
* @return false|string false if something went wrong, otherwise the URL string
*/
function validate_url(string $url) {
function validate_url(string $url): false|string {
return UrlHelper::validate($url);
}
@@ -260,7 +245,7 @@
}
/** @deprecated by TimeHelper::make_local_datetime() */
function make_local_datetime(string $timestamp, bool $long, ?int $owner_uid = null, bool $no_smart_dt = false, bool $eta_min = false): string {
function make_local_datetime(string $timestamp, bool $long = false, ?int $owner_uid = null, bool $no_smart_dt = false, bool $eta_min = false): string {
return TimeHelper::make_local_datetime($timestamp, $long, $owner_uid, $no_smart_dt, $eta_min);
}
@@ -274,12 +259,8 @@
/**
* This is used for user http parameters unless HTML code is actually needed.
*
* @param mixed $param
*
* @return mixed|null
*/
function clean($param) {
function clean(mixed $param): mixed {
if (is_array($param)) {
return array_map("trim", array_map("strip_tags", $param));
} else if (is_string($param)) {
@@ -290,11 +271,7 @@
}
function with_trailing_slash(string $str) : string {
if (substr($str, -1) === "/") {
return $str;
} else {
return "$str/";
}
return str_ends_with($str, '/') ? $str : "$str/";
}
function make_password(int $length = 12): string {
@@ -352,9 +329,8 @@
}
/** Convert values accepted by tt-rss as true/false to PHP booleans
* @see https://tt-rss.org/ApiReference/#boolean-values
* @see https://github.com/tt-rss/tt-rss/wiki/API-Reference#boolean-values
* @param null|string $s null values are considered false
* @return bool
*/
function sql_bool_to_bool(?string $s): bool {
return $s && ($s !== "f" && $s !== "false"); //no-op for PDO, backwards compat for legacy layer
@@ -446,7 +422,7 @@
/**
* @return false|string The decoded string or false if an error occurred.
*/
function gzdecode(string $string) { // no support for 2nd argument
function gzdecode(string $string): false|string { // no support for 2nd argument
return file_get_contents('compress.zlib://data:who/cares;base64,'.
base64_encode($string));
}
@@ -479,10 +455,7 @@
return null;
}
/**
* @param object|string $class
*/
function implements_interface($class, string $interface): bool {
function implements_interface(object|string $class, string $interface): bool {
$class_implemented_interfaces = class_implements($class);
if ($class_implemented_interfaces) {
@@ -491,14 +464,14 @@
return false;
}
function get_theme_path(string $theme): string {
function get_theme_path(string $theme, string $default = ""): string {
$check = "themes/$theme";
if (file_exists($check)) return $check;
$check = "themes.local/$theme";
if (file_exists($check)) return $check;
return "";
return $default;
}
function theme_exists(string $theme): bool {
@@ -523,4 +496,3 @@
return $ts;
}

View File

@@ -33,22 +33,25 @@
require({cache:{}});
</script>
<script type="text/javascript">
const __default_light_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_LIGHT_THEME), 'themes/light.css') ?>";
const __default_dark_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_DARK_THEME), 'themes/night.css') ?>";
</script>
<script type="text/javascript">
/* exported Plugins */
const Plugins = {};
<?php
foreach (PluginHost::getInstance()->get_plugins() as $n => $p) {
if (method_exists($p, "get_login_js")) {
$script = $p->get_login_js();
$script = $p->get_login_js();
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
}
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
}
}
?>
@@ -123,79 +126,84 @@
<fieldset>
<label><?= __("Login:") ?></label>
<input name="login" id="login" dojoType="dijit.form.TextBox" type="text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
required="1" value="<?= $_SESSION["fake_login"] ?? "" ?>" />
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
<?= Config::get(Config::DISABLE_LOGIN_FORM) ? 'disabled="disabled"' : '' ?>
required="1" value="<?= $_SESSION["fake_login"] ?? "" ?>" />
</fieldset>
<fieldset>
<label><?= __("Password:") ?></label>
<input type="password" name="password" required="1"
dojoType="dijit.form.TextBox"
class="input input-text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
value="<?= $_SESSION["fake_password"] ?? "" ?>"/>
dojoType="dijit.form.TextBox"
class="input input-text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
<?= Config::get(Config::DISABLE_LOGIN_FORM) ? 'disabled="disabled"' : '' ?>
value="<?= $_SESSION["fake_password"] ?? "" ?>"/>
</fieldset>
<?php if (strpos(Config::get(Config::PLUGINS), "auth_internal") !== false) { ?>
<?php if (!Config::get(Config::DISABLE_LOGIN_FORM) && str_contains(Config::get(Config::PLUGINS), "auth_internal")) { ?>
<fieldset class="align-right">
<a href="public.php?op=forgotpass"><?= __("I forgot my password") ?></a>
</fieldset>
<?php } ?>
<fieldset>
<label><?= __("Profile:") ?></label>
<?php if (!Config::get(Config::DISABLE_LOGIN_FORM)) { ?>
<fieldset>
<label><?= __("Profile:") ?></label>
<select disabled='disabled' name="profile" id="profile" dojoType='dijit.form.Select'>
<option><?= __("Default profile") ?></option>
</select>
</fieldset>
<fieldset class="narrow">
<label> </label>
<label id="bw_limit_label">
<?= \Controls\checkbox_tag("bw_limit", false, "",
["onchange" => 'UtilityApp.bwLimitChange(this)'], 'bw_limit') ?>
<?= __("Use less traffic") ?></label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="bw_limit_label" position="below" style="display:none">
<?= __("Does not display images in articles, reduces automatic refreshes."); ?>
</div>
<fieldset class="narrow">
<label> </label>
<label id="safe_mode_label">
<?= \Controls\checkbox_tag("safe_mode") ?>
<?= __("Safe mode") ?>
</label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="safe_mode_label" position="below" style="display:none">
<?= __("Uses default theme and prevents all plugins from loading."); ?>
</div>
<?php if (Config::get(Config::SESSION_COOKIE_LIFETIME) > 0) { ?>
<select disabled='disabled' name="profile" id="profile" dojoType='dijit.form.Select'>
<option><?= __("Default profile") ?></option>
</select>
</fieldset>
<fieldset class="narrow">
<label> </label>
<label>
<?= \Controls\checkbox_tag("remember_me") ?>
<?= __("Remember me") ?>
<label id="bw_limit_label">
<?= \Controls\checkbox_tag("bw_limit", false, "",
["onchange" => 'UtilityApp.bwLimitChange(this)'], 'bw_limit') ?>
<?= __("Use less traffic") ?></label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="bw_limit_label" position="below" style="display:none">
<?= __("Does not display images in articles, reduces automatic refreshes."); ?>
</div>
<fieldset class="narrow">
<label> </label>
<label id="safe_mode_label">
<?= \Controls\checkbox_tag("safe_mode") ?>
<?= __("Safe mode") ?>
</label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="safe_mode_label" position="below" style="display:none">
<?= __("Uses default theme and prevents all plugins from loading."); ?>
</div>
<?php if (Config::get(Config::SESSION_COOKIE_LIFETIME) > 0) { ?>
<fieldset class="narrow">
<label> </label>
<label>
<?= \Controls\checkbox_tag("remember_me") ?>
<?= __("Remember me") ?>
</label>
</fieldset>
<?php } ?>
<?php } ?>
<hr/>
<fieldset class="align-right">
<label> </label>
<?php if (!Config::get(Config::DISABLE_LOGIN_FORM)) { ?>
<?= \Controls\submit_tag(__('Log in')) ?>
<?php } ?>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_LOGINFORM_ADDITIONAL_BUTTONS) ?>
</fieldset>
@@ -203,8 +211,7 @@
</div>
<div class="footer">
<a href="https://tt-rss.org/">Tiny Tiny RSS</a>
&copy; 2005&ndash;<?= date('Y') ?> <a href="https://fakecake.org/">Andrew Dolgov</a>
<a href="https://github.com/tt-rss/tt-rss">Tiny Tiny RSS</a>
</div>
</div>

View File

@@ -19,7 +19,7 @@
<meta name="viewport" content="initial-scale=1,width=device-width" />
<?php if ($_SESSION["uid"] && empty($_SESSION["safe_mode"])) {
$theme = get_pref(Prefs::USER_CSS_THEME);
$theme = Prefs::get(Prefs::USER_CSS_THEME, $_SESSION['uid'], $_SESSION['profile'] ?? null);
if ($theme && theme_exists("$theme")) {
echo stylesheet_tag(get_theme_path($theme), ['id' => 'theme_css']);
}
@@ -29,6 +29,9 @@
<script type="text/javascript">
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
const __default_light_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_LIGHT_THEME), 'themes/light.css') ?>";
const __default_dark_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_DARK_THEME), 'themes/night.css') ?>";
</script>
<?php UserHelper::print_user_stylesheet() ?>
@@ -36,8 +39,9 @@
<style type="text/css">
<?php
foreach (PluginHost::getInstance()->get_plugins() as $p) {
if (method_exists($p, "get_css")) {
echo $p->get_css();
$css = $p->get_css();
if ($css) {
echo $css;
}
}
?>
@@ -73,16 +77,14 @@
<script type="text/javascript">
<?php
foreach (PluginHost::getInstance()->get_plugins() as $n => $p) {
if (method_exists($p, "get_js")) {
$script = $p->get_js();
$script = $p->get_js();
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
}
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
}
}
?>

View File

@@ -14,6 +14,7 @@ const App = {
global_unread: -1,
_widescreen_mode: false,
_loading_progress: 0,
_night_mode_retry_timeout: false,
hotkey_actions: {},
is_prefs: false,
LABEL_BASE_INDEX: -1024,
@@ -167,12 +168,25 @@ const App = {
setInitParam: function(k, v) {
this._initParams[k] = v;
},
nightModeChanged: function(is_night, link) {
console.log("night mode changed to", is_night);
nightModeChanged: function(is_night, link, retry = 0) {
console.log("nightModeChanged: night mode changed to", is_night, "retry", retry);
if (link) {
const css_override = is_night ? "themes/night.css" : "themes/light.css";
link.setAttribute("href", css_override + "?" + Date.now());
if (retry < 15) {
window.clearTimeout(this._night_mode_retry_timeout);
this._night_mode_retry_timeout = window.setTimeout(
() => this.nightModeChanged(is_night, link, ++retry),
3000);
}
xhr.post("backend.php", {op: "RPC", method: "getRuntimeInfo"}, () => {
const css_override = is_night ? App.getInitParam("default_dark_theme") : App.getInitParam("default_light_theme");
link.setAttribute("href", css_override + "?" + Date.now());
window.clearTimeout(this._night_mode_retry_timeout);
});
}
},
setupNightModeDetection: function(callback) {
@@ -690,6 +704,8 @@ const App = {
window.onerror = this.Error.onWindowError;
this.setInitParam("csrf_token", __csrf_token);
this.setInitParam("default_light_theme", __default_light_theme);
this.setInitParam("default_dark_theme", __default_dark_theme);
this.setupNightModeDetection(() => {
parser.parse();
@@ -829,11 +845,6 @@ const App = {
Headlines.initScrollHandler();
if (this.getInitParam("simple_update")) {
console.log("scheduling simple feed updater...");
window.setInterval(() => { Feeds.updateRandom() }, 30 * 1000);
}
if (this.getInitParam('check_for_updates')) {
window.setInterval(() => {
this.checkForUpdates();
@@ -1158,8 +1169,20 @@ const App = {
};
this.hotkey_actions["feed_debug_viewfeed"] = () => {
App.postOpenWindow("backend.php", {op: "Feeds", method: "view",
feed: Feeds.getActive(), timestamps: 1, debug: 1, cat: Feeds.activeIsCat(), csrf_token: __csrf_token});
let query = {
...{op: "Feeds", method: "view", feed: Feeds.getActive(), timestamps: 1,
debug: 1, cat: Feeds.activeIsCat(), csrf_token: __csrf_token},
...dojo.formToObject("toolbar-main")
};
if (Feeds._search_query) {
query = Object.assign(query, Feeds._search_query);
}
console.log('debug_viewfeed', query);
App.postOpenWindow("backend.php", query);
};
this.hotkey_actions["feed_edit"] = () => {

View File

@@ -123,15 +123,12 @@ const Article = {
Article.setActive(0);
},
displayUrl: function (id) {
const query = {op: "Article", method: "getmetadatabyid", id: id};
const hl = Headlines.objectById(id);
xhr.json("backend.php", query, (reply) => {
if (reply && reply.link) {
prompt(__("Article URL:"), reply.link);
} else {
alert(__("No URL could be displayed for this article."));
}
});
if (hl?.link)
prompt(__("Article URL:"), hl.link);
else
alert(__("No URL could be displayed for this article."));
},
openInNewWindow: function (id) {
/* global __csrf_token */

View File

@@ -58,10 +58,15 @@ const CommonDialogs = {
${App.getInitParam('enable_feed_cats') ?
`
<fieldset>
<label class='inline'>${__('Place in category:')}</label>
<label>${__('Place in category:')}</label>
${reply.cat_select}
</fieldset>
` : ''}
<fieldset>
<label>${__("Update interval:")}</label>
${App.FormFields.select_hash("update_interval", 0, reply.intervals.update)}
</fieldset>
</section>
<div id="feedDlg_feedsContainer" style="display : none">
@@ -113,10 +118,10 @@ const CommonDialogs = {
</footer>
</form>
`,
show_error: function (msg) {
show_error: function (msg, additional_info) {
const elem = App.byId("fadd_error_message");
elem.innerHTML = msg;
elem.innerHTML = `${msg}${additional_info ? `<br><br><h4>${__('Additional information')}</h4>${additional_info}` : ''}`;
Element.show(elem);
},
@@ -163,7 +168,7 @@ const CommonDialogs = {
dialog.show_error(__("Specified URL seems to be invalid."));
break;
case 3:
dialog.show_error(__("Specified URL doesn't seem to contain any feeds."));
dialog.show_error(__("Specified URL doesn't seem to contain any feeds."), App.escapeHtml(rc['message']));
break;
case 4:
{
@@ -188,10 +193,10 @@ const CommonDialogs = {
}
break;
case 5:
dialog.show_error(__("Couldn't download the specified URL: %s").replace("%s", rc['message']));
dialog.show_error(__("Couldn't download the specified URL."), App.escapeHtml(rc['message']));
break;
case 6:
dialog.show_error(__("XML validation failed: %s").replace("%s", rc['message']));
dialog.show_error(__("Invalid content."), App.escapeHtml(rc['message']));
break;
case 7:
dialog.show_error(__("Error while creating feed database entry."));
@@ -685,7 +690,7 @@ const CommonDialogs = {
</section>
<footer>
<button dojoType='dijit.form.Button' style='float : left' class='alt-info'
onclick='window.open("https://tt-rss.org/wiki/GeneratedFeeds")'>
onclick='window.open("https://github.com/tt-rss/tt-rss/wiki/Generated-Feeds")'>
<i class='material-icons'>help</i> ${__("More info...")}</button>
<button dojoType='dijit.form.Button' onclick="return App.dialogOf(this).regenFeedKey('${feed}', '${is_cat}')">
${App.FormFields.icon("refresh")}

View File

@@ -30,52 +30,45 @@ const Filters = {
params.offset = offset;
params.limit = test_dialog.limit;
console.log("getTestResults:" + offset);
xhr.json("backend.php", params, (result) => {
try {
if (result && test_dialog && test_dialog.open) {
test_dialog.results += result.length;
console.log("got results:" + result.length);
const loading_message = test_dialog.domNode.querySelector(".loading-message");
const results_list = test_dialog.domNode.querySelector(".filter-results-list");
loading_message.innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dialog.results)
.replace("%d", offset);
if (result.pre_filtering_count > 0) {
test_dialog.results += result.items.length;
console.log(offset + " " + test_dialog.max_offset);
loading_message.innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dialog.results)
.replace("%d", offset);
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("div", { innerHTML: result[i]});
results_list.innerHTML += result.items.reduce((current, item) => current + `<li title="${App.escapeHtml(item.rules.join('\n'))}"><span class='title'>${item.title}</span>
&mdash; <span class='feed'>${item.feed_title}</span>, <span class='date'>${item.date}</span>
<div class='preview text-muted'>${item.content_preview}</div></li>`, '');
results_list.innerHTML += tmp.innerHTML;
// get the next batch if there may be more available and testing limits haven't been reached
if (result.pre_filtering_count === test_dialog.limit &&
test_dialog.results < 30 && offset < test_dialog.max_offset) {
window.setTimeout(function () {
test_dialog.getTestResults(params, offset + test_dialog.limit);
}, 0);
return;
}
}
if (test_dialog.results < 30 && offset < test_dialog.max_offset) {
// all done-- either the backend found no more pre-filtering entries, or test limits were reached
test_dialog.domNode.querySelector(".loading-indicator").hide();
// get the next batch
window.setTimeout(function () {
test_dialog.getTestResults(params, offset + test_dialog.limit);
}, 0);
if (test_dialog.results == 0) {
results_list.innerHTML = `<li class="text-center text-muted">
${__('No recent articles matching this filter have been found.')}</li>`;
loading_message.innerHTML = __("Articles matching this filter:");
} else {
// all done
test_dialog.domNode.querySelector(".loading-indicator").hide();
if (test_dialog.results == 0) {
results_list.innerHTML = `<li class="text-center text-muted">
${__('No recent articles matching this filter have been found.')}</li>`;
loading_message.innerHTML = __("Articles matching this filter:");
} else {
loading_message.innerHTML = __("Found %d articles matching this filter:")
.replace("%d", test_dialog.results);
}
loading_message.innerHTML = __("Found at least %d articles matching this filter:")
.replace("%d", test_dialog.results);
}
} else if (!result) {
@@ -233,7 +226,7 @@ const Filters = {
<footer>
${App.FormFields.button_tag(App.FormFields.icon("help") + " " + __("More info"), "", {class: 'pull-left alt-info',
onclick: "window.open('https://tt-rss.org/wiki/ContentFilters')"})}
onclick: "window.open('https://github.com/tt-rss/tt-rss/wiki/Content-Filters')"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
@@ -324,7 +317,9 @@ const Filters = {
</form>
`);
dijit.byId("filterDlg_actionSelect").attr('value', action.action_id);
const actionSelect = dijit.byId("filterDlg_actionSelect").attr('value', action.action_id);
edit_action_dialog.toggleParam(actionSelect);
/*xhr.post("backend.php", {op: 'Pref_Filters', method: 'newaction', action: actionStr}, (reply) => {
edit_action_dialog.attr('content', reply);

View File

@@ -173,6 +173,9 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
tnode.markedCounterNode = dojo.create('span', { className: 'counterNode marked', innerHTML: args.item.markedcounter });
domConstruct.place(tnode.markedCounterNode, tnode.rowNode, 'first');
tnode.publishedCounterNode = dojo.create('span', { className: 'counterNode published', innerHTML: args.item.publishedcounter });
domConstruct.place(tnode.publishedCounterNode, tnode.rowNode, 'first');
tnode.auxCounterNode = dojo.create('span', { className: 'counterNode aux', innerHTML: args.item.auxcounter });
domConstruct.place(tnode.auxCounterNode, tnode.rowNode, 'first');
@@ -188,7 +191,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
updateCounter: function (item) {
const tree = this;
//console.log("updateCounter: " + item.id[0] + " " + item.unread + " " + tree);
// console.log("updateCounter: " + item.id[0] + " U: " + item.unread + " P: " + item.publishedcounter + " M: " + item.markedcounter);
let treeNode = tree._itemNodesMap[item.id];
@@ -198,6 +201,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
treeNode.unreadCounterNode.innerHTML = item.unread;
treeNode.auxCounterNode.innerHTML = item.auxcounter;
treeNode.markedCounterNode.innerHTML = item.markedcounter;
treeNode.publishedCounterNode.innerHTML = item.publishedcounter;
}
},
getTooltip: function (item) {
@@ -224,6 +228,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (item.unread > 0) rc += " Unread";
if (item.auxcounter > 0) rc += " Has_Aux";
if (item.markedcounter > 0) rc += " Has_Marked";
if (item.publishedcounter > 0) rc += " Has_Published";
if (item.updates_disabled > 0) rc += " UpdatesDisabled";
if (item.bare_id >= App.LABEL_BASE_INDEX && item.bare_id < 0 && !is_cat || item.bare_id == Feeds.FEED_ARCHIVED && !is_cat) rc += " Special";
if (item.bare_id == Feeds.CATEGORY_SPECIAL && is_cat) rc += " AlwaysVisible";

View File

@@ -23,7 +23,7 @@ const Feeds = {
infscroll_disabled: 0,
_infscroll_timeout: false,
_filter_query: false, // TODO: figure out the UI for this
_search_query: false,
_search_query: null,
last_search_query: [],
_viewfeed_wait_timeout: false,
_feeds_holder_observer: new IntersectionObserver(
@@ -105,6 +105,7 @@ const Feeds = {
this.setUnread(id, (kind == "cat"), ctr);
this.setValue(id, (kind == "cat"), 'auxcounter', parseInt(elems[l].auxcounter));
this.setValue(id, (kind == "cat"), 'markedcounter', parseInt(elems[l].markedcounter));
this.setValue(id, (kind == "cat"), 'publishedcounter', parseInt(elems[l].publishedcounter));
if (kind != "cat") {
this.setValue(id, false, 'error', error);
@@ -159,7 +160,7 @@ const Feeds = {
this.reload();
},
cancelSearch: function() {
this._search_query = "";
this._search_query = null;
this.reloadCurrent();
},
// null = get all data, [] would give empty response for specific type
@@ -270,6 +271,9 @@ const Feeds = {
console.log('got hash', hash);
if (hash.query)
this._search_query = {query: hash.query, search_language: hash.search_language};
if (hash.f != undefined) {
this.open({feed: parseInt(hash.f), is_cat: parseInt(hash.c)});
} else {
@@ -329,7 +333,12 @@ const Feeds = {
console.log('setActive', id, is_cat);
window.requestIdleCallback(() => {
App.Hash.set({f: id, c: is_cat ? 1 : 0});
App.Hash.set({
f: id,
c: is_cat ? 1 : 0,
query: Feeds._search_query?.query,
search_language: Feeds._search_query?.search_language,
});
});
this._active_feed_id = id;
@@ -649,7 +658,7 @@ const Feeds = {
<footer>
${reply.show_syntax_help ?
`${App.FormFields.button_tag(App.FormFields.icon("help") + " " + __("Search syntax"), "",
{class: 'alt-info pull-left', onclick: "window.open('https://tt-rss.org/wiki/SearchSyntax')"})}
{class: 'alt-info pull-left', onclick: "window.open('https://github.com/tt-rss/tt-rss/wiki/Search-Syntax')"})}
` : ''}
${App.FormFields.submit_tag(App.FormFields.icon("search") + " " + __('Search'), {onclick: "App.dialogOf(this).execute()"})}
@@ -664,7 +673,7 @@ const Feeds = {
// disallow empty queries
if (!Feeds._search_query.query)
Feeds._search_query = false;
Feeds._search_query = null;
this.hide();
Feeds.reloadCurrent();
@@ -735,13 +744,6 @@ const Feeds = {
dialog.show();
},
updateRandom: function() {
console.log("in update_random_feed");
xhr.json("backend.php", {op: "RPC", method: "updaterandomfeed"}, () => {
//
});
},
renderIcon: function(feed_id, exists) {
const icon_url = App.getInitParam("icons_url") + '?' + dojo.objectToQuery({op: 'feed_icon', id: feed_id});

View File

@@ -28,8 +28,8 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const rules = this.model.store.getValue(args.item, 'rules');
if (param) {
param = dojo.doc.createElement('span');
param.className = (enabled != false) ? 'labelParam' : 'labelParam filterDisabled';
param = dojo.doc.createElement('ul');
param.className = (enabled != false) ? 'actions_summary' : 'actions_summary filterDisabled';
param.innerHTML = args.item.param[0];
domConstruct.place(param, tnode.rowNode, 'first');
}
@@ -165,6 +165,39 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
alert(__("No filters selected."));
}
return false;
},
cloneSelectedFilters: function() {
const sel_rows = this.getSelectedFilters();
if (sel_rows.length > 0) {
const query = {op: "Pref_Filters", method: "clone", ids: sel_rows.toString()};
let proceed = false;
if (sel_rows.length === 1) {
const selected_filter = this.model.getCheckedItems()[0];
const new_filter_title = prompt(__("Name for new filter:"),
__("Clone of %s").replace("%s", this.model.store.getValue(selected_filter, "bare_name")));
if (new_filter_title) {
query.new_filter_title = new_filter_title;
proceed = true;
}
} else if (sel_rows.length > 1) {
proceed = confirm(__("Clone selected filters?"));
}
if (proceed) {
Notify.progress(__("Cloning selected filters..."));
xhr.post("backend.php", query, () => {
this.reload();
});
}
} else {
alert(__("No filters selected."));
}
return false;
},
});

View File

@@ -7,8 +7,11 @@ window.addEventListener("load", function() {
apply_night_mode: function (is_night, link) {
console.log("night mode changed to", is_night);
const light_theme = typeof __default_light_theme != 'undefined' ? __default_light_theme : 'themes/light.css';
const dark_theme = typeof __default_dark_theme != 'undefined' ? __default_dark_theme : 'themes/night.css';
if (link) {
const css_override = is_night ? "themes/night.css" : "themes/light.css";
const css_override = is_night ? dark_theme : light_theme;
link.setAttribute("href", css_override + "?" + Date.now());
}

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,7 @@
"url": "https://github.com/dojo/dijit.git"
},
"dependencies": {
"dojo": "1.16.5"
"dojo": "1.17.3"
},
"devDependencies": {
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,12 @@
{
"name": "dijit",
"version": "1.16.5",
"version": "1.17.3",
"directories": {
"lib": "."
},
"main": "main",
"dependencies": {
"dojo": "1.16.5"
"dojo": "1.17.3"
},
"description": "Dijit provides a complete collection of user interface controls based on Dojo, giving you the power to create web applications that are highly optimized for usability, performance, internationalization, accessibility, but above all deliver an incredible user experience.",
"license" : "BSD-3-Clause OR AFL-2.1",

View File

@@ -5,40 +5,40 @@
# It will automatically replace previous build of Dojo in ../dojo
# Dojo requires Java runtime to build. Further information on rebuilding Dojo
# is available here: http://dojotoolkit.org/reference-guide/build/index.html
# is available here: https://dojotoolkit.org/reference-guide/build/index.html
VERSION=1.16.5
VERSION=1.17.3
# Download and extract dojo src code if it doesn't already exist
if [ ! -d "dojo" ]; then
TARBALL=dojo-release-$VERSION-src.tar.gz
if [ ! -f $TARBALL ]; then
wget -q http://download.dojotoolkit.org/release-$VERSION/$TARBALL
fi
tar -zxf $TARBALL
mv dojo-release-$VERSION-src/* .
rm -rf dojo-release-$VERSION-src
TARBALL=dojo-release-$VERSION-src.tar.gz
if [ ! -f $TARBALL ]; then
wget https://download.dojotoolkit.org/release-$VERSION/$TARBALL
fi
tar -zxf $TARBALL
mv dojo-release-$VERSION-src/* .
rm -rf dojo-release-$VERSION-src
fi
if [ -d util/buildscripts/ ]; then
rm -rf release/dojo
rm -rf release/dojo
pushd util/buildscripts
./build.sh profile=../../tt-rss action=release optimize=shrinksafe cssOptimize=comments
popd
pushd util/buildscripts
./build.sh profile=../../tt-rss action=release optimize=shrinksafe cssOptimize=comments
popd
if [ -d release/dojo ]; then
rm -rf ../dojo ../dijit
cp -r release/dojo/dojo ..
cp -r release/dojo/dijit ..
if [ -d release/dojo ]; then
rm -rf ../dojo ../dijit
cp -r release/dojo/dojo ..
cp -r release/dojo/dijit ..
cd ..
cd ..
find dojo -name '*uncompressed*' -exec rm -- {} \;
find dijit -name '*uncompressed*' -exec rm -- {} \;
else
echo $0: ERROR: Dojo build seems to have failed.
fi
find dojo -name '*uncompressed*' -exec rm -- {} \;
find dijit -name '*uncompressed*' -exec rm -- {} \;
else
echo $0: ERROR: Dojo build seems to have failed.
fi
else
echo $0: ERROR: Please unpack Dojo source release into current directory.
echo $0: ERROR: Please unpack Dojo source release into current directory.
fi

View File

@@ -5,4 +5,4 @@
*/
//>>built
define("dojo/_base/array",["./kernel","../has","./lang"],function(_1,_2,_3){var _4={},u;function _5(fn){return _4[fn]=new Function("item","index","array",fn);};function _6(_7){var _8=!_7;return function(a,fn,o){var i=0,l=a&&a.length||0,_9;if(l&&typeof a=="string"){a=a.split("");}if(typeof fn=="string"){fn=_4[fn]||_5(fn);}if(o){for(;i<l;++i){_9=!fn.call(o,a[i],i,a);if(_7^_9){return !_9;}}}else{for(;i<l;++i){_9=!fn(a[i],i,a);if(_7^_9){return !_9;}}}return _8;};};function _a(up){var _b=1,_c=0,_d=0;if(!up){_b=_c=_d=-1;}return function(a,x,_e,_f){if(_f&&_b>0){return _10.lastIndexOf(a,x,_e);}var l=a&&a.length||0,end=up?l+_d:_c,i;if(_e===u){i=up?_c:l+_d;}else{if(_e<0){i=l+_e;if(i<0){i=_c;}}else{i=_e>=l?l+_d:_e;}}if(l&&typeof a=="string"){a=a.split("");}for(;i!=end;i+=_b){if(a[i]==x){return i;}}return -1;};};var _10={every:_6(false),some:_6(true),indexOf:_a(true),lastIndexOf:_a(false),forEach:function(arr,_11,_12){var i=0,l=arr&&arr.length||0;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _11=="string"){_11=_4[_11]||_5(_11);}if(_12){for(;i<l;++i){_11.call(_12,arr[i],i,arr);}}else{for(;i<l;++i){_11(arr[i],i,arr);}}},map:function(arr,_13,_14,Ctr){var i=0,l=arr&&arr.length||0,out=new (Ctr||Array)(l);if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _13=="string"){_13=_4[_13]||_5(_13);}if(_14){for(;i<l;++i){out[i]=_13.call(_14,arr[i],i,arr);}}else{for(;i<l;++i){out[i]=_13(arr[i],i,arr);}}return out;},filter:function(arr,_15,_16){var i=0,l=arr&&arr.length||0,out=[],_17;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _15=="string"){_15=_4[_15]||_5(_15);}if(_16){for(;i<l;++i){_17=arr[i];if(_15.call(_16,_17,i,arr)){out.push(_17);}}}else{for(;i<l;++i){_17=arr[i];if(_15(_17,i,arr)){out.push(_17);}}}return out;},clearCache:function(){_4={};}};1&&_3.mixin(_1,_10);return _10;});
define("dojo/_base/array",["./kernel","../has","./lang"],function(_1,_2,_3){var _4={},u;var _5;if(!_2("csp-restrictions")){_5=function(fn){return _4[fn]=new Function("item","index","array",fn);};}function _6(_7){var _8=!_7;return function(a,fn,o){var i=0,l=a&&a.length||0,_9;if(l&&typeof a=="string"){a=a.split("");}if(typeof fn=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{fn=_4[fn]||_5(fn);}}if(o){for(;i<l;++i){_9=!fn.call(o,a[i],i,a);if(_7^_9){return !_9;}}}else{for(;i<l;++i){_9=!fn(a[i],i,a);if(_7^_9){return !_9;}}}return _8;};};function _a(up){var _b=1,_c=0,_d=0;if(!up){_b=_c=_d=-1;}return function(a,x,_e,_f){if(_f&&_b>0){return _10.lastIndexOf(a,x,_e);}var l=a&&a.length||0,end=up?l+_d:_c,i;if(_e===u){i=up?_c:l+_d;}else{if(_e<0){i=l+_e;if(i<0){i=_c;}}else{i=_e>=l?l+_d:_e;}}if(l&&typeof a=="string"){a=a.split("");}for(;i!=end;i+=_b){if(a[i]==x){return i;}}return -1;};};var _10={every:_6(false),some:_6(true),indexOf:_a(true),lastIndexOf:_a(false),forEach:function(arr,_11,_12){var i=0,l=arr&&arr.length||0;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _11=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{_11=_4[_11]||_5(_11);}}if(_12){for(;i<l;++i){_11.call(_12,arr[i],i,arr);}}else{for(;i<l;++i){_11(arr[i],i,arr);}}},map:function(arr,_13,_14,Ctr){var i=0,l=arr&&arr.length||0,out=new (Ctr||Array)(l);if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _13=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{_13=_4[_13]||_5(_13);}}if(_14){for(;i<l;++i){out[i]=_13.call(_14,arr[i],i,arr);}}else{for(;i<l;++i){out[i]=_13(arr[i],i,arr);}}return out;},filter:function(arr,_15,_16){var i=0,l=arr&&arr.length||0,out=[],_17;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _15=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{_15=_4[_15]||_5(_15);}}if(_16){for(;i<l;++i){_17=arr[i];if(_15.call(_16,_17,i,arr)){out.push(_17);}}}else{for(;i<l;++i){_17=arr[i];if(_15(_17,i,arr)){out.push(_17);}}}return out;},clearCache:function(){_4={};}};1&&_3.mixin(_1,_10);return _10;});

File diff suppressed because one or more lines are too long

View File

@@ -5,4 +5,4 @@
*/
//>>built
define("dojo/_base/kernel",["../global","../has","./config","require","module"],function(_1,_2,_3,_4,_5){var i,p,_6={},_7={},_8={config:_3,global:_1,dijit:_6,dojox:_7};var _9={dojo:["dojo",_8],dijit:["dijit",_6],dojox:["dojox",_7]},_a=(_4.map&&_4.map[_5.id.match(/[^\/]+/)[0]]),_b;for(p in _a){if(_9[p]){_9[p][0]=_a[p];}else{_9[p]=[_a[p],{}];}}for(p in _9){_b=_9[p];_b[1]._scopeName=_b[0];if(!_3.noGlobals){_1[_b[0]]=_b[1];}}_8.scopeMap=_9;_8.baseUrl=_8.config.baseUrl=_4.baseUrl;_8.isAsync=!1||_4.async;_8.locale=_3.locale;var _c="$Rev:$".match(/[0-9a-f]{7,}/);_8.version={major:1,minor:16,patch:5,flag:"",revision:_c?_c[0]:NaN,toString:function(){var v=_8.version;return v.major+"."+v.minor+"."+v.patch+v.flag+" ("+v.revision+")";}};1||_2.add("extend-dojo",1);if(!_2("csp-restrictions")){(Function("d","d.eval = function(){return d.global.eval ? d.global.eval(arguments[0]) : eval(arguments[0]);}"))(_8);}if(0){_8.exit=function(_d){quit(_d);};}else{_8.exit=function(){};}if(!_2("host-webworker")){1||_2.add("dojo-guarantee-console",1);}if(1){_2.add("console-as-object",function(){return Function.prototype.bind&&console&&typeof console.log==="object";});typeof console!="undefined"||(console={});var cn=["assert","count","debug","dir","dirxml","error","group","groupEnd","info","profile","profileEnd","time","timeEnd","trace","warn","log"];var tn;i=0;while((tn=cn[i++])){if(!console[tn]){(function(){var _e=tn+"";console[_e]=("log" in console)?function(){var a=Array.prototype.slice.call(arguments);a.unshift(_e+":");console["log"](a.join(" "));}:function(){};console[_e]._fake=true;})();}else{if(_2("console-as-object")){console[tn]=Function.prototype.bind.call(console[tn],console);}}}}_2.add("dojo-debug-messages",!!_3.isDebug);_8.deprecated=_8.experimental=function(){};if(_2("dojo-debug-messages")){_8.deprecated=function(_f,_10,_11){var _12="DEPRECATED: "+_f;if(_10){_12+=" "+_10;}if(_11){_12+=" -- will be removed in version: "+_11;}console.warn(_12);};_8.experimental=function(_13,_14){var _15="EXPERIMENTAL: "+_13+" -- APIs subject to change without notice.";if(_14){_15+=" "+_14;}console.warn(_15);};}1||_2.add("dojo-modulePaths",1);if(1){if(_3.modulePaths){_8.deprecated("dojo.modulePaths","use paths configuration");var _16={};for(p in _3.modulePaths){_16[p.replace(/\./g,"/")]=_3.modulePaths[p];}_4({paths:_16});}}1||_2.add("dojo-moduleUrl",1);if(1){_8.moduleUrl=function(_17,url){_8.deprecated("dojo.moduleUrl()","use require.toUrl","2.0");var _18=null;if(_17){_18=_4.toUrl(_17.replace(/\./g,"/")+(url?("/"+url):"")+"/*.*").replace(/\/\*\.\*/,"")+(url?"":"/");}return _18;};}_8._hasResource={};return _8;});
define("dojo/_base/kernel",["../global","../has","./config","require","module"],function(_1,_2,_3,_4,_5){var i,p,_6={},_7={},_8={config:_3,global:_1,dijit:_6,dojox:_7};var _9={dojo:["dojo",_8],dijit:["dijit",_6],dojox:["dojox",_7]},_a=(_4.map&&_4.map[_5.id.match(/[^\/]+/)[0]]),_b;for(p in _a){if(_9[p]){_9[p][0]=_a[p];}else{_9[p]=[_a[p],{}];}}for(p in _9){_b=_9[p];_b[1]._scopeName=_b[0];if(!_3.noGlobals){_1[_b[0]]=_b[1];}}_8.scopeMap=_9;_8.baseUrl=_8.config.baseUrl=_4.baseUrl;_8.isAsync=!1||_4.async;_8.locale=_3.locale;var _c="$Rev:$".match(/[0-9a-f]{7,}/);_8.version={major:1,minor:17,patch:3,flag:"",revision:_c?_c[0]:NaN,toString:function(){var v=_8.version;return v.major+"."+v.minor+"."+v.patch+v.flag+" ("+v.revision+")";}};1||_2.add("extend-dojo",1);if(!_2("csp-restrictions")){(Function("d","d.eval = function(){return d.global.eval ? d.global.eval(arguments[0]) : eval(arguments[0]);}"))(_8);}if(0){_8.exit=function(_d){quit(_d);};}else{_8.exit=function(){};}if(!_2("host-webworker")){1||_2.add("dojo-guarantee-console",1);}if(1){_2.add("console-as-object",function(){return Function.prototype.bind&&console&&typeof console.log==="object";});typeof console!="undefined"||(console={});var cn=["assert","count","debug","dir","dirxml","error","group","groupEnd","info","profile","profileEnd","time","timeEnd","trace","warn","log"];var tn;i=0;while((tn=cn[i++])){if(!console[tn]){(function(){var _e=tn+"";console[_e]=("log" in console)?function(){var a=Array.prototype.slice.call(arguments);a.unshift(_e+":");console["log"](a.join(" "));}:function(){};console[_e]._fake=true;})();}else{if(_2("console-as-object")){console[tn]=Function.prototype.bind.call(console[tn],console);}}}}_2.add("dojo-debug-messages",!!_3.isDebug);_8.deprecated=_8.experimental=function(){};if(_2("dojo-debug-messages")){_8.deprecated=function(_f,_10,_11){var _12="DEPRECATED: "+_f;if(_10){_12+=" "+_10;}if(_11){_12+=" -- will be removed in version: "+_11;}console.warn(_12);};_8.experimental=function(_13,_14){var _15="EXPERIMENTAL: "+_13+" -- APIs subject to change without notice.";if(_14){_15+=" "+_14;}console.warn(_15);};}1||_2.add("dojo-modulePaths",1);if(1){if(_3.modulePaths){_8.deprecated("dojo.modulePaths","use paths configuration");var _16={};for(p in _3.modulePaths){_16[p.replace(/\./g,"/")]=_3.modulePaths[p];}_4({paths:_16});}}1||_2.add("dojo-moduleUrl",1);if(1){_8.moduleUrl=function(_17,url){_8.deprecated("dojo.moduleUrl()","use require.toUrl","2.0");var _18=null;if(_17){_18=_4.toUrl(_17.replace(/\./g,"/")+(url?("/"+url):"")+"/*.*").replace(/\/\*\.\*/,"")+(url?"":"/");}return _18;};}_8._hasResource={};return _8;});

2
lib/dojo/dojo.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
lib/dojo/json5.js Normal file
View File

@@ -0,0 +1,8 @@
/*
Copyright (c) 2004-2016, The JS Foundation All Rights Reserved.
Available via Academic Free License >= 2.1 OR the modified BSD license.
see: http://dojotoolkit.org/license for details
*/
//>>built
define("dojo/json5",["./json5/parse"],function(_1){return {parse:_1};});

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 TheCodingMachine
Copyright (c) 2012-2018 Aseem Kishore, and [others].
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,6 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.
[others]: https://github.com/json5/json5/contributors

28
lib/dojo/json5/README.md Normal file
View File

@@ -0,0 +1,28 @@
These modules are adapted from the [JSON5](https://github.com/json5/json5) project. JSON5 was adopted by
the Dojo Toolkit for use by `dojo/parser` to facilitate parsing data attributes without using the unsafe
JavaScript function `eval()`. As such only the parsing related modules from JSON5 are included.
Updates from the JSON5 project can be incorporated into the Dojo Toolkit with the following process:
* Clone the [JSON5 repository](https://github.com/json5/json5.git)
* Convert the relevant files to ES5 syntax with TypeScript's compiler:
```bash
tsc lib/parse.js lib/unicode.js lib/util.js --allowJs --module ES6 --outDir dojo --removeComments --target ES5
```
* Visually compare the existing modules in `dojo/json5` with the newly converted modules to see what changes will need
to be made
* Copy the files from the `json5/dojo` folder to the `dojo/json5` folder
* Manual updates:
* IMPORTANT: wrap the `lexStates` object property `default:` in quotes => `'default':`
* convert indentation to tabs in each module
* remove any trailing commas
* convert each module to AMD syntax
* Update `json5/parse.js` to use `dojo/string` methods for ES5 String methods:
* require `'../string'` as `dstring`
* replace calls to `codePointAt` with `dstring.codePointAt(str, position)`
* replace calls to `String.fromCodePoint` with `dstring.fromCodePoint`
* Run Dojo's JSON5 tests to ensure the updates were successful:
* `dojo/node_modules/intern-geezer/client.html?config=tests/dojo.intern&suites=tests/unit/json5`
* Update the line below recording the most recent update
Current as of 2020-06-12, commit [32bb2cd](https://github.com/json5/json5/commit/32bb2cdae4864b2ac80a6d9b4045efc4cc54f47a)

8
lib/dojo/json5/parse.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
lib/dojo/json5/util.js Normal file
View File

@@ -0,0 +1,8 @@
/*
Copyright (c) 2004-2016, The JS Foundation All Rights Reserved.
Available via Academic Free License >= 2.1 OR the modified BSD license.
see: http://dojotoolkit.org/license for details
*/
//>>built
define("dojo/json5/util",["./unicode"],function(_1){return {isSpaceSeparator:function(c){return typeof c==="string"&&_1.Space_Separator.test(c);},isIdStartChar:function(c){return typeof c==="string"&&((c>="a"&&c<="z")||(c>="A"&&c<="Z")||(c==="$")||(c==="_")||_1.ID_Start.test(c));},isIdContinueChar:function(c){return typeof c==="string"&&((c>="a"&&c<="z")||(c>="A"&&c<="Z")||(c>="0"&&c<="9")||(c==="$")||(c==="_")||(c==="")||(c==="")||_1.ID_Continue.test(c));},isDigit:function(c){return typeof c==="string"&&/[0-9]/.test(c);},isHexDigit:function(c){return typeof c==="string"&&/[0-9A-Fa-f]/.test(c);},};});

Some files were not shown because too many files have changed in this diff Show More