Compare commits

...

457 Commits

Author SHA1 Message Date
Andrew Dolgov 9f734c9050 minor phpstan tweaks 3 years ago
Andrew Dolgov 3b70d1f622 require phpstan via composer 3 years ago
Andrew Dolgov 2a5c2be6cd af_redditimgur: allow subscribing to teddit.net subreddits directly (rewriting to reddit.com) 3 years ago
Andrew Dolgov 41245da8a6 pluginhost: update comments for HOOK_ constants to use phpdoc syntax; add HOOK_PRE_SUBSCRIBE 3 years ago
Andrew Dolgov 6fe0751038 af_redditimgur: set some @var hints 3 years ago
Andrew Dolgov 377e0b812c add data-orig-feed-title to generated headline markup 3 years ago
Andrew Dolgov dd30825b94 af_comics: pass PluginHost to filter constructors 3 years ago
fox d1ffe6d6cf Merge pull request 'Fix undefined array key "output" when adding new label' (#47) from klempin/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/47
3 years ago
Philip Klempin aead30a041 Fix undefined array key "output" when adding new label 3 years ago
Andrew Dolgov a936e80630 OPML improvements/fixes:
* allow CLI import of OPML files (--opml-import)
 * visualize OPML structure when importing
 * add strict type hints to most OPML class methods
3 years ago
Andrew Dolgov 9e7e0e84d7 fix vfeed menu in three panel mode 3 years ago
Andrew Dolgov c6f5902cbc fix wrongly renamed CLI options --debug-force-... to --force-... 3 years ago
Andrew Dolgov a9646b9574 headlines: attach context menu to vfeed title node 3 years ago
Andrew Dolgov 145fc31625 feed tree context menu: add an entry to open originating website 3 years ago
Andrew Dolgov 949e2ab4d2 properly sanitize video poster attribute 3 years ago
Andrew Dolgov 8ed927dbd2 OPML: multiple fixes
- remove unused integer indexes when exporting filters as JSON
 - fix warning when importing filters without rules
 - properly assign category IDs for category filter rules
 - fix warning: check if outline attributes like xmlUrl are set before trying to use them
 - fix warning: don't try to use libxml_disable_entity_loader on PHP 8
3 years ago
Andrew Dolgov 78ff7770d1 classes/opml: fix indentation; when importing, don't produce warning
on filters with no rules defined.
3 years ago
fox 012a9fdee3 Merge pull request 'Fix undefined index error' (#45) from jpschewe/tt-rss:fix-undefined-index into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/45
3 years ago
Jon Schewe e44f0cb937 Fix undefined index error
Getting $op is handled at the top of the file, use the same variable
at the end of the file to avoid errors about an undefined index.
3 years ago
Andrew Dolgov 36e174750e fix label ordering in feed tree 3 years ago
fox b8f82ca12f Merge pull request 'fix password recovery' (#44) from mechnich/tt-rss:fix-password-recovery into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/44
3 years ago
jmechnich e8f9567d79 fix password recovery 3 years ago
Andrew Dolgov a1173ab06a block useless usort() E_DEPRECATED for the time being 3 years ago
Andrew Dolgov 2c931df77c remove SELF_USER_AGENT custom constant, replaced with configurable Config::HTTP_USER_AGENT / Config::get_user_agent() 3 years ago
Andrew Dolgov 5c60254474 Pref_Feeds:calculate_children_count - fix operator precedence 3 years ago
Andrew Dolgov 0808123179 fix broken feed tree generation when categories are disabled 3 years ago
fox 5ed108dce4 Merge pull request 'Use ORM more in 'classes/pref/feeds.php'.' (#43) from wn/tt-rss:feature/pref-feeds-idiorm into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/43
3 years ago
fox 28eafa2bcd Merge pull request 'pull latest readability-php via composer' (#42) from niehztog/tt-rss:update-readability into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/42
3 years ago
wn_ 23b4152c9e Make prefs feed search case-insensitive.
Previously the search query had to match lower title or feed_url (i.e. searching w/ uppercase wouldn't match).
3 years ago
wn_ 992e9cd9e3 Use ORM more in 'classes/pref/feeds.php'. 3 years ago
Nils Gotzhein b6b6771d8d pull latest readability-php via composer 3 years ago
fox a73e3bec45 Merge pull request 'Use ORM in some more parts of 'update.php'.' (#41) from wn/tt-rss:feature/update-use-idiorm into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/41
3 years ago
wn_ cf0ec06b8c Use ORM in some more parts of 'update.php'. 3 years ago
Andrew Dolgov 73d14338ab fix rendering of category filters on Uncategorized 3 years ago
Andrew Dolgov 9669bb94de main toolbar: clarify element ordering, fix some indents 3 years ago
Andrew Dolgov 44c5d0feba prolong PHP session cookie automatically to stop hard logouts after SESSION_COOKIE_LIFETIME expires 3 years ago
fox cd26dbe64c Merge pull request 'Rewrite feed entry link as href content' (#40) from klempin/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/40
3 years ago
Philip Klempin 14d57d9a14 Rewrite feed entry link as href content 3 years ago
fox 7bd9572aa1 Merge pull request 'Fix operator precedence' (#39) from klempin/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/39
3 years ago
Philip Klempin 1d4d3bc49c Fix operator precedence 3 years ago
Weblate f16fc3bf41 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/
3 years ago
Andrew Dolgov 93a5ba55d3 rebase translations 3 years ago
Andrew Dolgov 800ebd6373 revise previous a little bit more 3 years ago
Andrew Dolgov 69f261c41d revise previous a little bit 3 years ago
Andrew Dolgov e9c062a189 UrlHelper::rewrite_relative():
- support invoking specifying owner URL element/attribute
 - restrict mailto/magnet/tel schemes for A href
 - allow some data: base64 image types for IMG src

Sanitizer::sanitize():

 - when checking href and src attributes, pass element tagname and attribute to rewrite_relative()
3 years ago
fox 34807bacd4 Merge pull request 'Skip all urls with schemes different from base_url in rewrite_relative' (#38) from klempin/tt-rss:fix/mailto into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/38
3 years ago
Andrew Dolgov 4e9c3500fb clarify some @deprecation notices 3 years ago
Philip Klempin b3bedd0a94 Skip URI base on ALLOWED_RELATIVE_SCHEMES in rewrite_relative 3 years ago
Andrew Dolgov 8ed8a10965 add settings profile cloning 3 years ago
Andrew Dolgov 92c78beb90 apply usort workaround for readability-php because its authors were unable to do so for 3 months (https://github.com/andreskrey/readability.php/issues/99) 3 years ago
Andrew Dolgov 8e1281b41e add workaround for prefs feed tree favicon placement 3 years ago
Andrew Dolgov 326850845d UrlHelper::rewrite_relative: don't try to feed NULL to with_trailing_slash() 3 years ago
Andrew Dolgov dff479af64 feeditem_atom: support xml:base for enclosures and entry content
UrlHelper::rewrite_relative: use base URL path if relative url path is not absolute (experimental)
3 years ago
Andrew Dolgov d09a64d6f9 split googlereaderkeys plugin into separate repo 3 years ago
Andrew Dolgov 8574532b7f add hotkeys J/K to move between unread feeds 4 years ago
Andrew Dolgov 4795c4a2a9 Merge branch 'weblate-integration' 4 years ago
Eike 0f51350e9f Translated using Weblate (German)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/de/
4 years ago
Andrew Dolgov 295fc1f88a API: bump api level to 17 4 years ago
Andrew Dolgov 2adf364c2c provide base configuration object in login response to skip on initial getConfig 4 years ago
Andrew Dolgov 9f6237a1b8 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 57cd8acfc9 API: return custom sort types in getConfig 4 years ago
fox 77031575ab Merge pull request 'Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived articles' (#35) from kdan/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/35
4 years ago
linkai 983655165e Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived 4 years ago
kdan 6c06a26649 Merge branch 'master' into master 4 years ago
Andrew Dolgov f423874e05 checking for PDO there is rather useless 4 years ago
Andrew Dolgov b5a559a1a7 sanity check: in single user mode, only test for admin user if migrations have been completed 4 years ago
Andrew Dolgov e3c4724dc1 use database-backed sessions in single user mode 4 years ago
fox 82749ee7a7 Merge pull request 'Improve missing token check' (#36) from skazi/tt-rss:quiet-csrf into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/36
4 years ago
Jacek Tomasiak 0c38dc8456 Improve missing token check
Avoid "E_NOTICE (8) (classes/userhelper.php:78) Undefined index:
csrf_token" in logs.
4 years ago
kdan 2ccf0e50a2 Merge branch 'master' into master 4 years ago
linkai acf0e0d266 Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived 4 years ago
Andrew Dolgov b2f888e386 include archived articles (which lack associated feed id) when browsing by tag 4 years ago
Andrew Dolgov fea59de26b af_redditimgur: use core youtube vid helper 4 years ago
Andrew Dolgov 86300a0ca8 add urlhelper to extract youtube video id from url 4 years ago
Andrew Dolgov d11718c89c fix combined/three panel transition to expandable mode 4 years ago
linkai 0574675ed6 Fix:Plugins-share:init.php - site_url is NULL when share atircle by URL form archived 4 years ago
Andrew Dolgov e8f78181f1 af_redditimgur: instead of generating potentially blacklisted iframes (i.e. huge black boxes),
save found youtube videos as post enclosures for af_youtube_... plugins to deal with later, if enabled
4 years ago
Andrew Dolgov 88a7130d79 fix for previous changeset that broke expanded mode 4 years ago
Andrew Dolgov e8e4fc641e Article.pack: add no-op for three panel mode 4 years ago
Andrew Dolgov df145c8064 * cdm: render enclosures into content element
* deprecate cdm.intermediate
 * implement lazy-load for rendered enclosures
 * simplify pack/unpack logic for articles
4 years ago
Andrew Dolgov c6befcddb7 Merge branch 'master' of git.fakecake.org:fox/tt-rss 4 years ago
Andrew Dolgov 5a71426ea5 youtube_embed: use embed-responsive 4 years ago
Andrew Dolgov b3d45a4a5d Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov cc634ba91d Merge branch 'weblate-integration' 4 years ago
Eike b12072fef9 Translated using Weblate (German)
Currently translated at 99.8% (659 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/de/
4 years ago
fox a6c5dda7e0 Merge pull request 'FIX: public.php - Undefined index: feed_title' (#34) from ohaucke/tt-rss:public-php-fix into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/34
4 years ago
Oliver Haucke cfd9e6b53b FIX: public.php - Undefined index: feed_title 4 years ago
fox 0f61675cd0 Merge pull request 'Fix `getCategory` method.' (#32) from rodneys_mission/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/32
4 years ago
Rodney Stromlund c18383d1ea Fix `getCategory` method. 4 years ago
Andrew Dolgov 3e22368962 getPreviousFeed/getNextFeed: implement wrap around 4 years ago
Andrew Dolgov eadaaebd58 functions_enabled: trim spaces from disable_functions php ini setting 4 years ago
fox 61b4a678ea Merge pull request 'if backend request 'op' is empty fixed' (#27) from Cyb10101/tt-rss:cyb-backend-op into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/27
4 years ago
Cyb10101 c15c1dfb0b if backend request 'op' is empty fixed 4 years ago
Andrew Dolgov a61348e2b7 pluginhost: add profile_get/profile_set helpers 4 years ago
Andrew Dolgov a5af15cfe9 fix noscript notifications 4 years ago
Andrew Dolgov 49ef15f11d * fonts-ui: use system font family instead of segoe, etc. by name
* disable segoe-specific baseline hack for the time being
4 years ago
Andrew Dolgov c0fba62fa0 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 0acd33abe3 OTP: generate longer secrets, also make them easier to read/copy 4 years ago
Андрій Жук 0294297ccc Translated using Weblate (Ukrainian)
Currently translated at 89.8% (593 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/uk/
4 years ago
Piotr a4f82fddf4 Translated using Weblate (Polish)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/pl/
4 years ago
Glandos 49b25a6430 Translated using Weblate (French)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/fr/
4 years ago
fox f2f2b6d1f4 Merge pull request 'Adjust quotation marks in search query before 'str_getcsv'.' (#26) from wn/tt-rss:search-quotation-marks into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/26
4 years ago
wn_ 5d5c034a90 Adjust quotation marks in search query before 'str_getcsv'.
This moves a potential first quotation mark to before the associated keyword to ensure 'str_getcsv' groups the key and value correctly.  Without this 'str_getcsv' would split on potential spaces within the quoted value.
4 years ago
fox 0b82afabd5 Merge pull request 'Fix automatically showing next feed on catchup' (#25) from wn/tt-rss:bugfix/on-catchup-show-next-feed into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/25
4 years ago
wn_ 2ed5a79e64 Fix automatically showing next feed on catchup 4 years ago
Andrew Dolgov 8c32ed76df revert back to lower contrast light theme by default, add separate light-high-contrast.less 4 years ago
Andrew Dolgov ceb8179ccc don't use css-defined .svg files because firefox 4 years ago
Andrew Dolgov 19c277391e fonts-ui: add Cantarell 4 years ago
Andrew Dolgov 58ab641fea light theme: increase contrast 4 years ago
Andrew Dolgov be2d1602bd fix previous issue properly 4 years ago
Andrew Dolgov e3c51b0e6c Revert "clip max displayed counter value to 9999 because of container node width"
This reverts commit c34a4c85bd.
4 years ago
Andrew Dolgov c34a4c85bd clip max displayed counter value to 9999 because of container node width 4 years ago
Andrew Dolgov 0f6644880a yet another flex feedtree attempt 4 years ago
Andrew Dolgov 98251022d4 Revert "Revert "another attempt at flex-based feed tree""
This reverts commit 43744412f4.
4 years ago
Andrew Dolgov 334a361e79 don't try to j/k move to nonexistant feed 4 years ago
Andrew Dolgov d275134f26 unify return values for getPreviousFeed and usages of both prev/next 4 years ago
Andrew Dolgov 2e6d48ead7 * Feeds.openNextUnread: fix
* model.getNextFeed: make sure return values are consistent, stop
wrapping back to starred
4 years ago
Andrew Dolgov 43744412f4 Revert "another attempt at flex-based feed tree"
This reverts commit e12a6ca540.
4 years ago
Andrew Dolgov ef5d6b9b78 Merge branch 'master' of git.fakecake.org:fox/tt-rss 4 years ago
Andrew Dolgov e12a6ca540 another attempt at flex-based feed tree 4 years ago
Andrew Dolgov 1f5adf1600 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 68299c914b share: move og:image back to head 4 years ago
fox 56f7b25e85 Merge pull request 'Switch most of API to ORM' (#23) from wn/tt-rss:orm-api into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/23
4 years ago
wn_ 711e8e70e0 Switch most of API to ORM
'updateArticle' was left as-is due to Idiorm not supporting efficient multi-row updating (i.e. it would do an UPDATE per row).
4 years ago
Andrew Dolgov 718c9f07fa remove model.getNextUnreadFeed; unify code with feedTree.getNextFeed 4 years ago
Andrew Dolgov 43ea36d030 prefs: allow setting email if it was previously blank 4 years ago
fox ce9955c6ff Merge pull request 'Switch Handler_Public to ORM' (#22) from wn/tt-rss:orm-handler-public into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/22
4 years ago
wn_ cd52ca80ab Minor cleanup in 'Handler_Public->getProfiles' 4 years ago
wn_ baf3ecd4cf Fix a couple of array index warnings in 'Handler_Public->forgotpass' 4 years ago
Andrew Dolgov 968270ed48 fix excessive CPU usage on linux chromium caused by animated SVG icons 4 years ago
wn_ 541a07250c Switch 'Handler_Public->forgotpass' to ORM 4 years ago
wn_ f057c124d1 Switch 'Handler_Public->login' to ORM, fix 'Handler_Public->getProfiles' 4 years ago
wn_ 7ea48f7a4b Switch 'Handler_Public->rss' to ORM 4 years ago
wn_ b6ae280446 Switch 'Handler_Public->getProfiles' to ORM 4 years ago
Andrew Dolgov db0315e596 feed tree: set cursor pointer on tree label 4 years ago
Andrew Dolgov 88534a8ae4 fix loadingNode offset for feeds 4 years ago
Andrew Dolgov 82bed1e651 filter test dialog: remove .gif; cleanup markup 4 years ago
fox 7c2b473d12 Merge pull request 'Switch 'RSSUtils::update_basic_info' to ORM' (#21) from wn/tt-rss:orm-update_basic_info into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/21
4 years ago
wn_ 401b22666d Switch 'RSSUtils::update_basic_info' to ORM 4 years ago
Andrew Dolgov 0f5fd9ea13 use svg icon for headlines loadmore prompt 4 years ago
Andrew Dolgov 32c080bec0 use svg icon for the subscribe dialog (night mode) 4 years ago
Andrew Dolgov 166517240e use svg icon for the subscribe dialog 4 years ago
Andrew Dolgov 7a1e1630d8 use svg icon for packed article placeholders 4 years ago
Andrew Dolgov 92f859add2 update night theme re: previous 4 years ago
Andrew Dolgov a0e41f41a4 add svg loading indicators 4 years ago
Andrew Dolgov 7ec8a6cad0 simplify feed tree expando/loading/feed icon handling 4 years ago
Andrew Dolgov d9ba403927 remove some hardcoded color values 4 years ago
Andrew Dolgov 44b274b6d4 remove published opml (use CLI instead) 4 years ago
fox c134aa387d Merge pull request 'Fix E_NOTICE in add_handler().' (#20) from JustAMacUser/tt-rss:fix-addhandler-notice into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/20
4 years ago
Andrew Dolgov f81a579386 fix selected feedtree item being invisible in dark theme 4 years ago
JustAMacUser 39bbbef030 Fix E_NOTICE in `add_handler()`. 4 years ago
Andrew Dolgov 1870fe172b feed tree: css cleanup; set cursor 4 years ago
Andrew Dolgov b23ba3e236 error log: fix column widths 4 years ago
Andrew Dolgov a0ce7f556b nsfw: set cursor pointer 4 years ago
Andrew Dolgov 1664b87821 Merge branch 'weblate-integration' 4 years ago
Andrew Dolgov 13210747d8 mailer: stop warning if to_name is unset (it's optional anyway) 4 years ago
Andrew Dolgov 15b39a534d Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov f7ee812db2 update editorconfig 4 years ago
fox 1b71cd9f44 Merge pull request 'Set orm and pdo mysql charset on connection' (#19) from Gravemind/tt-rss:fix-mysql-charset into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/19
4 years ago
Jordan Galby 3d801b1ac5 set orm and pdo mysql charset on connection 4 years ago
Andrew Dolgov 2f402d598d only show right-side feed icon for vfeeds 4 years ago
Andrew Dolgov 38ab3ef11c Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 4ddcd54e8d * limit progressfunction debugging to size quota exceeded notifications
* af_redditimgur: reparent generated iframes outside of post table
4 years ago
fox 06ebb81eb8 Merge pull request 'Add coalescing operator to otp_enabled when changing user password' (#18) from klempin/tt-rss:fix/undefined-array-key into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/18
4 years ago
Philip Klempin fa22e1bc35 Add coalescing operator to otp_enabled when changing user password 4 years ago
Andrew Dolgov 4e81233ac9 make description clickable in plugin list row 4 years ago
Andrew Dolgov fcce1c443e api: don't try to pass null site_url to Article::_get_image() 4 years ago
Andrew Dolgov bc73bf0f67 cdmToggleGridSpan: toggle classname instead of a style property 4 years ago
Andrew Dolgov efde6d36c7 add HOOK_HEADLINES_SCROLL_HANDLER 4 years ago
Andrew Dolgov e85cba5958 sticky header: better positioning strategy 4 years ago
Marek Pavelka f9d366f028 Translated using Weblate (Czech)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/cs/
4 years ago
Andrew Dolgov 52d1a5c96d gettextify previous 4 years ago
Andrew Dolgov 580eccd3da throttle login attempts, controlled by Config::AUTH_MIN_INTERVAL 4 years ago
Andrew Dolgov b9268fcc88 schema: add ttrss_users.last_auth_attempt 4 years ago
Andrew Dolgov 96d89fe912 shorten_expanded: reduce log spam 4 years ago
Andrew Dolgov bd1630d278 grid mode: limit word breaking to link elements 4 years ago
Andrew Dolgov 76a6060ca3 get_override_links: actually return overrides 4 years ago
Andrew Dolgov 4949e1a590 valid OTP code should not be enough to login, oops 4 years ago
Andrew Dolgov 146b1e0feb * shorten_expanded: use ResizeObserver (DUH)
* add HOOK_HEADLINES_RENDERED
4 years ago
Andrew Dolgov 6e0474a7c8 update zoom layout a bit 4 years ago
Andrew Dolgov f67d2623b7 add some media queries to improve main UI on small-width devices 4 years ago
Andrew Dolgov a4da2f1e62 continuation of the css cleanup 4 years ago
Andrew Dolgov 755072de91 css cleanup, combined mode, fonts 4 years ago
Andrew Dolgov de47082ca6 Article.cdmToggleGridSpan: also set as active 4 years ago
Andrew Dolgov f9a381ecca grid: add a header icon (and a hotkey) to toggle article span entire row 4 years ago
Andrew Dolgov 27ab16b6dc add Config::LOCAL_OVERRIDE_JS 4 years ago
Andrew Dolgov 324aef9f6f route Logger:log() to user_error() if there's no adapter 4 years ago
Andrew Dolgov 03361dda34 remove previous spacer-specific hack, not needed anymore 4 years ago
Andrew Dolgov 24e64b8c78 exp: set last odd grid child to span all columns 4 years ago
Andrew Dolgov 21e0b28cf1 nsfw plugin: we don't actually need any JS 4 years ago
Andrew Dolgov f9a9fcbb56 fix related to Promise.allSettled() returning a bit different result object 4 years ago
Andrew Dolgov 3e1b3e8ea8 grid: add workaround for a single loaded headline not spanning all columns 4 years ago
Andrew Dolgov 143617afb1 * it feels weird for requireIdleCallback() to be optional while more
modern browser features are required
 * simplify browser startup feature check a bit
4 years ago
Andrew Dolgov 84fe383ed4 adjust grid view footer (3) 4 years ago
Andrew Dolgov 16726ec07f adjust grid view footer (2) 4 years ago
Andrew Dolgov a0dd5baa51 adjust grid view footer 4 years ago
Andrew Dolgov 5e738ec278 shorten stuff in af_zz_vidmute 4 years ago
Andrew Dolgov 71b12857e0 in grid mode, also force word-break .intermediate (enclosures) 4 years ago
Andrew Dolgov 353ee40378 shorten_expanded: remove loading=lazy on the js side instead 4 years ago
Andrew Dolgov 668b0ac7a6 shorten_expanded: no need to hook on HOOK_SANITIZE anymore 4 years ago
Andrew Dolgov fb89c3bad0 instead of a fixed column layout, fit based on minimum column size 4 years ago
Andrew Dolgov 5bc47451e1 shorten_expanded: increase timeout 4 years ago
Andrew Dolgov 36ad46e60d * shorten_expanded: use promises instead of a timeout hack
* normalize some icon colors
4 years ago
Ptsa Daniel 02af69328f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (655 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
4 years ago
Dario Di Ludovico 4aa595e3ba Translated using Weblate (Italian)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/it/
4 years ago
Andrew Dolgov 96031c80bf stop setting specific background color on .cdm.expanded 4 years ago
Andrew Dolgov e826c9e055 fix crash in preferences due to headlines-frame missing 4 years ago
Andrew Dolgov f58879c1dc small stuck header fixes in grid mode 4 years ago
Andrew Dolgov bdc72e5b63 fix headlines-spacer height in grid mode 4 years ago
Andrew Dolgov df9c389cbf in grid mode, hide feed title from header 4 years ago
Andrew Dolgov b6033d0bbd grid view tweaks 4 years ago
Andrew Dolgov 0b93d8d013 add hotkey to toggle grid view 4 years ago
Andrew Dolgov 089fa5ec26 use proper syntax for equal-width columns 4 years ago
Andrew Dolgov 87d13e826f fix vfeed group subtitle in grid mode 4 years ago
Andrew Dolgov eba8c97f36 some minor grid stuff 4 years ago
Andrew Dolgov a3ab4020bf set #headlines-spacer, etc, to span grid columns 4 years ago
Andrew Dolgov ddfa39015e experimental: add preference to show combined mode headlines as a 2 column grid 4 years ago
Andrew Dolgov 6ec66d0ce5 set border color on that too 4 years ago
Andrew Dolgov f804caec90 support coloring counters by feed-id/is-cat; set fresh counter to green 4 years ago
Andrew Dolgov ae7b87bca9 add HOOK_HEADLINE_MUTATIONS, HOOK_HEADLINE_MUTATIONS_SYNCED 4 years ago
Andrew Dolgov 2160a86092 show E_COMPILE_ERROR in event log at higher severity levels 4 years ago
Andrew Dolgov 4e1c78374f error log: allow wrapping long filenames 4 years ago
Andrew Dolgov 74391ec30a reorganize update.php a bit, remove unneeded options 4 years ago
Andrew Dolgov dd9d017f7d add another coalesce for rule inverse 4 years ago
Andrew Dolgov 9b321be270 get_article_filters: set coalesce values for inverse and match_any_rule 4 years ago
Andrew Dolgov 4fe2e6bbf1 app password list: fix th/td alignment 4 years ago
Andrew Dolgov b1961163b8 af_redditimgur: import link flair as tags 4 years ago
Andrew Dolgov bc7cb76379 describe global settings in classes/config.php 4 years ago
Weblate 63ca6333a5 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/
4 years ago
Andrew Dolgov ea25c49eb9 update messages.pot 4 years ago
Andrew Dolgov fe4c284858 Merge branch 'weblate-integration' 4 years ago
fox 9b2267510b Merge pull request 'Default to null 'rv' for plugin update check.' (#17) from wn/tt-rss:inaccurate-available-plugin-updates into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/17
4 years ago
wn_ fed5158ec5 Default to null 'rv' for plugin update check.
Previously 'rv' was returned as an empty JS array, causing 'p.rv.git_status != 0' to evaluate to true and a misleading 'Ready to update' appearing for certain plugins.
4 years ago
Andrew Dolgov cfb4882591 cleanup javascript_tag and stylesheet_tag 4 years ago
Andrew Dolgov 28dd255c30 show user css editor before xhr is completed 4 years ago
Andrew Dolgov bfeaf4d6a4 search dialog: add button icon 4 years ago
Andrew Dolgov ef03f8188c api: add support for setting score (bump api level to 16) 4 years ago
Andrew Dolgov c26f58d8a5 fix some php8 warnings 4 years ago
Andrew Dolgov a125e8540d Merge branch 'master' of git.fakecake.org:fox/tt-rss 4 years ago
Andrew Dolgov 1fb7125f90 minor cleanup related to toolbar-main (use dijit methods, etc) 4 years ago
Andrew Dolgov 46b77fc6b7 fix digest preview not working on mysql because of a quoted LIMIT argument 4 years ago
Andrew Dolgov 5db6939dc9 add to previous a bit 4 years ago
Andrew Dolgov 603cc89638 check updates one plugin at a time 4 years ago
Andrew Dolgov f4d0e7bb6d * af_redditimgur: optionally import score
* add pluginhost->set_array() to set many plugin settings at once
4 years ago
Andrew Dolgov 72c04123d4 HOOK_ARTICLE_IMAGE: stop after first provided match 4 years ago
Andrew Dolgov 518e677a6b nsfw: fix wrong return parameter count in hook article image 4 years ago
Andrew Dolgov 266c8a6eae add nsfw.png placeholder 4 years ago
Andrew Dolgov ac6a59914b nsfw: support API clients 4 years ago
Andrew Dolgov ffb93d72ac fix previous to actually save enabled plugins 4 years ago
Andrew Dolgov 773bad1490 prevent list of enabled plugins resetting if saved while in search results 4 years ago
Andrew Dolgov 1dcc36deca make rendered labels clickable 4 years ago
Andrew Dolgov c036c27ec7 logger: use constants instead of hardcoded string literals 4 years ago
Andrew Dolgov 17650775d2 hide event log accordion pane if LOG_DESTINATION is not sql 4 years ago
Andrew Dolgov 5bb8714839 allow blank override values 4 years ago
Andrew Dolgov 77b5201b7d set plugin list name width same as preferences table 4 years ago
Andrew Dolgov d6fd0d5462 add some icons, remove some words 4 years ago
Andrew Dolgov 39c570a9ff Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov b27218a1e3 add some more dialog icons 4 years ago
fox cb81b784e8 Merge pull request 'Fix "array offset on value of type null" for $error and $old_error' (#16) from ltGuillaume/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/16
4 years ago
Andrew Dolgov 1d9fa2a42e reduce overhead in hash set/get 4 years ago
ltGuillaume 825e362f0e Fix "array offset on value of type null" for $error and $old_error
I tried applying to only $error and only $old_error, but both appear to be needed.

Log entries:
E_NOTICE (8) 	classes/urlhelper.php:464 	Trying to access array offset on value of type null
1. classes/urlhelper.php(464): ttrss_error_handler(8, Trying to access array offset on value of type null, classes/urlhelper.php, 464, [)
2. classes/rssutils.php(464): fetch([{"url":"https://some.url.rss","login":"","pass":"","timeout":15,"last_modified":"Sat, 31 Aug 2019 15:22:31 GMT"})
3. update.php(235): update_rss_feed(732, 1)
4 years ago
Andrew Dolgov 7b0b5b55c7 fix plugins-list line height 4 years ago
Andrew Dolgov 68ecf52594 some small layout fixes, remove a few inline styles 4 years ago
Andrew Dolgov 473ea6255c render list of plugins on the client 4 years ago
Andrew Dolgov 217922899d set some more type hints 4 years ago
Andrew Dolgov 270f0c3132 general cleanup, set some type hints 4 years ago
Andrew Dolgov 63651bd91d fix some leftover variables 4 years ago
Andrew Dolgov e5469479c1 * don't try to update custom set feed favicons
* cleanup update_rss_feed() a bit, use ORM
4 years ago
Andrew Dolgov 42e057c808 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 53dcd4b229 fix plugins not shown as already installed if they have more than 1 dash 4 years ago
fox 42cb2e5112 Merge pull request 'The type hint for 'DAEMON_MAX_CHILD_RUNTIME' should be T_INT' (#15) from wn/tt-rss:deamon-max-child-runtime-type-hint into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/15
4 years ago
wn_ 2e8b064236 The type hint for 'DAEMON_MAX_CHILD_RUNTIME' should be T_INT 4 years ago
Andrew Dolgov 2cd159e2ce use separate database column for OTP secrets (migrate previous format if needed) 4 years ago
Andrew Dolgov 2aed79d729 schema: add separate otp_secret column 4 years ago
Andrew Dolgov ecb94ec23d login page: fix a warning if return is unset 4 years ago
Andrew Dolgov 5c1f9f31bd add a bunch of button icons 4 years ago
Andrew Dolgov fe06416f17 sessions: stop validating against hash of user agent because chromium is sending
different agent headers for whatever reason, example:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
like Gecko) Chrome/88.0.4324.192 Safari/537.36

Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like
Gecko) Chrome/88.0.4324.104 Safari/537.36

seems to be related, at least, to App.postOpenWindow() hack.
4 years ago
Andrew Dolgov 98c75a9e43 don't check for plugin updates automatically on pane open 4 years ago
Andrew Dolgov b649d2240f split af_zz_noautoplay into a separate repo 4 years ago
Andrew Dolgov c8883d3440 af_comics filters: don't try to load empty html 4 years ago
Andrew Dolgov bc2953b5e7 split no_url_hashes into a separate repo 4 years ago
Andrew Dolgov 198c9b4069 split scored_oldest_first into a separate repo 4 years ago
Andrew Dolgov e8e6329040 rename unfairly prefixed get_enclosures() in feeditem 4 years ago
Andrew Dolgov c744cfe2dc plugin installer: show last commit timestamp 4 years ago
Andrew Dolgov d016f7a499 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 476965b161 show installed plugins in the installer list 4 years ago
fox c9b0196de0 Merge pull request 'Fix Undefined index when using Single User Mode' (#14) from Threk/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/14
4 years ago
Threk 9442ceb7bd Fix Undefined index when using Single User Mode 4 years ago
Andrew Dolgov f398fea414 shorten plugin list action buttons 4 years ago
Andrew Dolgov cb4b730e42 split af_unburn 4 years ago
Andrew Dolgov 386dc415d9 a bit better search behavior for plugin installer 4 years ago
Andrew Dolgov 9b8b07376f shorten install button text 4 years ago
Andrew Dolgov f90531ae40 reduce plugin installer entry height 4 years ago
Andrew Dolgov 6cf771f2bc _get_available_plugins: decode as array 4 years ago
Andrew Dolgov c50a4296a5 split vf_shared 4 years ago
Andrew Dolgov 04128c7870 add search to plugin installer 4 years ago
Andrew Dolgov 2f6ea8b387 split a bunch of plugins into separate repos 4 years ago
Andrew Dolgov b74e313844 use computed style for element.prototype.visible 4 years ago
Andrew Dolgov 4fda5ccd0e fix a bunch of bookmarklets login forms not leading back 4 years ago
Andrew Dolgov 30765805fd use orm for settings profiles stuff 4 years ago
Andrew Dolgov 31b29e0a56 log applied migrations 4 years ago
Andrew Dolgov 8f8ca49e4b migrations: refuse to apply empty schema files 4 years ago
Andrew Dolgov 4ede76280b migrations: don't try to use transactions on mysql 4 years ago
Andrew Dolgov bd4ade6329 remove ttrss_version from base schema 4 years ago
Andrew Dolgov 5eb0f3d640 bring back web dbupdate using new migrations system 4 years ago
Andrew Dolgov e19570f422 sessions: don't check schema version 4 years ago
Andrew Dolgov c0fb0a5ec0 wip for db_migrations for core schema 4 years ago
Andrew Dolgov 921569e5da support loading base schema as latest version 4 years ago
Andrew Dolgov 8256ab5dd9 wip: initial for db_migrations 4 years ago
Andrew Dolgov 0cb719a404 add basic local plugin uninstaller 4 years ago
Marek Pavelka 5c6c123676 Translated using Weblate (Czech)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/cs/
4 years ago
Andrew Dolgov dfdb746a76 add word wrap for git stdout/stderr pre elements 4 years ago
Andrew Dolgov cb7f322f09 add basic plugin installer (uses tt-rss.org) 4 years ago
Andrew Dolgov 06cb181f73 add update button for system plugins 4 years ago
Andrew Dolgov 75e659ba65 reduce Amount of Caps Used in Multiple Dialogs 4 years ago
Andrew Dolgov 0730128a97 add a send test email button to prefs/system 4 years ago
Andrew Dolgov dbda996a7a previous one was not good enough i guess 4 years ago
Andrew Dolgov 1aedd22306 config::make_self_url() strip index.php etc 4 years ago
Andrew Dolgov 50087df162 * remove _SKIP_SELF_URL_PATH_CHECKS
* simplify SELF_URL_PATH checks wrt trailing slash
4 years ago
Andrew Dolgov adf7189e94 show timing information in xhr.post/json 4 years ago
Andrew Dolgov 3b67abb0ea reddit: import comment counts 4 years ago
Andrew Dolgov 6f93c45c28 use orm in some more places; prevent _get_cat_title from hitting the db for uncategorized 4 years ago
Andrew Dolgov 9ec0732942 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov ba86c64d38 add digest preview button, also fix a bunch of bugs 4 years ago
fox c4b78ed0a6 Merge pull request 'Fix undefined array key warnings when using iOS app' (#12) from sam302psu/tt-rss:undefined-array-keys into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/12
4 years ago
sam302psu 57fdf032e9 changed skip and limit to coalesce to 0 instead of "" 4 years ago
sam302psu 8f8142df29 Fix undefined array key warnings when using iOS app
Use coalesce operator and empty string/default value to fix undefined array key warnings filling up logs when using iOS app to access api.
4 years ago
Andrew Dolgov 386316aba1 update previous (comment) 4 years ago
Andrew Dolgov 1ab6ca57af initialize Db object early because otherwise ORM might be used unconfigured 4 years ago
Andrew Dolgov d6629ed188 move dbupdater to db/updater; move base SCHEMA_VERSION constant inside db/updater class 4 years ago
Andrew Dolgov 86b12fc06c pluginhost: remove namespace classloader, plugins should use composer instead 4 years ago
Andrew Dolgov 08ff629af5 limit user data sent to frontend 4 years ago
Andrew Dolgov d4ad483add user editor: allow toggling otp 4 years ago
Andrew Dolgov 982bd838bf use orm when setting personal data; fix some warnings in mailer class 4 years ago
Andrew Dolgov 30b94fb194 store widescreen mode setting in preferences instead of a cookie 4 years ago
Andrew Dolgov 1a7f724bfa move around some methods in base plugins class 4 years ago
Andrew Dolgov 20d0cbff77 use ORM for article _labels_of/_feeds_of 4 years ago
Andrew Dolgov f9888fc67f use separate connection for logging 4 years ago
Andrew Dolgov c4eaab8a31 feeds/_add_cat: use ORM 4 years ago
Andrew Dolgov 7cf12233d7 use ORM when subscribing feeds 4 years ago
Andrew Dolgov dae0476159 sql logger: use orm 4 years ago
Andrew Dolgov 2005a7bf4f revise behavior of Feeds::_cat_of 4 years ago
Andrew Dolgov f097ae608d article/redirect: use orm (cast id to int) 4 years ago
Andrew Dolgov 3bab5ca6b1 article/redirect: use orm 4 years ago
Andrew Dolgov f195e86be3 don't rely on exit code when checking version (again) 4 years ago
Andrew Dolgov 84d8b08d1f use orm for feed access keys 4 years ago
Andrew Dolgov 70adfd4a74 * sanitize: never rewrite relative links to our own prefix
* use Config::get_self_url() instead of get_self_url_prefix() in a bunch
of places
4 years ago
Andrew Dolgov 6f835ded78 remove (unused) prefs/toggleAdvanced 4 years ago
Andrew Dolgov f56a4eab17 use orm for app password stuff 4 years ago
Andrew Dolgov 372e8e062c Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 51ed72efab use dash instead of space when invoking git to get version 4 years ago
fox cd504b0e60 Merge pull request 'Get the version as an array in RPC->checkforupdates.' (#11) from wn/tt-rss:bugfix/checkforupdates-version into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/11
4 years ago
wn_ 03400bd8d4 Get the version as an array in RPC->checkforupdates. 4 years ago
Andrew Dolgov 031ee47a3e don't try to pass string literal NOW() to ORM as a timestamp 4 years ago
Andrew Dolgov b150e46a52 revert back load_filters-related changes 4 years ago
Andrew Dolgov cd962dfa00 delete Article getScore (seems to be unused) 4 years ago
Andrew Dolgov 56f658711f use orm for a bunch of short feed/cat queries 4 years ago
Andrew Dolgov 8b1a2406e6 userhelper: use orm for a few more user-related things 4 years ago
Andrew Dolgov 127a868e40 userhelper: use orm for some things 4 years ago
Andrew Dolgov f38be747d1 initial for idiorm 4 years ago
Andrew Dolgov f96abd2b52 generate_syndicated_feed: timestamp is a strtotime() expression, not an integer 4 years ago
Andrew Dolgov 2d1391a02b come to think of it, we don't need it at all 4 years ago
Andrew Dolgov dbad39d7a2 auth_internal: don't try to get otp_enabled on old schema 4 years ago
Andrew Dolgov 6359259dbb simplify internal authentication code and bump default algo to SSHA-512 4 years ago
Andrew Dolgov 320503dd39 move version-related stuff to Config; fix conditional feed requests 4 years ago
Andrew Dolgov 20a844085f hide version for bundled plugins because it's meaningless; for everything else support showing version using git (if about[0] is null) 4 years ago
Andrew Dolgov 1e6973307c we don't need to initialize urlhelper properties 4 years ago
Andrew Dolgov 7ef72fe0dc move startup checks to Config, set a bunch of @deprecated annotations 4 years ago
Andrew Dolgov b05d4e3d9f speed up plugin updating a bit, fix some phpstan warnings 4 years ago
Andrew Dolgov bf02afed45 check schema version on backend calls because session stuff does it anyway and it's already cached 4 years ago
Andrew Dolgov 1bb0d9b603 sanity_check: config.php is now optional, also cleanup some error messages 4 years ago
Andrew Dolgov a22ddb2fe0 move material-icons to composer 4 years ago
Andrew Dolgov bada1601fc OTP form: simplify layout, use dojo controls 4 years ago
Andrew Dolgov f4fdc9c2a3 some plugin updater UI improvements 4 years ago
Andrew Dolgov afc7142250 move all $fetch globals to UrlHelper 4 years ago
Andrew Dolgov e2cbb54b2c plugin updater: show changes before updating 4 years ago
Andrew Dolgov 7f2fe465b0 add plugin updates checker into normal updates checker 4 years ago
Andrew Dolgov d821e4b090 disable plugin update checking if CHECK_FOR_UPDATES is disabled 4 years ago
Andrew Dolgov 85f411d688 don't try to update all plugins 4 years ago
Andrew Dolgov 15f9cb708e reload prefs when plugin updater is closed 4 years ago
Andrew Dolgov de63e3799a only show plugin update buttons when needed 4 years ago
Ptsa Daniel 5832b0b040 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (655 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
4 years ago
Andrew Dolgov cf5c7c4f29 feeds/add: hide php8 warning 4 years ago
Andrew Dolgov 78a7b3642f af_redditimgur: allow adding custom tags for NSFW posts 4 years ago
Andrew Dolgov dfff2cef7b add basic updater for stuff in plugins.local 4 years ago
Andrew Dolgov 5edcbf2e9b add an option to disable conditional counters 4 years ago
Andrew Dolgov c1cd3324e3 bump schema for ttrss_user_labels2 indexes 4 years ago
Andrew Dolgov 6d06450649 don't rely only on label_cache contents when displaying headline labels 4 years ago
Andrew Dolgov 126b1fd2de don't try to compare null value against anything 4 years ago
Andrew Dolgov c521e26a19 use absolute namespace for readability 4 years ago
Andrew Dolgov d6bb77f452 exclude a bunch of phpunit test files 4 years ago
Andrew Dolgov ebf16a36a1 remove a bunch of return type hints that didn't quite fit 4 years ago
Andrew Dolgov ef8c3abd7e Merge branch 'master' of git.tt-rss.org:fox/tt-rss 4 years ago
Andrew Dolgov 3fd7856543 * switch to composer for qrcode and otp dependencies
* move most OTP-related stuff into userhelper
* remove old phpqrcode and otphp libraries
4 years ago
fox c6fb62f384 Merge pull request 'fix-mysql-support' (#10) from klatch/tt-rss:fix-mysql-support into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/10
4 years ago
Andrew Dolgov bc4475b669 add missing composer files 4 years ago
Andrew Dolgov cf1ede0ba8 pull latest readability-php via composer 4 years ago
fox 1baf8c5217 Merge pull request 'Fix the type hint for '_DEFAULT_VIEW_MODE'.' (#9) from wn/tt-rss:bugfix/default-view-mode-type into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/9
4 years ago
Andrew Dolgov d577eb898c when browsing by tags, return same set of columns as normally 4 years ago
Andrew Dolgov c01b6e43fd add pluginhost->get_array() shorthand 4 years ago
wn_ 86513d70dd Fix the type hint for '_DEFAULT_VIEW_MODE'. 4 years ago
Andrew Dolgov bf9033beb6 rebase-translations: disable everything except for messages.pot 4 years ago
Andrew Dolgov 167c9fc34e silence php8 warnings in otp secondary login form 4 years ago
Andrew Dolgov e6a875b7e4 check if client-presented URL scheme is different from one configured in SELF_URL_PATH 4 years ago
Andrew Dolgov 4896874bda _get_headlines: don't try to use _SESSION uid 4 years ago
Andrew Dolgov fa7c6a6129 we need to compile .mo files after all 4 years ago
Dario Di Ludovico b63119df33 Translated using Weblate (Italian)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/it/
4 years ago
Andrew Dolgov b5d9b285f1 Translated using Weblate (Russian)
Currently translated at 91.5% (604 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/ru/
4 years ago
Weblate 05364e11ed Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/
4 years ago
Andrew Dolgov cb512d653c match a few more translated strings 4 years ago
Andrew Dolgov 2a0b3a161c rebase-translations: try only dealing with messages.pot, let weblate rebuild .po files 4 years ago
Weblate ab0bf8692d Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/
4 years ago
Andrew Dolgov c21fbb2d13 rebase translations, fixing a few JS strings not mached; remove obsolete scripts (2) 4 years ago
Andrew Dolgov 15cad4a9c0 rebase translations, fixing a few JS strings not mached; remove obsolete scripts 4 years ago
Andrew Dolgov 634f1210a6 Merge branch 'weblate-integration' 4 years ago
Andrew Dolgov 9a2f893672 Translated using Weblate (Russian)
Currently translated at 90.0% (588 of 653 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/ru/
4 years ago
Andrew Dolgov 8d49b6396e Merge branch 'weblate-integration' 4 years ago
Ptsa Daniel 5794a801f0 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (648 of 653 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
4 years ago
Dario Di Ludovico 1dfa699aea Translated using Weblate (Italian)
Currently translated at 100.0% (653 of 653 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/it/
4 years ago
Glandos a6853d2f49 Translated using Weblate (French)
Currently translated at 100.0% (653 of 653 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/fr/
4 years ago
Andrew Dolgov 26a6177bc9 upd previous 4 years ago
Andrew Dolgov 9689f884ab add Prefs::DEBUG_HEADLINE_IDS 4 years ago
Andrew Dolgov 05f690c86b add a separator before HEADLINES_NO_DISTINCT 4 years ago
Andrew Dolgov 3ab664f846 feeds/view: silence view_mode warning 4 years ago
Andrew Dolgov f3d4bae32e add an option to disable DISTINCT on headlines query (unless it's Labels category) 4 years ago
Andrew Dolgov 51142e1bf8 silence phpstan warning 4 years ago
Andrew Dolgov 7815a881e8 cleanup previous 4 years ago
Andrew Dolgov 56b10fea18 pass translations to frontend as a json object 4 years ago
Andrew Dolgov fd9cd52929 prefs: migrate after cache has been filled to skip 1 pref request 4 years ago
Andrew Dolgov a1ca62af50 cache schema version better 4 years ago
Andrew Dolgov 22ae284db4 reduce overall amount of unnecessary database queries 4 years ago
Andrew Dolgov 281f2efeb8 wrap prefs->migrate() into a transaction block 4 years ago
Andrew Dolgov 89ad25405e userhelper: only notify failed login for actual logins 4 years ago
Andrew Dolgov 8915bd1b21 fix crash caused by non-numeric non-null _SESSION[uid] passed to sql logger 4 years ago
Andrew Dolgov 34c74400a4 enforce some stricter type checking for loggers 4 years ago
Andrew Dolgov dcf0135285 logger: shorter syntax 4 years ago
Andrew Dolgov 59c14e9c00 api: remove base64 encoded passwords (wtf), log all authentication failures in userhelper 4 years ago
Andrew Dolgov efd196839a stop caching schema version entirely, fix some session_start() related warnings 4 years ago
Andrew Dolgov 1464abbbfc prefs cleanup 4 years ago
Andrew Dolgov f137e64a13 get_version: pass int to strftime() 4 years ago
Andrew Dolgov c96172fa04 use constants in get_pref()/set_pref() 4 years ago
Andrew Dolgov 5aa05c90e1 pref-prefs: use constants instead of hardcoded strings 4 years ago
Andrew Dolgov 011e318947 prefs: don't try to do anything on schema < 141 4 years ago
Andrew Dolgov 6f02b1afd0 cleanup a bunch of old prefs code 4 years ago
Frenck Lutke 27b676b7b2 fix checkboxes shown as checked when they're not with mysql
The issue occurs because boolean/tinyint values are retrieved from mysql
as strings, and in php/js all non-empty strings are cast as boolean
true.

Current PDO mysql driver doesn't support `PDO::ATTR_STRINGIFY_FETCHES =
false`, and if I disable prepare-emulation so it uses the native MySQL
driver instead which supposedly does support it, prepare statements no
longer play nice with named parameters.

Every remaining clean solution that comes to mind that can cover all
cases, just for MySQL, adds an annoying amount of additional code /
overhead.

As long as the `App.FormFields.checkbox_tag()` JS function is the only
one suffering from the lack of conversion, I'll go with easy ugly over
here.
4 years ago
Andrew Dolgov 7f18e8c33b updater: show owner login instead of just uid 4 years ago
Andrew Dolgov 7869378436 deal with feed update scheduling w/ new prefs 4 years ago
Frenck Lutke 2f2642bbd4 add fallback for feed_language on edit-feed-saving
Feed_language is only included in the form if running on pgsql, failing
the not null constraint on mysql setups.
4 years ago
Andrew Dolgov 00d0cb8c81 remove unused data from schema files 4 years ago
Andrew Dolgov 2621fe7955 fix get_pref always using default profile; remove unneeded code from db_prefs 4 years ago
Andrew Dolgov bd2314170d implement prefs UI based on new prefs class and a few more things 4 years ago
Andrew Dolgov e858e979e9 Merge branch 'master' into wip-new-prefs 4 years ago
Andrew Dolgov 49a9afadce add prefs caching 4 years ago
Andrew Dolgov 1112922029 bump schema for upcoming prefs overhaul 4 years ago
Andrew Dolgov 8026f3c3bd initial (wip) for new prefs: add missing 4 years ago
Andrew Dolgov 988eb3ac91 initial (wip) for new prefs 4 years ago
Andrew Dolgov 922a699215 reorder debug targets 4 years ago

@ -4,3 +4,6 @@ insert_final_newline = true
[*.php]
indent_style = tab
[*.js]
indent_style = tab

1
.gitignore vendored

@ -11,3 +11,4 @@ Thumbs.db
/cache/*/*
/lock/*
/.vscode/settings.json
/vendor/**/.git

@ -1,6 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for XDebug",
"type": "php",
"request": "launch",
"pathMappings": {
"/var/www/html/tt-rss": "${workspaceRoot}",
},
"port": 9000
},
{
"name": "Launch Chrome",
"request": "launch",
@ -10,14 +19,6 @@
},
"urlFilter": "*/tt-rss/*",
"runtimeExecutable": "chrome.exe",
},
{
"name": "Listen for XDebug",
"type": "php",
"request": "launch",
"pathMappings": {
"/var/www/html/tt-rss": "${workspaceRoot}",
},
"port": 9000
}]
}
]
}

@ -20,6 +20,3 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Copyright (c) 2005 Andrew Dolgov (unless explicitly stated otherwise).
Uses Silk icons by Mark James: http://www.famfamfam.com/lab/icons/silk/

@ -23,7 +23,7 @@
if (!empty($_REQUEST["sid"])) {
session_id($_REQUEST["sid"]);
@session_start();
session_start();
}
startup_gettext();
@ -46,7 +46,7 @@
UserHelper::load_user_plugins($_SESSION["uid"]);
}
$method = strtolower($_REQUEST["op"]);
$method = strtolower($_REQUEST["op"] ?? "");
$handler = new API($_REQUEST);

@ -2,7 +2,7 @@
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
get_include_path());
$op = $_REQUEST["op"];
$op = $_REQUEST['op'] ?? '';
$method = !empty($_REQUEST['subop']) ?
$_REQUEST['subop'] :
$_REQUEST["method"] ?? false;
@ -51,6 +51,11 @@
UserHelper::load_user_plugins($_SESSION["uid"]);
}
if (Config::is_migration_needed()) {
print Errors::to_json(Errors::E_SCHEMA_MISMATCH);
return;
}
$purge_intervals = array(
0 => __("Use default"),
-1 => __("Never purge"),
@ -96,7 +101,6 @@
$op = "pluginhandler";
} */
// TODO: figure out if is this still needed
$op = str_replace("-", "_", $op);
$override = PluginHost::getInstance()->lookup_handler($op, $method);
@ -135,6 +139,9 @@
} else {
if (method_exists($handler, "catchall")) {
$handler->catchall($method);
} else {
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD, ["info" => get_class($handler) . "->$method"]);
}
}
$handler->after();
@ -154,6 +161,6 @@
}
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
?>

@ -1,7 +1,7 @@
<?php
class API extends Handler {
const API_LEVEL = 15;
const API_LEVEL = 17;
const STATUS_OK = 0;
const STATUS_ERR = 1;
@ -36,7 +36,7 @@ class API extends Handler {
return false;
}
if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref('ENABLE_API_ACCESS')) {
if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref(Prefs::ENABLE_API_ACCESS)) {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
return false;
}
@ -49,7 +49,7 @@ class API extends Handler {
}
function getVersion() {
$rv = array("version" => get_version());
$rv = array("version" => Config::get_version());
$this->_wrap(self::STATUS_OK, $rv);
}
@ -59,25 +59,29 @@ class API extends Handler {
}
function login() {
@session_destroy();
@session_start();
if (session_status() == PHP_SESSION_ACTIVE) {
session_destroy();
}
session_start();
$login = clean($_REQUEST["user"]);
$password = clean($_REQUEST["password"]);
$password_base64 = base64_decode(clean($_REQUEST["password"]));
if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin";
if ($uid = UserHelper::find_user_by_login($login)) {
if (get_pref("ENABLE_API_ACCESS", $uid)) {
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) { // try login with normal password
$this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
"api_level" => self::API_LEVEL));
} else if (UserHelper::authenticate($login, $password_base64, false, Auth_Base::AUTH_SERVICE_API)) { // else try with base64_decoded password
if (get_pref(Prefs::ENABLE_API_ACCESS, $uid)) {
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) {
// needed for _get_config()
UserHelper::load_user_plugins($_SESSION['uid']);
$this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
"config" => $this->_get_config(),
"api_level" => self::API_LEVEL));
} else { // else we are not logged in
user_error("Failed login attempt for $login from " . UserHelper::get_user_ip(), E_USER_WARNING);
} else {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
}
} else {
@ -99,8 +103,8 @@ class API extends Handler {
}
function getUnread() {
$feed_id = clean($_REQUEST["feed_id"]);
$is_cat = clean($_REQUEST["is_cat"]);
$feed_id = clean($_REQUEST["feed_id"] ?? "");
$is_cat = clean($_REQUEST["is_cat"] ?? "");
if ($feed_id) {
$this->_wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat)));
@ -133,49 +137,48 @@ class API extends Handler {
// TODO do not return empty categories, return Uncategorized and standard virtual cats
if ($enable_nested)
$nested_qpart = "parent_cat IS NULL";
else
$nested_qpart = "true";
$categories = ORM::for_table('ttrss_feed_categories')
->select_many('id', 'title', 'order_id')
->select_many_expr([
'num_feeds' => '(SELECT COUNT(id) FROM ttrss_feeds WHERE ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id)',
'num_cats' => '(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE c2.parent_cat = ttrss_feed_categories.id)',
])
->where('owner_uid', $_SESSION['uid']);
$sth = $this->pdo->prepare("SELECT
id, title, order_id, (SELECT COUNT(id) FROM
ttrss_feeds WHERE
ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id) AS num_feeds,
(SELECT COUNT(id) FROM
ttrss_feed_categories AS c2 WHERE
c2.parent_cat = ttrss_feed_categories.id) AS num_cats
FROM ttrss_feed_categories
WHERE $nested_qpart AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
if ($enable_nested) {
$categories->where_null('parent_cat');
}
$cats = array();
$cats = [];
while ($line = $sth->fetch()) {
if ($include_empty || $line["num_feeds"] > 0 || $line["num_cats"] > 0) {
$unread = getFeedUnread($line["id"], true);
foreach ($categories->find_many() as $category) {
if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) {
$unread = getFeedUnread($category->id, true);
if ($enable_nested)
$unread += Feeds::_get_cat_children_unread($line["id"]);
$unread += Feeds::_get_cat_children_unread($category->id);
if ($unread || !$unread_only) {
array_push($cats, array("id" => (int) $line["id"],
"title" => $line["title"],
"unread" => (int) $unread,
"order_id" => (int) $line["order_id"],
));
array_push($cats, [
'id' => (int) $category->id,
'title' => $category->title,
'unread' => (int) $unread,
'order_id' => (int) $category->order_id,
]);
}
}
}
foreach (array(-2,-1,0) as $cat_id) {
foreach ([-2,-1,0] as $cat_id) {
if ($include_empty || !$this->_is_cat_empty($cat_id)) {
$unread = getFeedUnread($cat_id, true);
if ($unread || !$unread_only) {
array_push($cats, array("id" => $cat_id,
"title" => Feeds::_get_cat_title($cat_id),
"unread" => (int) $unread));
array_push($cats, [
'id' => $cat_id,
'title' => Feeds::_get_cat_title($cat_id),
'unread' => (int) $unread,
]);
}
}
}
@ -189,15 +192,15 @@ class API extends Handler {
if (is_numeric($feed_id)) $feed_id = (int) $feed_id;
$limit = (int)clean($_REQUEST["limit"]);
$limit = (int)clean($_REQUEST["limit"] ?? 0 );
if (!$limit || $limit >= 200) $limit = 200;
$offset = (int)clean($_REQUEST["skip"]);
$offset = (int)clean($_REQUEST["skip"] ?? 0);
$filter = clean($_REQUEST["filter"] ?? "");
$is_cat = self::_param_to_bool(clean($_REQUEST["is_cat"] ?? false));
$show_excerpt = self::_param_to_bool(clean($_REQUEST["show_excerpt"] ?? false));
$show_content = self::_param_to_bool(clean($_REQUEST["show_content"]));
$show_content = self::_param_to_bool(clean($_REQUEST["show_content"] ?? false));
/* all_articles, unread, adaptive, marked, updated */
$view_mode = clean($_REQUEST["view_mode"] ?? null);
$include_attachments = self::_param_to_bool(clean($_REQUEST["include_attachments"] ?? false));
@ -259,6 +262,10 @@ class API extends Handler {
break;
case 3:
$field = "note";
break;
case 4:
$field = "score";
break;
};
switch ($mode) {
@ -274,6 +281,7 @@ class API extends Handler {
}
if ($field == "note") $set_to = $this->pdo->quote($data);
if ($field == "score") $set_to = (int) $data;
if ($field && $set_to && count($article_ids) > 0) {
@ -296,60 +304,59 @@ class API extends Handler {
}
function getArticle() {
$article_ids = explode(",", clean($_REQUEST["article_id"]));
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
self::_param_to_bool($_REQUEST["sanitize"]);
if (count($article_ids) > 0) {
$article_qmarks = arr_qmarks($article_ids);
$sth = $this->pdo->prepare("SELECT id,guid,title,link,content,feed_id,comments,int_id,
marked,unread,published,score,note,lang,
".SUBSTRING_FOR_DATE."(updated,1,16) as updated,
author,(SELECT title FROM ttrss_feeds WHERE id = feed_id) AS feed_title,
(SELECT site_url FROM ttrss_feeds WHERE id = feed_id) AS site_url,
(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images
FROM ttrss_entries,ttrss_user_entries
WHERE id IN ($article_qmarks) AND ref_id = id AND owner_uid = ?");
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
$articles = array();
while ($line = $sth->fetch()) {
$article = array(
"id" => $line["id"],
"guid" => $line["guid"],
"title" => $line["title"],
"link" => $line["link"],
"labels" => Article::_get_labels($line['id']),
"unread" => self::_param_to_bool($line["unread"]),
"marked" => self::_param_to_bool($line["marked"]),
"published" => self::_param_to_bool($line["published"]),
"comments" => $line["comments"],
"author" => $line["author"],
"updated" => (int) strtotime($line["updated"]),
"feed_id" => $line["feed_id"],
"attachments" => Article::_get_enclosures($line['id']),
"score" => (int)$line["score"],
"feed_title" => $line["feed_title"],
"note" => $line["note"],
"lang" => $line["lang"]
);
$article_ids = 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)',
'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)',
])
->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue')
->where_in('e.id', array_map('intval', $article_ids))
->where('ue.owner_uid', $_SESSION['uid'])
->find_many();
$articles = [];
foreach ($entries as $entry) {
$article = [
'id' => $entry->id,
'guid' => $entry->guid,
'title' => $entry->title,
'link' => $entry->link,
'labels' => Article::_get_labels($entry->id),
'unread' => self::_param_to_bool($entry->unread),
'marked' => self::_param_to_bool($entry->marked),
'published' => self::_param_to_bool($entry->published),
'comments' => $entry->comments,
'author' => $entry->author,
'updated' => (int) strtotime($entry->updated),
'feed_id' => $entry->feed_id,
'attachments' => Article::_get_enclosures($entry->id),
'score' => (int) $entry->score,
'feed_title' => $entry->feed_title,
'note' => $entry->note,
'lang' => $entry->lang,
];
if ($sanitize_content) {
$article["content"] = Sanitizer::sanitize(
$line["content"],
self::_param_to_bool($line['hide_images']),
false, $line["site_url"], false, $line["id"]);
$article['content'] = Sanitizer::sanitize(
$entry->content,
self::_param_to_bool($entry->hide_images),
false, $entry->site_url, false, $entry->id);
} else {
$article["content"] = $line["content"];
$article['content'] = $entry->content;
}
$hook_object = ["article" => &$article];
$hook_object = ['article' => &$article];
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
function ($result) use (&$article) {
@ -360,29 +367,32 @@ class API extends Handler {
$article['content'] = DiskCache::rewrite_urls($article['content']);
array_push($articles, $article);
}
$this->_wrap(self::STATUS_OK, $articles);
} else {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
$this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]);
}
}
function getConfig() {
private function _get_config() {
$config = [
"icons_dir" => Config::get(Config::ICONS_DIR),
"icons_url" => Config::get(Config::ICONS_URL)
];
$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();
$sth = $this->pdo->prepare("SELECT COUNT(*) AS cf FROM
ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $config;
}
$config["num_feeds"] = $row["cf"];
function getConfig() {
$config = $this->_get_config();
$this->_wrap(self::STATUS_OK, $config);
}
@ -417,36 +427,36 @@ class API extends Handler {
}
function getLabels() {
$article_id = (int)clean($_REQUEST['article_id']);
$article_id = (int)clean($_REQUEST['article_id'] ?? -1);
$rv = array();
$rv = [];
$sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color
FROM ttrss_labels2
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$_SESSION['uid']]);
$labels = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('caption')
->find_many();
if ($article_id)
$article_labels = Article::_get_labels($article_id);
else
$article_labels = array();
while ($line = $sth->fetch()) {
$article_labels = [];
foreach ($labels as $label) {
$checked = false;
foreach ($article_labels as $al) {
if (Labels::feed_to_label_id($al[0]) == $line['id']) {
if (Labels::feed_to_label_id($al[0]) == $label->id) {
$checked = true;
break;
}
}
array_push($rv, array(
"id" => (int)Labels::label_to_feed_id($line['id']),
"caption" => $line['caption'],
"fg_color" => $line['fg_color'],
"bg_color" => $line['bg_color'],
"checked" => $checked));
array_push($rv, [
'id' => (int) Labels::label_to_feed_id($label->id),
'caption' => $label->caption,
'fg_color' => $label->fg_color,
'bg_color' => $label->bg_color,
'checked' => $checked,
]);
}
$this->_wrap(self::STATUS_OK, $rv);
@ -507,10 +517,7 @@ class API extends Handler {
}
private static function _api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
$feeds = array();
$pdo = Db::pdo();
$feeds = [];
$limit = (int) $limit;
$offset = (int) $offset;
@ -523,17 +530,15 @@ class API extends Handler {
$counters = Counters::get_labels();
foreach (array_values($counters) as $cv) {
$unread = $cv["counter"];
$unread = $cv['counter'];
if ($unread || !$unread_only) {
$row = array(
"id" => (int) $cv["id"],
"title" => $cv["description"],
"unread" => $cv["counter"],
"cat_id" => -2,
);
$row = [
'id' => (int) $cv['id'],
'title' => $cv['description'],
'unread' => $cv['counter'],
'cat_id' => -2,
];
array_push($feeds, $row);
}
@ -543,45 +548,45 @@ class API extends Handler {
/* Virtual feeds */
if ($cat_id == -4 || $cat_id == -1) {
foreach (array(-1, -2, -3, -4, -6, 0) as $i) {
foreach ([-1, -2, -3, -4, -6, 0] as $i) {
$unread = getFeedUnread($i);
if ($unread || !$unread_only) {
$title = Feeds::_get_title($i);
$row = array(
"id" => $i,
"title" => $title,
"unread" => $unread,
"cat_id" => -1,
);
$row = [
'id' => $i,
'title' => $title,
'unread' => $unread,
'cat_id' => -1,
];
array_push($feeds, $row);
}
}
}
/* Child cats */
if ($include_nested && $cat_id) {
$sth = $pdo->prepare("SELECT
id, title, order_id FROM ttrss_feed_categories
WHERE parent_cat = ? AND owner_uid = ? ORDER BY order_id, title");
$sth->execute([$cat_id, $_SESSION['uid']]);
$categories = ORM::for_table('ttrss_feed_categories')
->where(['parent_cat' => $cat_id, 'owner_uid' => $_SESSION['uid']])
->order_by_asc('order_id')
->order_by_asc('title')
->find_many();
while ($line = $sth->fetch()) {
$unread = getFeedUnread($line["id"], true) +
Feeds::_get_cat_children_unread($line["id"]);
foreach ($categories as $category) {
$unread = getFeedUnread($category->id, true) +
Feeds::_get_cat_children_unread($category->id);
if ($unread || !$unread_only) {
$row = array(
"id" => (int) $line["id"],
"title" => $line["title"],
"unread" => $unread,
"is_cat" => true,
"order_id" => (int) $line["order_id"]
);
$row = [
'id' => (int) $category->id,
'title' => $category->title,
'unread' => $unread,
'is_cat' => true,
'order_id' => (int) $category->order_id,
];
array_push($feeds, $row);
}
}
@ -589,51 +594,36 @@ class API extends Handler {
/* Real feeds */
if ($limit) {
$limit_qpart = "LIMIT $limit OFFSET $offset";
} else {
$limit_qpart = "";
}
/* API only: -3 All feeds, excluding virtual feeds (e.g. Labels and such) */
if ($cat_id == -4 || $cat_id == -3) {
$sth = $pdo->prepare("SELECT
id, feed_url, cat_id, title, order_id, ".
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE owner_uid = ?
ORDER BY order_id, title " . $limit_qpart);
$sth->execute([$_SESSION['uid']]);
$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')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('order_id')
->order_by_asc('title');
} else {
if ($limit) $feeds_obj->limit($limit);
if ($offset) $feeds_obj->offset($offset);
$sth = $pdo->prepare("SELECT
id, feed_url, cat_id, title, order_id, ".
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))
AND owner_uid = :uid
ORDER BY order_id, title " . $limit_qpart);
$sth->execute([":uid" => $_SESSION['uid'], ":cat" => $cat_id]);
if ($cat_id != -3 && $cat_id != -4) {
$feeds_obj->where_raw('(cat_id = ? OR (? = 0 AND cat_id IS NULL))', [$cat_id, $cat_id]);
}
while ($line = $sth->fetch()) {
$unread = getFeedUnread($line["id"]);
$has_icon = Feeds::_has_icon($line['id']);
foreach ($feeds_obj->find_many() as $feed) {
$unread = getFeedUnread($feed->id);
$has_icon = Feeds::_has_icon($feed->id);
if ($unread || !$unread_only) {
$row = array(
"feed_url" => $line["feed_url"],
"title" => $line["title"],
"id" => (int)$line["id"],
"unread" => (int)$unread,
"has_icon" => $has_icon,
"cat_id" => (int)$line["cat_id"],
"last_updated" => (int) strtotime($line["last_updated"]),
"order_id" => (int) $line["order_id"],
);
$row = [
'feed_url' => $feed->feed_url,
'title' => $feed->title,
'id' => (int) $feed->id,
'unread' => (int) $unread,
'has_icon' => $has_icon,
'cat_id' => (int) $feed->cat_id,
'last_updated' => (int) strtotime($feed->last_updated),
'order_id' => (int) $feed->order_id,
];
array_push($feeds, $row);
}
@ -648,26 +638,24 @@ class API extends Handler {
$search = "", $include_nested = false, $sanitize_content = true,
$force_update = false, $excerpt_length = 100, $check_first_id = false, $skip_first_id_check = false) {
$pdo = Db::pdo();
if ($force_update && $feed_id > 0 && is_numeric($feed_id)) {
// Update the feed if required with some basic flood control
$sth = $pdo->prepare(
"SELECT cache_images,".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE id = ?");
$sth->execute([$feed_id]);
$feed = ORM::for_table('ttrss_feeds')
->select_many('id', 'cache_images')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->find_one($feed_id);
if ($row = $sth->fetch()) {
$last_updated = strtotime($row["last_updated"]);
$cache_images = self::_param_to_bool($row["cache_images"]);
if ($feed) {
$last_updated = strtotime($feed->last_updated);
$cache_images = self::_param_to_bool($feed->cache_images);
if (!$cache_images && time() - $last_updated > 120) {
RSSUtils::update_rss_feed($feed_id, true);
} else {
$sth = $pdo->prepare("UPDATE ttrss_feeds SET last_updated = '1970-01-01', last_update_started = '1970-01-01'
WHERE id = ?");
$sth->execute([$feed_id]);
$feed->last_updated = '1970-01-01';
$feed->last_update_started = '1970-01-01';
$feed->save();
}
}
}
@ -787,7 +775,8 @@ class API extends Handler {
list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures,
$line["content"], // unsanitized
$line["site_url"]);
$line["site_url"] ?? "", // could be null if archived article
$headline_row);
$headline_row["flavor_image"] = $flavor_image;
$headline_row["flavor_stream"] = $flavor_stream;
@ -817,15 +806,15 @@ class API extends Handler {
function unsubscribeFeed() {
$feed_id = (int) clean($_REQUEST["feed_id"]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
$feed_exists = ORM::for_table('ttrss_feeds')
->where(['id' => $feed_id, 'owner_uid' => $_SESSION['uid']])
->count();
if ($row = $sth->fetch()) {
Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]);
$this->_wrap(self::STATUS_OK, array("status" => "OK"));
if ($feed_exists) {
Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']);
$this->_wrap(self::STATUS_OK, ['status' => 'OK']);
} else {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
$this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]);
}
}
@ -858,27 +847,33 @@ class API extends Handler {
// only works for labels or uncategorized for the time being
private function _is_cat_empty($id) {
if ($id == -2) {
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_labels2
WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $row["count"] == 0;
$label_count = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->count();
return $label_count == 0;
} else if ($id == 0) {
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_feeds
WHERE cat_id IS NULL AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $row["count"] == 0;
$uncategorized_count = ORM::for_table('ttrss_feeds')
->where_null('cat_id')
->where('owner_uid', $_SESSION['uid'])
->count();
return $uncategorized_count == 0;
}
return false;
}
private function _get_custom_sort_types() {
$ret = [];
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) {
foreach ($result as $sort_value => $sort_title) {
$ret[$sort_value] = $sort_title;
}
});
return $ret;
}
}

@ -5,26 +5,23 @@ class Article extends Handler_Protected {
const ARTICLE_KIND_YOUTUBE = 3;
function redirect() {
$id = (int) clean($_REQUEST['id'] ?? 0);
$article = ORM::for_table('ttrss_entries')
->table_alias('e')
->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue')
->where('ue.owner_uid', $_SESSION['uid'])
->find_one((int)$_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT link FROM ttrss_entries, ttrss_user_entries
WHERE id = ? AND id = ref_id AND owner_uid = ?
LIMIT 1");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$article_url = UrlHelper::validate(str_replace("\n", "", $row['link']));
if ($article) {
$article_url = UrlHelper::validate($article->link);
if ($article_url) {
header("Location: $article_url");
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
print "URL of article $id is blank.";
return;
}
} else {
print_error(__("Article not found."));
}
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
print "Article not found or has an empty URL.";
}
static function _create_published_article($title, $url, $content, $labels_str,
@ -182,19 +179,6 @@ class Article extends Handler_Protected {
print json_encode(["id" => $ids, "score" => $score]);
}
function getScore() {
$id = clean($_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT score FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
$row = $sth->fetch();
$score = $row['score'];
print json_encode(["id" => $id, "score" => (int)$score]);
}
function setArticleTags() {
$id = clean($_REQUEST["id"]);
@ -349,7 +333,7 @@ class Article extends Handler_Protected {
$rv['can_inline'] = isset($_SESSION["uid"]) &&
empty($_SESSION["bw_limit"]) &&
!get_pref("STRIP_IMAGES") &&
!get_pref(Prefs::STRIP_IMAGES) &&
($always_display_enclosures || !preg_match("/<img/i", $article_content));
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
@ -433,42 +417,39 @@ class Article extends Handler_Protected {
}
function getmetadatabyid() {
$id = clean($_REQUEST['id']);
$article = ORM::for_table('ttrss_entries')
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where('ue.owner_uid', $_SESSION['uid'])
->find_one((int)$_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT link, title FROM ttrss_entries, ttrss_user_entries
WHERE ref_id = ? AND ref_id = id AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$link = $row['link'];
$title = $row['title'];
echo json_encode(["link" => $link, "title" => $title]);
if ($article) {
echo json_encode(["link" => $article->link, "title" => $article->title]);
} else {
echo json_encode([]);
}
}
static function _get_enclosures($id) {
$encs = ORM::for_table('ttrss_enclosures')
->where('post_id', $id)
->find_many();
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT * FROM ttrss_enclosures
WHERE post_id = ? AND content_url != ''");
$sth->execute([$id]);
$rv = array();
$rv = [];
$cache = new DiskCache("images");
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
foreach ($encs as $enc) {
$cache_key = sha1($enc->content_url);
if ($cache->exists(sha1($line["content_url"]))) {
$line["content_url"] = $cache->get_url(sha1($line["content_url"]));
if ($cache->exists($cache_key)) {
$enc->content_url = $cache->get_url($cache_key);
}
array_push($rv, $line);
array_push($rv, $enc->as_array());
}
return $rv;
}
static function _purge_orphans() {
@ -562,17 +543,20 @@ class Article extends Handler_Protected {
return $rv;
}
static function _get_image($enclosures, $content, $site_url) {
static function _get_image(array $enclosures, string $content, string $site_url, array $headline) {
$article_image = "";
$article_stream = "";
$article_kind = 0;
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE,
function ($result) use (&$article_image, &$article_stream, &$content) {
function ($result, $plugin) use (&$article_image, &$article_stream, &$content) {
list ($article_image, $article_stream, $content) = $result;
// run until first hard match
return !empty($article_image);
},
$enclosures, $content, $site_url);
$enclosures, $content, $site_url, $headline);
if (!$article_image && !$article_stream) {
$tmpdoc = new DOMDocument();
@ -645,17 +629,16 @@ class Article extends Handler_Protected {
if (count($article_ids) == 0)
return [];
$id_qmarks = arr_qmarks($article_ids);
$sth = Db::pdo()->prepare("SELECT DISTINCT label_cache FROM ttrss_entries e, ttrss_user_entries ue
WHERE ue.ref_id = e.id AND id IN ($id_qmarks)");
$sth->execute($article_ids);
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where_in('id', $article_ids)
->find_many();
$rv = [];
while ($row = $sth->fetch()) {
$labels = json_decode($row["label_cache"]);
foreach ($entries as $entry) {
$labels = json_decode($entry->label_cache);
if (isset($labels) && is_array($labels)) {
foreach ($labels as $label) {
@ -672,19 +655,18 @@ class Article extends Handler_Protected {
if (count($article_ids) == 0)
return [];
$id_qmarks = arr_qmarks($article_ids);
$sth = Db::pdo()->prepare("SELECT DISTINCT feed_id FROM ttrss_entries e, ttrss_user_entries ue
WHERE ue.ref_id = e.id AND id IN ($id_qmarks)");
$sth->execute($article_ids);
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where_in('id', $article_ids)
->find_many();
$rv = [];
while ($row = $sth->fetch()) {
array_push($rv, $row["feed_id"]);
foreach ($entries as $entry) {
array_push($rv, $entry->feed_id);
}
return $rv;
return array_unique($rv);
}
}

@ -23,13 +23,14 @@ abstract class Auth_Base extends Plugin implements IAuthModule {
if (!$password) $password = make_password();
$salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$pwd_hash = encrypt_password($password, $salt, true);
$sth = $this->pdo->prepare("INSERT INTO ttrss_users
(login,access_level,last_login,created,pwd_hash,salt)
VALUES (LOWER(?), 0, null, NOW(), ?,?)");
$sth->execute([$login, $pwd_hash, $salt]);
$user = ORM::for_table('ttrss_users')->create();
$user->salt = UserHelper::get_salt();
$user->login = mb_strtolower($login);
$user->pwd_hash = UserHelper::hash_password($password, $user->salt);
$user->access_level = 0;
$user->created = Db::NOW();
$user->save();
return UserHelper::find_user_by_login($login);

@ -6,7 +6,22 @@ class Config {
const T_STRING = 2;
const T_INT = 3;
// override defaults, defined below in _DEFAULTS[], via environment: DB_TYPE becomes TTRSS_DB_TYPE, etc
const SCHEMA_VERSION = 145;
/* override defaults, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
DB_TYPE becomes:
.env:
TTRSS_DB_TYPE=pgsql
or config.php:
putenv('TTRSS_DB_TYPE=pgsql');
etc, etc.
*/
const DB_TYPE = "DB_TYPE";
const DB_HOST = "DB_HOST";
@ -14,46 +29,151 @@ class Config {
const DB_NAME = "DB_NAME";
const DB_PASS = "DB_PASS";
const DB_PORT = "DB_PORT";
// database credentials
const MYSQL_CHARSET = "MYSQL_CHARSET";
// 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 SELF_URL_PATH = "SELF_URL_PATH";
// this should be set to a fully qualified URL used to access
// your tt-rss instance over the net, such as: https://example.com/tt-rss/
// if your tt-rss instance is behind a reverse proxy, use external URL.
// tt-rss will likely help you pick correct value for this on startup
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
// operate in single user mode, disables all functionality related to
// multiple users and authentication. enabling this assumes you have
// your tt-rss directory protected by other means (e.g. http auth).
const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_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 PHP_EXECUTABLE = "PHP_EXECUTABLE";
// use this PHP CLI executable to start various tasks
const LOCK_DIRECTORY = "LOCK_DIRECTORY";
// base directory for lockfiles (must be writable)
const CACHE_DIR = "CACHE_DIR";
// base directory for local cache (must be writable)
const ICONS_DIR = "ICONS_DIR";
const ICONS_URL = "ICONS_URL";
// directory and URL for feed favicons (directory must be writable)
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
// auto create users authenticated via external modules
const AUTH_AUTO_LOGIN = "AUTH_AUTO_LOGIN";
// auto log in users authenticated via external modules i.e. auth_remote
const FORCE_ARTICLE_PURGE = "FORCE_ARTICLE_PURGE";
// unconditinally purge all articles older than this amount, in days
// overrides user-controlled purge interval
const SESSION_COOKIE_LIFETIME = "SESSION_COOKIE_LIFETIME";
// default lifetime of a session (e.g. login) cookie. In seconds,
// 0 means cookie will be deleted when browser closes.
const SMTP_FROM_NAME = "SMTP_FROM_NAME";
const SMTP_FROM_ADDRESS = "SMTP_FROM_ADDRESS";
// send email using this name and address
const DIGEST_SUBJECT = "DIGEST_SUBJECT";
// default subject for email digest
const CHECK_FOR_UPDATES = "CHECK_FOR_UPDATES";
// enable built-in update checker, both for core code and plugins (using git)
const PLUGINS = "PLUGINS";
// system plugins enabled for all users, comma separated list, no quotes
// keep at least one auth module in there (i.e. auth_internal)
const LOG_DESTINATION = "LOG_DESTINATION";
// available options: sql (default, event log), syslog, stdout (for debugging)
const LOCAL_OVERRIDE_STYLESHEET = "LOCAL_OVERRIDE_STYLESHEET";
// link this stylesheet on all pages (if it exists), should be placed in themes.local
const LOCAL_OVERRIDE_JS = "LOCAL_OVERRIDE_JS";
// same but this javascript file (you can use that for polyfills), should be placed in themes.local
const DAEMON_MAX_CHILD_RUNTIME = "DAEMON_MAX_CHILD_RUNTIME";
// in seconds, terminate update tasks that ran longer than this interval
const DAEMON_MAX_JOBS = "DAEMON_MAX_JOBS";
// max concurrent update jobs forking update daemon starts
const FEED_FETCH_TIMEOUT = "FEED_FETCH_TIMEOUT";
// How long to wait for response when requesting feed from a site (seconds)
const FEED_FETCH_NO_CACHE_TIMEOUT = "FEED_FETCH_NO_CACHE_TIMEOUT";
// Same but not cached
const FILE_FETCH_TIMEOUT = "FILE_FETCH_TIMEOUT";
// Default timeout when fetching files from remote sites
const FILE_FETCH_CONNECT_TIMEOUT = "FILE_FETCH_CONNECT_TIMEOUT";
// How long to wait for initial response from website when fetching files from remote sites
const DAEMON_UPDATE_LOGIN_LIMIT = "DAEMON_UPDATE_LOGIN_LIMIT";
// stop updating feeds if user haven't logged in for X days
const DAEMON_FEED_LIMIT = "DAEMON_FEED_LIMIT";
// how many feeds to update in one batch
const DAEMON_SLEEP_INTERVAL = "DAEMON_SLEEP_INTERVAL";
// default sleep interval between feed updates (sec)
const MAX_CACHE_FILE_SIZE = "MAX_CACHE_FILE_SIZE";
// do not cache files larger than that (bytes)
const MAX_DOWNLOAD_FILE_SIZE = "MAX_DOWNLOAD_FILE_SIZE";
// do not download files larger than that (bytes)
const MAX_FAVICON_FILE_SIZE = "MAX_FAVICON_FILE_SIZE";
// max file size for downloaded favicons (bytes)
const CACHE_MAX_DAYS = "CACHE_MAX_DAYS";
// max age in days for various automatically cached (temporary) files
const MAX_CONDITIONAL_INTERVAL = "MAX_CONDITIONAL_INTERVAL";
// max interval between forced unconditional updates for servers not complying with http if-modified-since (seconds)
const DAEMON_UNSUCCESSFUL_DAYS_LIMIT = "DAEMON_UNSUCCESSFUL_DAYS_LIMIT";
// automatically disable updates for feeds which failed to
// update for this amount of days; 0 disables
const LOG_SENT_MAIL = "LOG_SENT_MAIL";
// log all sent emails in the event log
const HTTP_PROXY = "HTTP_PROXY";
// use HTTP proxy for requests
const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES";
// prevent users from changing passwords
const SESSION_NAME = "SESSION_NAME";
// default session cookie name
const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES";
// enable plugin update checker (using git)
const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER";
// allow installing first party plugins using plugin installer in prefs
const AUTH_MIN_INTERVAL = "AUTH_MIN_INTERVAL";
// minimum amount of seconds required between authentication attempts
const HTTP_USER_AGENT = "HTTP_USER_AGENT";
// http user agent (changing this is not recommended)
// default values for all of the above:
private const _DEFAULTS = [
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
Config::DB_HOST => [ "db", Config::T_STRING ],
@ -80,10 +200,12 @@ class Config {
Config::T_STRING ],
Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ],
Config::PLUGINS => [ "auth_internal", Config::T_STRING ],
Config::LOG_DESTINATION => [ "sql", Config::T_STRING ],
Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ],
Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css",
Config::T_STRING ],
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_STRING ],
Config::LOCAL_OVERRIDE_JS => [ "local-overrides.js",
Config::T_STRING ],
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ],
Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ],
Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ],
Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ],
@ -102,19 +224,33 @@ class Config {
Config::HTTP_PROXY => [ "", Config::T_STRING ],
Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ],
Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ],
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::T_STRING ],
];
private static $instance;
private $params = [];
private $schema_version = null;
private $version = [];
public static function get_instance() {
/** @var Db_Migrations $migrations */
private $migrations;
public static function get_instance() : Config {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
private function __clone() {
//
}
function __construct() {
$ref = new ReflectionClass(get_class($this));
@ -124,12 +260,112 @@ class Config {
list ($defval, $deftype) = $this::_DEFAULTS[$const];
$this->params[$cvalue] = [ $this->cast_to(!empty($override) ? $override : $defval, $deftype), $deftype ];
$this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ];
}
}
}
private function cast_to(string $value, int $type_hint) {
/* package maintainers who don't use git: if version_static.txt exists in tt-rss root
directory, its contents are displayed instead of git commit-based version, this could be generated
based on source git tree commit used when creating the package */
static function get_version(bool $as_string = true) {
return self::get_instance()->_get_version($as_string);
}
private function _get_version(bool $as_string = true) {
$root_dir = dirname(__DIR__);
if (empty($this->version)) {
$this->version["status"] = -1;
if (PHP_OS === "Darwin") {
$ttrss_version["version"] = "UNKNOWN (Unsupported, Darwin)";
} else if (file_exists("$root_dir/version_static.txt")) {
$this->version["version"] = trim(file_get_contents("$root_dir/version_static.txt")) . " (Unsupported)";
} else if (is_dir("$root_dir/.git")) {
$this->version = self::get_version_from_git($root_dir);
if ($this->version["status"] != 0) {
user_error("Unable to determine version: " . $this->version["version"], E_USER_WARNING);
$this->version["version"] = "UNKNOWN (Unsupported, Git error)";
}
} else {
$this->version["version"] = "UNKNOWN (Unsupported)";
}
}
return $as_string ? $this->version["version"] : $this->version;
}
static function get_version_from_git(string $dir) {
$descriptorspec = [
1 => ["pipe", "w"], // STDOUT
2 => ["pipe", "w"], // STDERR
];
$rv = [
"status" => -1,
"version" => "",
"commit" => "",
"timestamp" => 0,
];
$proc = proc_open("git --no-pager log --pretty=\"version-%ct-%h\" -n1 HEAD",
$descriptorspec, $pipes, $dir);
if (is_resource($proc)) {
$stdout = trim(stream_get_contents($pipes[1]));
$stderr = trim(stream_get_contents($pipes[2]));
$status = proc_close($proc);
$rv["status"] = $status;
list($check, $timestamp, $commit) = explode("-", $stdout);
if ($check == "version") {
$rv["version"] = strftime("%y.%m", (int)$timestamp) . "-$commit";
$rv["commit"] = $commit;
$rv["timestamp"] = $timestamp;
// proc_close() may return -1 even if command completed successfully
// so if it looks like we got valid data, we ignore it
if ($rv["status"] == -1)
$rv["status"] = 0;
} else {
$rv["version"] = T_sprintf("Git error [RC=%d]: %s", $status, $stderr);
}
}
return $rv;
}
static function get_migrations() : Db_Migrations {
return self::get_instance()->_get_migrations();
}
private function _get_migrations() : Db_Migrations {
if (empty($this->migrations)) {
$this->migrations = new Db_Migrations();
$this->migrations->initialize(dirname(__DIR__) . "/sql", "ttrss_version", true, self::SCHEMA_VERSION);
}
return $this->migrations;
}
static function is_migration_needed() : bool {
return self::get_migrations()->is_migration_needed();
}
static function get_schema_version() : int {
return self::get_migrations()->get_version();
}
static function cast_to(string $value, int $type_hint) {
switch ($type_hint) {
case self::T_BOOL:
return sql_bool_to_bool($value);
@ -149,7 +385,7 @@ class Config {
private function _add(string $param, string $default, int $type_hint) {
$override = getenv($this::_ENVVAR_PREFIX . $param);
$this->params[$param] = [ $this->cast_to(!empty($override) ? $override : $default, $type_hint), $type_hint ];
$this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ];
}
static function add(string $param, string $default, int $type_hint = Config::T_STRING) {
@ -164,4 +400,245 @@ class Config {
return $instance->_get($param);
}
/** this returns Config::SELF_URL_PATH sans trailing slash */
static function get_self_url() : string {
$self_url_path = self::get(Config::SELF_URL_PATH);
if (substr($self_url_path, -1) === "/") {
return substr($self_url_path, 0, -1);
} else {
return $self_url_path;
}
}
static function is_server_https() : bool {
return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) ||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https');
}
/** generates reference self_url_path (no trailing slash) */
static function make_self_url() : string {
$proto = self::is_server_https() ? 'https' : 'http';
$self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"];
$self_url_path = preg_replace("/\w+\.php(\?.*$)?$/", "", $self_url_path);
if (substr($self_url_path, -1) === "/") {
return substr($self_url_path, 0, -1);
} else {
return $self_url_path;
}
}
/* sanity check stuff */
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() {
/*
we don't actually need the DB object right now but some checks below might use ORM which won't be initialized
because it is set up in the Db constructor, which is why it's a good idea to invoke it as early as possible
it is a bit of a hack, maybe ORM should be initialized somewhere else (functions.php?)
*/
$pdo = Db::pdo();
$errors = [];
if (strpos(self::get(Config::PLUGINS), "auth_") === false) {
array_push($errors, "Please enable at least one authentication module via PLUGINS");
}
if (function_exists('posix_getuid') && posix_getuid() == 0) {
array_push($errors, "Please don't run this script as root.");
}
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
array_push($errors, "PHP version 7.1.0 or newer required. You're using " . PHP_VERSION . ".");
}
if (!class_exists("UConverter")) {
array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module.");
}
if (!is_writable(self::get(Config::CACHE_DIR) . "/images")) {
array_push($errors, "Image cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/images)");
}
if (!is_writable(self::get(Config::CACHE_DIR) . "/upload")) {
array_push($errors, "Upload cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/upload)");
}
if (!is_writable(self::get(Config::CACHE_DIR) . "/export")) {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)");
}
// ttrss_users won't be there on initial startup (before migrations are done)
if (!Config::is_migration_needed() && self::get(Config::SINGLE_USER_MODE)) {
if (UserHelper::get_login_by_id(1) != "admin") {
array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found.");
}
}
if (php_sapi_name() != "cli") {
if (self::get_schema_version() < 0) {
array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (<code>update.php --update-schema</code>)");
}
$ref_self_url_path = self::make_self_url();
if ($ref_self_url_path) {
$ref_self_url_path = preg_replace("/\w+\.php$/", "", $ref_self_url_path);
}
if (self::get_self_url() == "http://example.org/tt-rss") {
$hint = $ref_self_url_path ? "(possible value: <b>$ref_self_url_path</b>)" : "";
array_push($errors,
"Please set SELF_URL_PATH to the correct value for your server: $hint");
}
if (self::get_self_url() != $ref_self_url_path) {
array_push($errors,
"Please set SELF_URL_PATH to the correct value detected for your server: <b>$ref_self_url_path</b> (you're using: <b>" . self::get_self_url() . "</b>)");
}
}
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");
}
if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) {
array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL.");
}
if (!function_exists("json_encode")) {
array_push($errors, "PHP support for JSON is required, but was not found.");
}
if (!class_exists("PDO")) {
array_push($errors, "PHP support for PDO is required but was not found.");
}
if (!function_exists("mb_strlen")) {
array_push($errors, "PHP support for mbstring functions is required but was not found.");
}
if (!function_exists("hash")) {
array_push($errors, "PHP support for hash() function is required but was not found.");
}
if (ini_get("safe_mode")) {
array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss.");
}
if (!function_exists("mime_content_type")) {
array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module.");
}
if (!class_exists("DOMDocument")) {
array_push($errors, "PHP support for DOMDocument is required, but was not found.");
}
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") { ?>
<!DOCTYPE html>
<html>
<head>
<title>Startup failed</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link rel="stylesheet" type="text/css" href="themes/light.css">
</head>
<body class="sanity_failed flat ttrss_utility">
<div class="content">
<h1>Startup failed</h1>
<p>Please fix errors indicated by the following messages:</p>
<?php foreach ($errors as $error) { echo self::format_error($error); } ?>
<p>You might want to check tt-rss <a target="_blank" href="https://tt-rss.org/wiki.php">wiki</a> or the
<a target="_blank" href="https://community.tt-rss.org/">forums</a> for more information. Please search the forums before creating new topic
for your question.</p>
</div>
</body>
</html>
<?php
die;
} else if (count($errors) > 0) {
echo "Please fix errors indicated by the following messages:\n\n";
foreach ($errors as $error) {
echo " * " . strip_tags($error)."\n";
}
echo "\nYou might want to check tt-rss wiki or the forums for more information.\n";
echo "Please search the forums before creating new topic for your question.\n";
exit(1);
}
}
private static function format_error($msg) {
return "<div class=\"alert alert-danger\">$msg</div>";
}
static function get_override_links() {
$rv = "";
$local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET));
if ($local_css) $rv .= stylesheet_tag($local_css);
$local_js = get_theme_path(self::get(self::LOCAL_OVERRIDE_JS));
if ($local_js) $rv .= javascript_tag($local_js);
return $rv;
}
static function get_user_agent() {
return sprintf(self::get(self::HTTP_USER_AGENT), self::get_version());
}
}

@ -21,21 +21,20 @@ class Counters {
);
}
static private function get_cat_children($cat_id, $owner_uid) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE parent_cat = ?
AND owner_uid = ?");
$sth->execute([$cat_id, $owner_uid]);
static private function get_cat_children(int $cat_id, int $owner_uid) {
$unread = 0;
$marked = 0;
while ($line = $sth->fetch()) {
list ($tmp_unread, $tmp_marked) = self::get_cat_children($line["id"], $owner_uid);
$cats = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid)
->where('parent_cat', $cat_id)
->find_many();
foreach ($cats as $cat) {
list ($tmp_unread, $tmp_marked) = self::get_cat_children($cat->id, $owner_uid);
$unread += $tmp_unread + Feeds::_get_cat_unread($line["id"], $owner_uid);
$marked += $tmp_marked + Feeds::_get_cat_marked($line["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);
}
return [$unread, $marked];
@ -178,7 +177,7 @@ class Counters {
$has_img = false;
}
// hide default un-updated timestamp i.e. 1980-01-01 (?) -fox
// 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 = '';
@ -200,35 +199,22 @@ class Counters {
return $ret;
}
private static function get_global($global_unread = -1) {
$ret = [];
if ($global_unread == -1) {
$global_unread = Feeds::_get_global_unread();
}
$cv = [
private static function get_global() {
$ret = [
[
"id" => "global-unread",
"counter" => (int) $global_unread
"counter" => (int) Feeds::_get_global_unread()
]
];
array_push($ret, $cv);
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT COUNT(id) AS fn FROM
ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
$subscribed_feeds = $row["fn"];
$subcribed_feeds = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count();
$cv = [
array_push($ret, [
"id" => "subscribed-feeds",
"counter" => (int) $subscribed_feeds
];
array_push($ret, $cv);
"counter" => $subcribed_feeds
]);
return $ret;
}

@ -1,27 +1,50 @@
<?php
class Db
{
/* @var Db $instance */
/** @var Db $instance */
private static $instance;
private $link;
/* @var PDO $pdo */
/** @var PDO $pdo */
private $pdo;
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)));
}
}
static function NOW() {
return date("Y-m-d H:i:s", time());
}
private function __clone() {
//
}
// this really shouldn't be used unless a separate PDO connection is needed
// normal usage is Db::pdo()->prepare(...) etc
public function pdo_connect() {
public static function get_dsn() {
$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 = '';
}
return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port . $db_charset;
}
// this really shouldn't be used unless a separate PDO connection is needed
// normal usage is Db::pdo()->prepare(...) etc
public function pdo_connect() : PDO {
try {
$pdo = new PDO(Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port,
$pdo = new PDO(self::get_dsn(),
Config::get(Config::DB_USER),
Config::get(Config::DB_PASS));
} catch (Exception $e) {
@ -49,7 +72,7 @@ class Db
return $pdo;
}
public static function instance() {
public static function instance() : Db {
if (self::$instance == null)
self::$instance = new self();
@ -60,7 +83,7 @@ class Db
if (self::$instance == null)
self::$instance = new self();
if (!self::$instance->pdo) {
if (empty(self::$instance->pdo)) {
self::$instance->pdo = self::$instance->pdo_connect();
}

@ -0,0 +1,198 @@
<?php
class Db_Migrations {
private $base_filename = "schema.sql";
private $base_path;
private $migrations_path;
private $migrations_table;
private $base_is_latest;
private $pdo;
private $cached_version;
private $cached_max_version;
private $max_version_override;
function __construct() {
$this->pdo = Db::pdo();
}
function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql") {
$plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin);
$this->initialize($plugin_dir . "/${schema_suffix}",
strtolower("ttrss_migrations_plugin_" . get_class($plugin)),
$base_is_latest);
}
function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0) {
$this->base_path = "$root_path/" . Config::get(Config::DB_TYPE);
$this->migrations_path = $this->base_path . "/migrations";
$this->migrations_table = $migrations_table;
$this->base_is_latest = $base_is_latest;
$this->max_version_override = $max_version_override;
}
private function set_version(int $version) {
Debug::log("Updating table {$this->migrations_table} with version ${version}...", Debug::LOG_EXTENDED);
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
if ($res = $sth->fetch()) {
$sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?");
} else {
$sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)");
}
$sth->execute([$version]);
$this->cached_version = $version;
}
function get_version() : int {
if (isset($this->cached_version))
return $this->cached_version;
try {
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
if ($res = $sth->fetch()) {
return (int) $res['schema_version'];
} else {
return -1;
}
} catch (PDOException $e) {
$this->create_migrations_table();
return -1;
}
}
private function create_migrations_table() {
$this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)");
}
private function migrate_to(int $version) {
try {
if ($version <= $this->get_version()) {
Debug::log("Refusing to apply version $version: current version is higher", Debug::LOG_VERBOSE);
return false;
}
if ($version == 0)
Debug::log("Loading base database schema...", Debug::LOG_VERBOSE);
else
Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE);
$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();
foreach ($lines as $line) {
Debug::log($line, Debug::LOG_EXTENDED);
try {
$this->pdo->query($line);
} catch (PDOException $e) {
Debug::log("Failed on line: $line", Debug::LOG_VERBOSE);
throw $e;
}
}
if ($version == 0 && $this->base_is_latest)
$this->set_version($this->get_max_version());
else
$this->set_version($version);
if (Config::get(Config::DB_TYPE) != "mysql")
$this->pdo->commit();
Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE);
Logger::log(E_USER_NOTICE, "Applied migration to version $version for {$this->migrations_table}");
} else {
Debug::log("Migration failed: schema file is empty or missing.", Debug::LOG_VERBOSE);
}
} catch (PDOException $e) {
Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE);
try {
$this->pdo->rollback();
} catch (PDOException $ie) {
//
}
throw $e;
}
}
function get_max_version() : int {
if ($this->max_version_override > 0)
return $this->max_version_override;
if (isset($this->cached_max_version))
return $this->cached_max_version;
$migrations = glob("{$this->migrations_path}/*.sql");
if (count($migrations) > 0) {
natsort($migrations);
$this->cached_max_version = (int) basename(array_pop($migrations), ".sql");
} else {
$this->cached_max_version = 0;
}
return $this->cached_max_version;
}
function is_migration_needed() : bool {
return $this->get_version() != $this->get_max_version();
}
function migrate() : bool {
if ($this->get_version() == -1) {
try {
$this->migrate_to(0);
} catch (PDOException $e) {
user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
return false;
}
}
for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) {
try {
$this->migrate_to($i);
} catch (PDOException $e) {
user_error("Failed to apply migration ${i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
return false;
//throw $e;
}
}
return !$this->is_migration_needed();
}
private function get_lines(int $version) : array {
if ($version > 0)
$filename = "{$this->migrations_path}/${version}.sql";
else
$filename = "{$this->base_path}/{$this->base_filename}";
if (file_exists($filename)) {
$lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)),
function ($line) {
return strlen(trim($line)) > 0 && strpos($line, "--") !== 0;
});
return array_filter(explode(";", implode("", $lines)), function ($line) {
return strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]);
});
} else {
user_error("Requested schema file ${filename} not found.", E_USER_ERROR);
return [];
}
}
}

@ -1,173 +1,12 @@
<?php
class Db_Prefs {
private $pdo;
private static $instance;
private $cache;
function __construct() {
$this->pdo = Db::pdo();
$this->cache = [];
$this->cache_prefs();
}
private function __clone() {
//
}
public static function get() {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
private function cache_prefs() {
if (!empty($_SESSION["uid"])) {
$profile = $_SESSION["profile"] ?? false;
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
$sth = $this->pdo->prepare("SELECT up.pref_name, pt.type_name, up.value
FROM ttrss_user_prefs up
JOIN ttrss_prefs p ON (up.pref_name = p.pref_name)
JOIN ttrss_prefs_types pt ON (p.type_id = pt.id)
WHERE
up.pref_name NOT LIKE '_MOBILE%' AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
owner_uid = :uid");
$sth->execute([":profile" => $profile, ":uid" => $_SESSION["uid"]]);
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$pref_name = $row["pref_name"];
$this->cache[$pref_name] = [
"type" => $row["type_name"],
"value" => $row["value"]
];
}
}
}
// this class is a stub for the time being (to be removed)
function read($pref_name, $user_id = false, $die_on_error = false) {
if (!$user_id) {
$user_id = $_SESSION["uid"];
$profile = $_SESSION["profile"] ?? false;
} else {
$profile = false;
}
if ($user_id == ($_SESSION['uid'] ?? false) && isset($this->cache[$pref_name])) {
$tuple = $this->cache[$pref_name];
return $this->convert($tuple["value"], $tuple["type"]);
}
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
$sth = $this->pdo->prepare("SELECT up.pref_name, pt.type_name, up.value
FROM ttrss_user_prefs up
JOIN ttrss_prefs p ON (up.pref_name = p.pref_name)
JOIN ttrss_prefs_types pt ON (p.type_id = pt.id)
WHERE
up.pref_name = :pref_name AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
owner_uid = :uid");
$sth->execute([":uid" => $user_id, ":profile" => $profile, ":pref_name" => $pref_name]);
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$value = $row["value"];
$type_name = $row["type_name"];
if ($user_id == ($_SESSION["uid"] ?? false)) {
$this->cache[$pref_name] = [
"type" => $row["type_name"],
"value" => $row["value"]
];
}
return $this->convert($value, $type_name);
} else if ($die_on_error) {
user_error("Failed retrieving preference $pref_name for user $user_id", E_USER_ERROR);
} else {
user_error("Failed retrieving preference $pref_name for user $user_id", E_USER_WARNING);
}
return null;
}
function convert($value, $type_name) {
if ($type_name == "bool") {
return $value == "true";
} else if ($type_name == "integer") {
return (int)$value;
} else {
return $value;
}
return get_pref($pref_name, $user_id);
}
function write($pref_name, $value, $user_id = false, $strip_tags = true) {
if ($strip_tags) $value = strip_tags($value);
if (!$user_id) {
$user_id = $_SESSION["uid"];
@$profile = $_SESSION["profile"] ?? false;
} else {
$profile = null;
return set_pref($pref_name, $value, $user_id, $strip_tags);
}
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
$type_name = "";
$current_value = "";
if (isset($this->cache[$pref_name])) {
$type_name = $this->cache[$pref_name]["type"];
$current_value = $this->cache[$pref_name]["value"];
}
if (!$type_name) {
$sth = $this->pdo->prepare("SELECT type_name
FROM ttrss_prefs,ttrss_prefs_types
WHERE pref_name = ? AND type_id = ttrss_prefs_types.id");
$sth->execute([$pref_name]);
if ($row = $sth->fetch())
$type_name = $row["type_name"];
} else if ($current_value == $value) {
return;
}
if ($type_name) {
if ($type_name == "bool") {
if ($value == "1" || $value == "true") {
$value = "true";
} else {
$value = "false";
}
} else if ($type_name == "integer") {
$value = (int)$value;
}
if ($pref_name == 'USER_TIMEZONE' && $value == '') {
$value = 'UTC';
}
$sth = $this->pdo->prepare("UPDATE ttrss_user_prefs SET
value = :value WHERE pref_name = :pref_name
AND (profile = :profile OR (:profile IS NULL AND profile IS NULL))
AND owner_uid = :uid");
$sth->execute([":pref_name" => $pref_name, ":value" => $value, ":uid" => $user_id, ":profile" => $profile]);
if ($user_id == $_SESSION["uid"]) {
$this->cache[$pref_name]["type"] = $type_name;
$this->cache[$pref_name]["value"] = $value;
}
}
}
}

@ -1,83 +0,0 @@
<?php
class DbUpdater {
private $pdo;
private $db_type;
private $need_version;
function __construct($pdo, $db_type, $need_version) {
$this->pdo = $pdo;
$this->db_type = $db_type;
$this->need_version = (int) $need_version;
}
function get_schema_version() {
$row = $this->pdo->query("SELECT schema_version FROM ttrss_version")->fetch();
return (int) $row['schema_version'];
}
function is_update_required() {
return $this->get_schema_version() < $this->need_version;
}
function get_schema_lines($version) {
$filename = "schema/versions/".$this->db_type."/$version.sql";
if (file_exists($filename)) {
return explode(";", (string)preg_replace("/[\r\n]/", "", (string)file_get_contents($filename)));
} else {
user_error("DB Updater: schema file for version $version is not found.");
return false;
}
}
function update_to($version, $html_output = true) {
if ($this->get_schema_version() == $version - 1) {
$lines = $this->get_schema_lines($version);
if (is_array($lines)) {
$this->pdo->beginTransaction();
foreach ($lines as $line) {
if (strpos($line, "--") !== 0 && $line) {
if ($html_output)
print "<pre>$line</pre>";
else
Debug::log("> $line");
try {
$this->pdo->query($line); // PDO returns errors as exceptions now
} catch (PDOException $e) {
if ($html_output) {
print "<div class='text-error'>Error: " . $e->getMessage() . "</div>";
} else {
Debug::log("Error: " . $e->getMessage());
}
$this->pdo->rollBack();
return false;
}
}
}
$db_version = $this->get_schema_version();
if ($db_version == $version) {
$this->pdo->commit();
return true;
} else {
$this->pdo->rollBack();
return false;
}
} else {
return false;
}
} else {
return false;
}
}
}

@ -1,14 +1,26 @@
<?php
class Debug {
public static $LOG_DISABLED = -1;
public static $LOG_NORMAL = 0;
public static $LOG_VERBOSE = 1;
public static $LOG_EXTENDED = 2;
const LOG_DISABLED = -1;
const LOG_NORMAL = 0;
const LOG_VERBOSE = 1;
const LOG_EXTENDED = 2;
/** @deprecated */
public static $LOG_DISABLED = self::LOG_DISABLED;
/** @deprecated */
public static $LOG_NORMAL = self::LOG_NORMAL;
/** @deprecated */
public static $LOG_VERBOSE = self::LOG_VERBOSE;
/** @deprecated */
public static $LOG_EXTENDED = self::LOG_EXTENDED;
private static $enabled = false;
private static $quiet = false;
private static $logfile = false;
private static $loglevel = 0;
private static $loglevel = self::LOG_NORMAL;
public static function set_logfile($logfile) {
self::$logfile = $logfile;
@ -34,7 +46,7 @@ class Debug {
return self::$loglevel;
}
public static function log($message, $level = 0) {
public static function log($message, int $level = 0) {
if (!self::$enabled || self::$loglevel < $level) return false;

@ -21,8 +21,8 @@ class Digest
while ($line = $res->fetch()) {
if (@get_pref('DIGEST_ENABLE', $line['id'], false)) {
$preferred_ts = strtotime(get_pref('DIGEST_PREFERRED_TIME', $line['id'], '00:00'));
if (get_pref(Prefs::DIGEST_ENABLE, $line['id'])) {
$preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id']));
// try to send digests within 2 hours of preferred time
if ($preferred_ts && time() >= $preferred_ts &&
@ -31,7 +31,7 @@ class Digest
Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]);
$do_catchup = get_pref('DIGEST_CATCHUP', $line['id'], false);
$do_catchup = get_pref(Prefs::DIGEST_CATCHUP, $line['id']);
global $tz_offset;
@ -78,7 +78,7 @@ class Digest
Debug::log("All done.");
}
static function prepare_headlines_digest($user_id, $days = 1, $limit = 1000) {
static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) {
$tpl = new Templator();
$tpl_t = new Templator();
@ -86,21 +86,23 @@ class Digest
$tpl->readTemplateFromFile("digest_template_html.txt");
$tpl_t->readTemplateFromFile("digest_template.txt");
$user_tz_string = get_pref('USER_TIMEZONE', $user_id);
$user_tz_string = get_pref(Prefs::USER_TIMEZONE, $user_id);
if ($user_tz_string == 'Automatic')
$user_tz_string = 'GMT';
$local_ts = TimeHelper::convert_timestamp(time(), 'UTC', $user_tz_string);
$tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
$tpl->setVariable('CUR_TIME', date('G:i', $local_ts));
$tpl->setVariable('TTRSS_HOST', Config::get(Config::get(Config::SELF_URL_PATH)));
$tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
$tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts));
$tpl_t->setVariable('TTRSS_HOST', Config::get(Config::get(Config::SELF_URL_PATH)));
$tpl_t->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$affected_ids = array();
$days = (int) $days;
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") */ {
@ -117,7 +119,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
@ -130,10 +132,8 @@ class Digest
AND unread = true
AND score >= 0
ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC
LIMIT :limit");
$sth->bindParam(':user_id', intval($user_id, 10), PDO::PARAM_INT);
$sth->bindParam(':limit', intval($limit, 10), PDO::PARAM_INT);
$sth->execute();
LIMIT " . (int)$limit);
$sth->execute([':user_id' => $user_id]);
$headlines_count = 0;
$headlines = array();
@ -152,7 +152,7 @@ class Digest
$updated = TimeHelper::make_local_datetime($line['last_updated'], false,
$user_id);
if (get_pref('ENABLE_FEED_CATS', $user_id)) {
if (get_pref(Prefs::ENABLE_FEED_CATS, $user_id)) {
$line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title'];
}

@ -273,7 +273,7 @@ class DiskCache {
}
public function get_url($filename) {
return get_self_url_prefix() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename);
return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename);
}
// check for locally cached (media) URLs and rewrite to local versions

@ -5,8 +5,9 @@ class Errors {
const E_UNKNOWN_METHOD = "E_UNKNOWN_METHOD";
const E_UNKNOWN_PLUGIN = "E_UNKNOWN_PLUGIN";
const E_SCHEMA_MISMATCH = "E_SCHEMA_MISMATCH";
const E_URL_SCHEME_MISMATCH = "E_URL_SCHEME_MISMATCH";
static function to_json(string $code) {
return json_encode(["error" => ["code" => $code]]);
static function to_json(string $code, array $params = []) {
return json_encode(["error" => ["code" => $code, "params" => $params]]);
}
}

@ -9,7 +9,7 @@ abstract class FeedItem {
abstract function get_comments_url();
abstract function get_comments_count();
abstract function get_categories();
abstract function _get_enclosures();
abstract function get_enclosures();
abstract function get_author();
abstract function get_language();
}

@ -60,43 +60,76 @@ class FeedItem_Atom extends FeedItem_Common {
}
}
/** $base is optional (returns $content if $base is null), $content is an HTML string */
private function rewrite_content_to_base($base, $content) {
if (!empty($base) && !empty($content)) {
$tmpdoc = new DOMDocument();
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . $content)) {
$tmpxpath = new DOMXPath($tmpdoc);
$elems = $tmpxpath->query("(//*[@href]|//*[@src])");
foreach ($elems as $elem) {
if ($elem->hasAttribute("href")) {
$elem->setAttribute("href",
UrlHelper::rewrite_relative($base, $elem->getAttribute("href")));
} else if ($elem->hasAttribute("src")) {
$elem->setAttribute("src",
UrlHelper::rewrite_relative($base, $elem->getAttribute("src")));
}
}
return $tmpdoc->saveXML();
}
}
return $content;
}
function get_content() {
$content = $this->elem->getElementsByTagName("content")->item(0);
if ($content) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
if ($content->hasAttribute('type')) {
if ($content->getAttribute('type') == 'xhtml') {
for ($i = 0; $i < $content->childNodes->length; $i++) {
$child = $content->childNodes->item($i);
if ($child->hasChildNodes()) {
return $this->doc->saveHTML($child);
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
}
}
}
}
return $this->subtree_or_text($content);
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
}
}
// TODO: duplicate code should be merged with get_content()
function get_description() {
$content = $this->elem->getElementsByTagName("summary")->item(0);
if ($content) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
if ($content->hasAttribute('type')) {
if ($content->getAttribute('type') == 'xhtml') {
for ($i = 0; $i < $content->childNodes->length; $i++) {
$child = $content->childNodes->item($i);
if ($child->hasChildNodes()) {
return $this->doc->saveHTML($child);
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
}
}
}
}
return $this->subtree_or_text($content);
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
}
}
@ -119,26 +152,32 @@ class FeedItem_Atom extends FeedItem_Common {
return $this->normalize_categories($cats);
}
function _get_enclosures() {
function get_enclosures() {
$links = $this->elem->getElementsByTagName("link");
$encs = array();
$encs = [];
foreach ($links as $link) {
if ($link && $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->link = clean($link->getAttribute("href"));
$enc->length = clean($link->getAttribute("length"));
$enc->link = clean($link->getAttribute("href"));
if (!empty($base)) {
$enc->link = UrlHelper::rewrite_relative($base, $enc->link);
}
array_push($encs, $enc);
}
}
}
$encs = array_merge($encs, parent::_get_enclosures());
$encs = array_merge($encs, parent::get_enclosures());
return $encs;
}

@ -78,7 +78,7 @@ abstract class FeedItem_Common extends FeedItem {
}
// this is common for both Atom and RSS types and deals with various media: elements
function _get_enclosures() {
function get_enclosures() {
$encs = [];
$enclosures = $this->xpath->query("media:content", $this->elem);
@ -190,6 +190,7 @@ abstract class FeedItem_Common extends FeedItem {
}, $tmp);
// remove empty values
// @phpstan-ignore-next-line
$tmp = array_filter($tmp, 'strlen');
asort($tmp);

@ -112,7 +112,7 @@ class FeedItem_RSS extends FeedItem_Common {
return $this->normalize_categories($cats);
}
function _get_enclosures() {
function get_enclosures() {
$enclosures = $this->elem->getElementsByTagName("enclosure");
$encs = array();
@ -129,7 +129,7 @@ class FeedItem_RSS extends FeedItem_Common {
array_push($encs, $enc);
}
$encs = array_merge($encs, parent::_get_enclosures());
$encs = array_merge($encs, parent::get_enclosures());
return $encs;
}

@ -108,7 +108,7 @@ class Feeds extends Handler_Protected {
$this->_mark_timestamp("db query");
$vfeed_group_enabled = get_pref("VFEED_GROUP_BY_FEED") &&
$vfeed_group_enabled = get_pref(Prefs::VFEED_GROUP_BY_FEED) &&
!(in_array($feed, self::NEVER_GROUP_FEEDS) && !$cat_view);
$result = $qfh_ret[0]; // this could be either a PDO query result or a -1 if first id changed
@ -167,7 +167,7 @@ class Feeds extends Handler_Protected {
++$headlines_count;
if (!get_pref('SHOW_CONTENT_PREVIEW')) {
if (!get_pref(Prefs::SHOW_CONTENT_PREVIEW)) {
$line["content_preview"] = "";
} else {
$line["content_preview"] = "&mdash; " . truncate_string(strip_tags($line["content"]), 250);
@ -200,6 +200,7 @@ class Feeds extends Handler_Protected {
$feed_id = $line["feed_id"];
if ($line["num_labels"] > 0) {
$label_cache = $line["label_cache"];
$labels = false;
@ -207,16 +208,19 @@ class Feeds extends Handler_Protected {
$label_cache = json_decode($label_cache, true);
if ($label_cache) {
if ($label_cache["no-labels"] ?? false == 1)
$labels = array();
if ($label_cache["no-labels"] ?? 0 == 1)
$labels = [];
else
$labels = $label_cache;
}
} else {
$labels = Article::_get_labels($id);
}
if (!is_array($labels)) $labels = Article::_get_labels($id);
$line["labels"] = Article::_get_labels($id);
$line["labels"] = $labels;
} else {
$line["labels"] = [];
}
if (count($topmost_article_ids) < 3) {
array_push($topmost_article_ids, $id);
@ -247,37 +251,26 @@ class Feeds extends Handler_Protected {
$this->_mark_timestamp(" sanitize");
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM,
function ($result, $plugin) use (&$line) {
$line = $result;
$this->_mark_timestamp(" hook_render_cdm: " . get_class($plugin));
},
$line);
$this->_mark_timestamp(" hook_render_cdm");
$line['content'] = DiskCache::rewrite_urls($line['content']);
$this->_mark_timestamp(" disk_cache_rewrite");
$this->_mark_timestamp(" note");
if (!get_pref("CDM_EXPANDED")) {
if (!get_pref(Prefs::CDM_EXPANDED)) {
$line["cdm_excerpt"] = "<span class='collapse'>
<i class='material-icons' onclick='return Article.cdmUnsetActive(event)'
title=\"" . __("Collapse article") . "\">remove_circle</i></span>";
if (get_pref('SHOW_CONTENT_PREVIEW')) {
if (get_pref(Prefs::SHOW_CONTENT_PREVIEW)) {
$line["cdm_excerpt"] .= "<span class='excerpt'>" . $line["content_preview"] . "</span>";
}
}
$this->_mark_timestamp(" pre-enclosures");
if ($line["num_enclosures"] > 0) {
$line["enclosures"] = Article::_format_enclosures($id,
$line["always_display_enclosures"],
$line["content"],
$line["hide_images"]);
} else {
$line["enclosures"] = [ 'formatted' => '', 'entries' => [] ];
}
$this->_mark_timestamp(" enclosures");
@ -292,9 +285,11 @@ class Feeds extends Handler_Protected {
if ($line["tag_cache"])
$tags = explode(",", $line["tag_cache"]);
else
$tags = false;
$tags = [];
$line["tags"] = Article::_get_tags($line["id"], false, $line["tag_cache"]);
$line["tags"] = $tags;
//$line["tags"] = Article::_get_tags($line["id"], false, $line["tag_cache"]);
$this->_mark_timestamp(" tags");
@ -320,11 +315,25 @@ class Feeds extends Handler_Protected {
}
$this->_mark_timestamp(" color");
$this->_mark_timestamp(" pre-hook_render_cdm");
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM,
function ($result, $plugin) use (&$line) {
$line = $result;
$this->_mark_timestamp(" hook: " . get_class($plugin));
},
$line);
$this->_mark_timestamp(" hook_render_cdm");
$line['content'] = DiskCache::rewrite_urls($line['content']);
$this->_mark_timestamp(" disk_cache_rewrite");
/* we don't need those */
foreach (["date_entered", "guid", "last_published", "last_marked", "tag_cache", "favicon_avg_color",
"uuid", "label_cache", "yyiw"] as $k)
"uuid", "label_cache", "yyiw", "num_enclosures"] as $k)
unset($line[$k]);
array_push($reply['content'], $line);
@ -413,7 +422,7 @@ class Feeds extends Handler_Protected {
$feed = $_REQUEST["feed"];
$method = $_REQUEST["m"] ?? "";
$view_mode = $_REQUEST["view_mode"];
$view_mode = $_REQUEST["view_mode"] ?? "";
$limit = 30;
$cat_view = $_REQUEST["cat"] == "true";
$next_unread_feed = $_REQUEST["nuf"] ?? 0;
@ -459,13 +468,14 @@ class Feeds extends Handler_Protected {
return;
}
set_pref("_DEFAULT_VIEW_MODE", $view_mode);
set_pref("_DEFAULT_VIEW_ORDER_BY", $order_by);
set_pref(Prefs::_DEFAULT_VIEW_MODE, $view_mode);
set_pref(Prefs::_DEFAULT_VIEW_ORDER_BY, $order_by);
/* bump login timestamp if needed */
if (time() - $_SESSION["last_login_update"] > 3600) {
$sth = $this->pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
$sth->execute([$_SESSION['uid']]);
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
$user->last_login = Db::NOW();
$user->save();
$_SESSION["last_login_update"] = time();
}
@ -499,7 +509,7 @@ class Feeds extends Handler_Protected {
"disable_cache" => (bool) $disable_cache];
// this is parsed by handleRpcJson() on first viewfeed() to set cdm expanded, etc
$reply['runtime-info'] = RPC::make_runtime_info();
$reply['runtime-info'] = RPC::_make_runtime_info();
print json_encode($reply);
}
@ -573,10 +583,27 @@ class Feeds extends Handler_Protected {
"show_language" => Config::get(Config::DB_TYPE) == "pgsql",
"show_syntax_help" => count(PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SEARCH)) == 0,
"all_languages" => Pref_Feeds::get_ts_languages(),
"default_language" => get_pref('DEFAULT_SEARCH_LANGUAGE')
"default_language" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE)
]);
}
function opensite() {
$feed = ORM::for_table('ttrss_feeds')
->find_one((int)$_REQUEST['feed_id']);
if ($feed) {
$site_url = UrlHelper::validate($feed->site_url);
if ($site_url) {
header("Location: $site_url");
return;
}
}
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
print "Feed not found or has an empty site URL.";
}
function updatedebugger() {
header("Content-type: text/html");
@ -799,7 +826,7 @@ class Feeds extends Handler_Protected {
if ($feed == -3) {
$intl = (int) get_pref("FRESH_ARTICLE_MAX_AGE");
$intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE);
if (Config::get(Config::DB_TYPE) == "pgsql") {
$match_part = "date_entered > NOW() - INTERVAL '$intl hour' ";
@ -892,7 +919,7 @@ class Feeds extends Handler_Protected {
} else if ($n_feed == -3) {
$match_part = "unread = true AND score >= 0";
$intl = (int) get_pref("FRESH_ARTICLE_MAX_AGE", $owner_uid);
$intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE, $owner_uid);
if (Config::get(Config::DB_TYPE) == "pgsql") {
$match_part .= " AND date_entered > NOW() - INTERVAL '$intl hour' ";
@ -953,7 +980,7 @@ class Feeds extends Handler_Protected {
function add() {
$feed = clean($_REQUEST['feed']);
$cat = clean($_REQUEST['cat']);
$cat = clean($_REQUEST['cat'] ?? '');
$need_auth = isset($_REQUEST['need_auth']);
$login = $need_auth ? clean($_REQUEST['login']) : '';
$pass = $need_auth ? clean($_REQUEST['pass']) : '';
@ -975,21 +1002,25 @@ class Feeds extends Handler_Protected {
* to get all possible feeds.
* 5 - Couldn't download the URL content.
* 6 - Content is an invalid XML.
* 7 - Error while creating feed database entry.
*/
static function _subscribe($url, $cat_id = 0,
$auth_login = '', $auth_pass = '') {
global $fetch_last_error;
global $fetch_last_error_content;
global $fetch_last_content_type;
$auth_login = '', $auth_pass = '') : array {
$pdo = Db::pdo();
$url = UrlHelper::validate($url);
if (!$url) return array("code" => 2);
if (!$url) return ["code" => 2];
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_PRE_SUBSCRIBE,
/** @phpstan-ignore-next-line */
function ($result) use (&$url, &$auth_login, &$auth_pass) {
// arguments are updated inside the hook (if needed)
},
$url, $auth_login, $auth_pass);
$contents = @UrlHelper::fetch($url, false, $auth_login, $auth_pass);
$contents = UrlHelper::fetch($url, false, $auth_login, $auth_pass);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SUBSCRIBE_FEED,
function ($result) use (&$contents) {
@ -998,14 +1029,14 @@ class Feeds extends Handler_Protected {
$contents, $url, $auth_login, $auth_pass);
if (empty($contents)) {
if (preg_match("/cloudflare\.com/", $fetch_last_error_content)) {
$fetch_last_error .= " (feed behind Cloudflare)";
if (preg_match("/cloudflare\.com/", UrlHelper::$fetch_last_error_content)) {
UrlHelper::$fetch_last_error .= " (feed behind Cloudflare)";
}
return array("code" => 5, "message" => $fetch_last_error);
return array("code" => 5, "message" => UrlHelper::$fetch_last_error);
}
if (mb_strpos($fetch_last_content_type, "html") !== false && self::_is_html($contents)) {
if (mb_strpos(UrlHelper::$fetch_last_content_type, "html") !== false && self::_is_html($contents)) {
$feedUrls = self::_get_feeds_from_html($url, $contents);
if (count($feedUrls) == 0) {
@ -1017,35 +1048,33 @@ class Feeds extends Handler_Protected {
$url = key($feedUrls);
}
if (!$cat_id) $cat_id = null;
$sth = $pdo->prepare("SELECT id FROM ttrss_feeds
WHERE feed_url = ? AND owner_uid = ?");
$sth->execute([$url, $_SESSION['uid']]);
$feed = ORM::for_table('ttrss_feeds')
->where('feed_url', $url)
->where('owner_uid', $_SESSION['uid'])
->find_one();
if ($row = $sth->fetch()) {
return array("code" => 0, "feed_id" => (int) $row["id"]);
if ($feed) {
return ["code" => 0, "feed_id" => $feed->id];
} else {
$sth = $pdo->prepare(
"INSERT INTO ttrss_feeds
(owner_uid,feed_url,title,cat_id, auth_login,auth_pass,update_method,auth_pass_encrypted)
VALUES (?, ?, ?, ?, ?, ?, 0, false)");
$sth->execute([$_SESSION['uid'], $url, "[Unknown]", $cat_id, (string)$auth_login, (string)$auth_pass]);
$sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE feed_url = ?
AND owner_uid = ?");
$sth->execute([$url, $_SESSION['uid']]);
$row = $sth->fetch();
$feed_id = $row["id"];
$feed = ORM::for_table('ttrss_feeds')->create();
$feed->set([
'owner_uid' => $_SESSION['uid'],
'feed_url' => $url,
'title' => "[Unknown]",
'cat_id' => $cat_id ? $cat_id : null,
'auth_login' => (string)$auth_login,
'auth_pass' => (string)$auth_pass,
'update_method' => 0,
'auth_pass_encrypted' => false,
]);
if ($feed_id) {
RSSUtils::set_basic_feed_info($feed_id);
if ($feed->save()) {
RSSUtils::update_basic_info($feed->id);
return ["code" => 1, "feed_id" => (int) $feed->id];
}
return array("code" => 1, "feed_id" => (int) $feed_id);
return ["code" => 7];
}
}
@ -1087,19 +1116,44 @@ class Feeds extends Handler_Protected {
return false;
}
static function _find_by_url($feed_url, $owner_uid) {
$sth = Db::pdo()->prepare("SELECT id FROM ttrss_feeds WHERE
feed_url = ? AND owner_uid = ?");
$sth->execute([$feed_url, $owner_uid]);
static function _find_by_url(string $feed_url, int $owner_uid) {
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $owner_uid)
->where('feed_url', $feed_url)
->find_one();
if ($row = $sth->fetch()) {
return $row["id"];
if ($feed) {
return $feed->id;
} else {
return false;
}
}
/** $owner_uid defaults to $_SESSION['uid] */
static function _find_by_title(string $title, bool $cat = false, int $owner_uid = 0) {
$res = false;
if ($cat) {
$res = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid ? $owner_uid : $_SESSION['uid'])
->where('title', $title)
->find_one();
} else {
$res = ORM::for_table('ttrss_feeds')
->where('owner_uid', $owner_uid ? $owner_uid : $_SESSION['uid'])
->where('title', $title)
->find_one();
}
if ($res) {
return $res->id;
} else {
return false;
}
}
static function _get_title($id, $cat = false) {
static function _get_title($id, bool $cat = false) {
$pdo = Db::pdo();
if ($cat) {
@ -1146,7 +1200,7 @@ class Feeds extends Handler_Protected {
}
// only real cats
static function _get_cat_marked($cat, $owner_uid = false) {
static function _get_cat_marked(int $cat, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -1169,7 +1223,7 @@ class Feeds extends Handler_Protected {
}
}
static function _get_cat_unread($cat, $owner_uid = false) {
static function _get_cat_unread(int $cat, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -1203,7 +1257,7 @@ class Feeds extends Handler_Protected {
}
// only accepts real cats (>= 0)
static function _get_cat_children_unread($cat, $owner_uid = false) {
static function _get_cat_children_unread(int $cat, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
@ -1222,7 +1276,7 @@ class Feeds extends Handler_Protected {
return $unread;
}
static function _get_global_unread($user_id = false) {
static function _get_global_unread(int $user_id = 0) {
if (!$user_id) $user_id = $_SESSION["uid"];
@ -1238,29 +1292,27 @@ class Feeds extends Handler_Protected {
return $row["count"];
}
static function _get_cat_title($cat_id) {
if ($cat_id == -1) {
static function _get_cat_title(int $cat_id) {
switch ($cat_id) {
case 0:
return __("Uncategorized");
case -1:
return __("Special");
} else if ($cat_id == -2) {
case -2:
return __("Labels");
} else {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT title FROM ttrss_feed_categories WHERE
id = ?");
$sth->execute([$cat_id]);
default:
$cat = ORM::for_table('ttrss_feed_categories')
->find_one($cat_id);
if ($row = $sth->fetch()) {
return $row["title"];
if ($cat) {
return $cat->title;
} else {
return __("Uncategorized");
return "UNKNOWN";
}
}
}
private static function _get_label_unread($label_id, $owner_uid = false) {
private static function _get_label_unread($label_id, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
@ -1478,7 +1530,7 @@ class Feeds extends Handler_Protected {
} else if ($feed == -3) { // fresh virtual feed
$query_strategy_part = "unread = true AND score >= 0";
$intl = (int) get_pref("FRESH_ARTICLE_MAX_AGE", $owner_uid);
$intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE, $owner_uid);
if (Config::get(Config::DB_TYPE) == "pgsql") {
$query_strategy_part .= " AND date_entered > NOW() - INTERVAL '$intl hour' ";
@ -1564,9 +1616,15 @@ class Feeds extends Handler_Protected {
$first_id = 0;
if (Config::get(Config::DB_TYPE) == "pgsql") {
$yyiw_qpart = "to_char(date_entered, 'IYYY-IW') AS yyiw";
} else {
$yyiw_qpart = "date_format(date_entered, '%Y-%u') AS yyiw";
}
if (is_numeric($feed)) {
// proper override_order applied above
if ($vfeed_query_part && !$ignore_vfeed_group && get_pref('VFEED_GROUP_BY_FEED', $owner_uid)) {
if ($vfeed_query_part && !$ignore_vfeed_group && get_pref(Prefs::VFEED_GROUP_BY_FEED, $owner_uid)) {
if (!(in_array($feed, self::NEVER_GROUP_BY_DATE) && !$cat_view)) {
$yyiw_desc = $order_by == "date_reverse" ? "" : "desc";
@ -1583,7 +1641,7 @@ class Feeds extends Handler_Protected {
}
if (!$allow_archived) {
$from_qpart = "${ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id),ttrss_feeds";
$from_qpart = "${ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id), ttrss_feeds";
$feed_check_qpart = "ttrss_user_entries.feed_id = ttrss_feeds.id AND";
} else {
@ -1601,16 +1659,19 @@ class Feeds extends Handler_Protected {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$sanity_interval_qpart = "date_entered >= NOW() - INTERVAL '1 hour' AND";
$yyiw_qpart = "to_char(date_entered, 'IYYY-IW') AS yyiw";
$distinct_columns = str_replace("desc", "", strtolower($order_by));
$distinct_qpart = "DISTINCT ON (id, $distinct_columns)";
} else {
$sanity_interval_qpart = "date_entered >= DATE_SUB(NOW(), INTERVAL 1 hour) AND";
$yyiw_qpart = "date_format(date_entered, '%Y-%u') AS yyiw";
$distinct_qpart = "DISTINCT"; //fallback
}
// except for Labels category
if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid) && !($feed == -2 && $cat_view)) {
$distinct_qpart = "";
}
if (!$search && !$skip_first_id_check) {
// if previous topmost article id changed that means our current pagination is no longer valid
$query = "SELECT
@ -1675,7 +1736,9 @@ class Feeds extends Handler_Protected {
last_marked, last_published,
$vfeed_query_part
$content_query_part
author,score
author,score,
(SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels,
(SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures
FROM
$from_qpart
WHERE
@ -1699,40 +1762,46 @@ class Feeds extends Handler_Protected {
} else {
// browsing by tag
if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid)) {
$distinct_qpart = "";
} else {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$distinct_columns = str_replace("desc", "", strtolower($order_by));
$distinct_qpart = "DISTINCT ON (id, $distinct_columns)";
} else {
$distinct_qpart = "DISTINCT"; //fallback
}
}
$query = "SELECT $distinct_qpart
ttrss_entries.id AS id,
date_entered,
$yyiw_qpart,
guid,
note,
ttrss_entries.id as id,
title,
ttrss_entries.title,
updated,
unread,
feed_id,
marked,
published,
label_cache,
tag_cache,
always_display_enclosures,
site_url,
note,
num_comments,
comments,
int_id,
tag_cache,
label_cache,
link,
lang,
uuid,
last_read,
(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images,
lang,
hide_images,
unread,feed_id,marked,published,link,last_read,
last_marked, last_published,
$since_id_part
$vfeed_query_part
$content_query_part
author, score
FROM ttrss_entries, ttrss_user_entries, ttrss_tags
author, score,
(SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels,
(SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures
FROM ttrss_entries,
ttrss_user_entries LEFT JOIN ttrss_feeds ON (ttrss_feeds.id = ttrss_user_entries.feed_id),
ttrss_tags
WHERE
ref_id = ttrss_entries.id AND
ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND
@ -1757,7 +1826,7 @@ class Feeds extends Handler_Protected {
}
static function _get_parent_cats($cat, $owner_uid) {
static function _get_parent_cats(int $cat, int $owner_uid) {
$rv = array();
$pdo = Db::pdo();
@ -1774,7 +1843,7 @@ class Feeds extends Handler_Protected {
return $rv;
}
static function _get_child_cats($cat, $owner_uid) {
static function _get_child_cats(int $cat, int $owner_uid) {
$rv = array();
$pdo = Db::pdo();
@ -1819,19 +1888,15 @@ class Feeds extends Handler_Protected {
return $rv;
}
static function _cat_of_feed($feed) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT cat_id FROM ttrss_feeds
WHERE id = ?");
$sth->execute([$feed]);
// returns Uncategorized as 0
static function _cat_of(int $feed) : int {
$feed = ORM::for_table('ttrss_feeds')->find_one($feed);
if ($row = $sth->fetch()) {
return $row["cat_id"];
if ($feed) {
return (int)$feed->cat_id;
} else {
return false;
return -1;
}
}
private function _color_of($name) {
@ -1882,80 +1947,79 @@ class Feeds extends Handler_Protected {
return preg_match("/<html|DOCTYPE html/i", substr($content, 0, 8192)) !== 0;
}
static function _add_cat($feed_cat, $parent_cat_id = false, $order_id = 0) {
if (!$feed_cat) return false;
static function _remove_cat(int $id, int $owner_uid) {
$cat = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid)
->find_one($id);
$feed_cat = mb_substr($feed_cat, 0, 250);
if (!$parent_cat_id) $parent_cat_id = null;
$pdo = Db::pdo();
$tr_in_progress = false;
try {
$pdo->beginTransaction();
} catch (Exception $e) {
$tr_in_progress = true;
if ($cat)
$cat->delete();
}
$sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE (parent_cat = :parent OR (:parent IS NULL AND parent_cat IS NULL))
AND title = :title AND owner_uid = :uid");
$sth->execute([':parent' => $parent_cat_id, ':title' => $feed_cat, ':uid' => $_SESSION['uid']]);
static function _add_cat(string $title, int $owner_uid, int $parent_cat = null, int $order_id = 0) {
if (!$sth->fetch()) {
$cat = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid)
->where('parent_cat', $parent_cat)
->where('title', $title)
->find_one();
$sth = $pdo->prepare("INSERT INTO ttrss_feed_categories (owner_uid,title,parent_cat,order_id)
VALUES (?, ?, ?, ?)");
$sth->execute([$_SESSION['uid'], $feed_cat, $parent_cat_id, (int)$order_id]);
if (!$cat) {
$cat = ORM::for_table('ttrss_feed_categories')->create();
if (!$tr_in_progress) $pdo->commit();
$cat->set([
'owner_uid' => $owner_uid,
'parent_cat' => $parent_cat,
'order_id' => $order_id,
'title' => $title,
]);
return true;
return $cat->save();
}
$pdo->commit();
return false;
}
static function _get_access_key($feed_id, $is_cat, $owner_uid = false) {
static function _clear_access_keys(int $owner_uid) {
$key = ORM::for_table('ttrss_access_keys')
->where('owner_uid', $owner_uid)
->delete_many();
}
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
static function _update_access_key(string $feed_id, bool $is_cat, int $owner_uid) {
$key = ORM::for_table('ttrss_access_keys')
->where('owner_uid', $owner_uid)
->where('feed_id', $feed_id)
->where('is_cat', $is_cat)
->delete_many();
$is_cat = bool_to_sql_bool($is_cat);
return self::_get_access_key($feed_id, $is_cat, $owner_uid);
}
$pdo = Db::pdo();
static function _get_access_key(string $feed_id, bool $is_cat, int $owner_uid) {
$key = ORM::for_table('ttrss_access_keys')
->where('owner_uid', $owner_uid)
->where('feed_id', $feed_id)
->where('is_cat', $is_cat)
->find_one();
$sth = $pdo->prepare("SELECT access_key FROM ttrss_access_keys
WHERE feed_id = ? AND is_cat = ?
AND owner_uid = ?");
$sth->execute([$feed_id, $is_cat, $owner_uid]);
if ($row = $sth->fetch()) {
return $row["access_key"];
if ($key) {
return $key->access_key;
} else {
$key = uniqid_short();
$sth = $pdo->prepare("INSERT INTO ttrss_access_keys
(access_key, feed_id, is_cat, owner_uid)
VALUES (?, ?, ?, ?)");
$key = ORM::for_table('ttrss_access_keys')->create();
$sth->execute([$key, $feed_id, $is_cat, $owner_uid]);
$key->owner_uid = $owner_uid;
$key->feed_id = $feed_id;
$key->is_cat = $is_cat;
$key->access_key = uniqid_short();
return $key;
if ($key->save()) {
return $key->access_key;
}
}
}
/**
* Purge a feed old posts.
*
* @param mixed $feed_id The id of the purged feed.
* @param mixed $purge_interval Olderness of purged posts.
* @access public
* @return mixed
*/
static function _purge($feed_id, $purge_interval) {
static function _purge(int $feed_id, int $purge_interval) {
if (!$purge_interval) $purge_interval = self::_get_purge_interval($feed_id);
@ -1975,7 +2039,7 @@ class Feeds extends Handler_Protected {
$purge_unread = true;
$purge_interval = Config::get(Config::FORCE_ARTICLE_PURGE);
} else {
$purge_unread = get_pref("PURGE_UNREAD_ARTICLES", $owner_uid, false);
$purge_unread = get_pref(Prefs::PURGE_UNREAD_ARTICLES, $owner_uid);
}
$purge_interval = (int) $purge_interval;
@ -2025,30 +2089,21 @@ class Feeds extends Handler_Protected {
return $rows_deleted;
}
private static function _get_purge_interval($feed_id) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT purge_interval, owner_uid FROM ttrss_feeds
WHERE id = ?");
$sth->execute([$feed_id]);
private static function _get_purge_interval(int $feed_id) {
$feed = ORM::for_table('ttrss_feeds')->find_one($feed_id);
if ($row = $sth->fetch()) {
$purge_interval = $row["purge_interval"];
$owner_uid = $row["owner_uid"];
if ($purge_interval == 0)
$purge_interval = get_pref('PURGE_OLD_DAYS', $owner_uid, false);
return $purge_interval;
if ($feed) {
if ($feed->purge_interval != 0)
return $feed->purge_interval;
else
return get_pref(Prefs::PURGE_OLD_DAYS, $feed->owner_uid);
} else {
return -1;
}
}
private static function _search_to_sql($search, $search_language, $owner_uid) {
$keywords = str_getcsv(trim($search), " ");
$keywords = str_getcsv(preg_replace('/(-?\w+)\:"(\w+)/', '"${1}:${2}', trim($search)), ' ');
$query_keywords = array();
$search_words = array();
$search_query_leftover = array();
@ -2058,7 +2113,7 @@ class Feeds extends Handler_Protected {
if ($search_language)
$search_language = $pdo->quote(mb_strtolower($search_language));
else
$search_language = $pdo->quote(mb_strtolower(get_pref('DEFAULT_SEARCH_LANGUAGE', $owner_uid)));
$search_language = $pdo->quote(mb_strtolower(get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE, $owner_uid)));
foreach ($keywords as $k) {
if (strpos($k, "-") === 0) {
@ -2166,7 +2221,7 @@ class Feeds extends Handler_Protected {
default:
if (strpos($k, "@") === 0) {
$user_tz_string = get_pref('USER_TIMEZONE', $_SESSION['uid']);
$user_tz_string = get_pref(Prefs::USER_TIMEZONE, $_SESSION['uid']);
$orig_ts = strtotime(substr($k, 1));
$k = date("Y-m-d", TimeHelper::convert_timestamp($orig_ts, $user_tz_string, 'UTC'));

@ -1,9 +1,10 @@
<?php
class Handler_Public extends Handler {
private function generate_syndicated_feed($owner_uid, $feed, $is_cat,
$limit, $offset, $search,
$view_mode = false, $format = 'atom', $order = false, $orig_guid = false, $start_ts = false) {
// $feed may be a tag
private function generate_syndicated_feed(int $owner_uid, string $feed, bool $is_cat,
int $limit, int $offset, string $search, string $view_mode = "",
string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = "") {
$note_style = "background-color : #fff7d5;
border-width : 1px; ".
@ -40,7 +41,7 @@ class Handler_Public extends Handler {
if (!$is_cat && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) {
$user_plugins = get_pref("_ENABLED_PLUGINS", $owner_uid);
$user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
$tmppluginhost = new PluginHost();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
@ -48,10 +49,14 @@ class Handler_Public extends Handler {
//$tmppluginhost->load_data();
$handler = $tmppluginhost->get_feed_handler(
PluginHost::feed_to_pfeed_id($feed));
PluginHost::feed_to_pfeed_id((int)$feed));
if ($handler) {
$qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id($feed), $params);
$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);
return false;
}
} else {
@ -63,7 +68,7 @@ class Handler_Public extends Handler {
$feed_site_url = $qfh_ret[2];
/* $last_error = $qfh_ret[3]; */
$feed_self_url = get_self_url_prefix() .
$feed_self_url = Config::get_self_url() .
"/public.php?op=rss&id=$feed&key=" .
Feeds::_get_access_key($feed, false, $owner_uid);
@ -75,7 +80,7 @@ class Handler_Public extends Handler {
$tpl->readTemplateFromFile("generated_feed.txt");
$tpl->setVariable('FEED_TITLE', $feed_title, true);
$tpl->setVariable('VERSION', get_version(), true);
$tpl->setVariable('VERSION', Config::get_version(), true);
$tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true);
$tpl->setVariable('SELF_URL', htmlspecialchars(get_self_url_prefix()), true);
@ -124,7 +129,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true);
$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : get_self_url_prefix()), true);
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ? $line['feed_title'] : $feed_title), true);
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true);
foreach ($line["tags"] as $tag) {
$tpl->setVariable('ARTICLE_CATEGORY', htmlspecialchars($tag), true);
@ -151,7 +156,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true);
}
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url);
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line);
$tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true);
@ -176,10 +181,8 @@ class Handler_Public extends Handler {
$feed['title'] = $feed_title;
$feed['feed_url'] = $feed_self_url;
$feed['self_url'] = get_self_url_prefix();
$feed['articles'] = array();
$feed['self_url'] = Config::get_self_url();
$feed['articles'] = [];
while ($line = $result->fetch()) {
@ -267,17 +270,18 @@ class Handler_Public extends Handler {
$rv = [];
if ($login) {
$sth = $this->pdo->prepare("SELECT ttrss_settings_profiles.* FROM ttrss_settings_profiles,ttrss_users
WHERE ttrss_users.id = ttrss_settings_profiles.owner_uid AND LOWER(login) = LOWER(?) ORDER BY title");
$sth->execute([$login]);
$profiles = ORM::for_table('ttrss_settings_profiles')
->table_alias('p')
->select_many('title' , 'p.id')
->join('ttrss_users', ['owner_uid', '=', 'u.id'], 'u')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->order_by_asc('title')
->find_many();
$rv = [ [ "value" => 0, "label" => __("Default profile") ] ];
while ($line = $sth->fetch()) {
$id = $line["id"];
$title = $line["title"];
array_push($rv, [ "label" => $title, "value" => $id ]);
foreach ($profiles as $profile) {
array_push($rv, [ "label" => $profile->title, "value" => $profile->id ]);
}
}
@ -304,7 +308,7 @@ class Handler_Public extends Handler {
$search = clean($_REQUEST["q"] ?? "");
$view_mode = clean($_REQUEST["view-mode"] ?? "");
$order = clean($_REQUEST["order"] ?? "");
$start_ts = (int)clean($_REQUEST["ts"] ?? 0);
$start_ts = clean($_REQUEST["ts"] ?? "");
$format = clean($_REQUEST['format'] ?? "atom");
$orig_guid = clean($_REQUEST["orig_guid"] ?? false);
@ -313,24 +317,21 @@ class Handler_Public extends Handler {
UserHelper::authenticate("admin", null);
}
$owner_id = false;
if ($key) {
$sth = $this->pdo->prepare("SELECT owner_uid FROM
ttrss_access_keys WHERE access_key = ? AND feed_id = ?");
$sth->execute([$key, $feed]);
$access_key = ORM::for_table('ttrss_access_keys')
->select('owner_uid')
->where(['access_key' => $key, 'feed_id' => $feed])
->find_one();
if ($row = $sth->fetch())
$owner_id = $row["owner_uid"];
if ($access_key) {
$this->generate_syndicated_feed($access_key->owner_uid, $feed, $is_cat, $limit,
$offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts);
return;
}
}
if ($owner_id) {
$this->generate_syndicated_feed($owner_id, $feed, $is_cat, $limit,
$offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts);
} else {
header('HTTP/1.1 403 Forbidden');
}
}
function updateTask() {
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
@ -354,46 +355,42 @@ class Handler_Public extends Handler {
$remember_me = clean($_POST["remember_me"] ?? false);
$safe_mode = checkbox_to_sql_bool(clean($_POST["safe_mode"] ?? false));
if (session_status() != PHP_SESSION_ACTIVE) {
if ($remember_me) {
@session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME));
session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME));
} else {
@session_set_cookie_params(0);
session_set_cookie_params(0);
}
}
if (UserHelper::authenticate($login, $password)) {
$_POST["password"] = "";
if (get_schema_version() >= 120) {
$_SESSION["language"] = get_pref("USER_LANGUAGE", $_SESSION["uid"]);
$_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]);
}
$_SESSION["ref_schema_version"] = get_schema_version(true);
$_SESSION["ref_schema_version"] = get_schema_version();
$_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false);
$_SESSION["safe_mode"] = $safe_mode;
if (!empty($_POST["profile"])) {
$profile = (int) clean($_POST["profile"]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles
WHERE id = ? AND owner_uid = ?");
$sth->execute([$profile, $_SESSION['uid']]);
$profile_obj = ORM::for_table('ttrss_settings_profiles')
->where(['id' => $profile, 'owner_uid' => $_SESSION['uid']])
->find_one();
if ($sth->fetch()) {
$_SESSION["profile"] = $profile;
} else {
$_SESSION["profile"] = null;
}
$_SESSION["profile"] = $profile_obj ? $profile : null;
}
} else {
// start an empty session to deliver login error message
@session_start();
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
if (!isset($_SESSION["login_error_msg"]))
$_SESSION["login_error_msg"] = __("Incorrect username or password");
user_error("Failed login attempt for $login from " . UserHelper::get_user_ip(), E_USER_WARNING);
}
$return = clean($_REQUEST['return']);
@ -401,7 +398,7 @@ class Handler_Public extends Handler {
if ($_REQUEST['return'] && mb_strpos($return, Config::get(Config::SELF_URL_PATH)) === 0) {
header("Location: " . clean($_REQUEST['return']));
} else {
header("Location: " . get_self_url_prefix());
header("Location: " . Config::get_self_url());
}
}
}
@ -415,7 +412,7 @@ class Handler_Public extends Handler {
startup_gettext();
session_start();
@$hash = clean($_REQUEST["hash"]);
$hash = clean($_REQUEST["hash"] ?? '');
header('Content-Type: text/html; charset=utf-8');
?>
@ -448,30 +445,27 @@ class Handler_Public extends Handler {
print "<h1>".__("Password recovery")."</h1>";
print "<div class='content'>";
@$method = clean($_POST['method']);
$method = clean($_POST['method'] ?? '');
if ($hash) {
$login = clean($_REQUEST["login"]);
if ($login) {
$sth = $this->pdo->prepare("SELECT id, resetpass_token FROM ttrss_users
WHERE LOWER(login) = LOWER(?)");
$sth->execute([$login]);
$user = ORM::for_table('ttrss_users')
->select_many('id', 'resetpass_token')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->find_one();
if ($row = $sth->fetch()) {
$id = $row["id"];
$resetpass_token_full = $row["resetpass_token"];
list($timestamp, $resetpass_token) = explode(":", $resetpass_token_full);
if ($user) {
list($timestamp, $resetpass_token) = explode(":", $user->resetpass_token);
if ($timestamp && $resetpass_token &&
$timestamp >= time() - 15*60*60 &&
$resetpass_token === $hash) {
$user->resetpass_token = null;
$user->save();
$sth = $this->pdo->prepare("UPDATE ttrss_users SET resetpass_token = NULL
WHERE id = ?");
$sth->execute([$id]);
UserHelper::reset_password($id, true);
UserHelper::reset_password($user->id, true);
print "<p>"."Completed."."</p>";
@ -520,7 +514,6 @@ class Handler_Public extends Handler {
</form>";
} else if ($method == 'do') {
$login = clean($_POST["login"]);
$email = clean($_POST["email"]);
$test = clean($_POST["test"]);
@ -532,23 +525,20 @@ class Handler_Public extends Handler {
<input type='hidden' name='op' value='forgotpass'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>".__("Go back")."</button>
</form>";
} else {
// prevent submitting this form multiple times
$_SESSION["pwdreset:testvalue1"] = rand(1, 1000);
$_SESSION["pwdreset:testvalue2"] = rand(1, 1000);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users
WHERE LOWER(login) = LOWER(?) AND email = ?");
$sth->execute([$login, $email]);
$user = ORM::for_table('ttrss_users')
->select('id')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->where('email', $email)
->find_one();
if ($row = $sth->fetch()) {
if ($user) {
print_notice("Password reset instructions are being sent to your email address.");
$id = $row["id"];
if ($id) {
$resetpass_token = sha1(get_random_bytes(128));
$resetpass_link = get_self_url_prefix() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
"&login=" . urlencode($login);
@ -576,20 +566,10 @@ class Handler_Public extends Handler {
if (!$rc) print_error($mailer->error());
$resetpass_token_full = time() . ":" . $resetpass_token;
$sth = $this->pdo->prepare("UPDATE ttrss_users
SET resetpass_token = ?
WHERE LOWER(login) = LOWER(?) AND email = ?");
$sth->execute([$resetpass_token_full, $login, $email]);
} else {
print_error("User ID not found.");
}
$user->resetpass_token = time() . ":" . $resetpass_token;
$user->save();
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
} else {
print_error(__("Sorry, login and email combination not found."));
@ -597,23 +577,20 @@ class Handler_Public extends Handler {
<input type='hidden' name='op' value='forgotpass'>
<button dojoType='dijit.form.Button' type='submit'>".__("Go back")."</button>
</form>";
}
}
}
print "</div>";
print "</div>";
print "</body>";
print "</html>";
}
function dbupdate() {
startup_gettext();
if (!Config::get(Config::SINGLE_USER_MODE) && $_SESSION["access_level"] < 10) {
if (!Config::get(Config::SINGLE_USER_MODE) && ($_SESSION["access_level"] ?? 0) < 10) {
$_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script.");
$this->_render_login_form();
exit;
@ -623,33 +600,55 @@ class Handler_Public extends Handler {
<!DOCTYPE html>
<html>
<head>
<title>Database Updater</title>
<title>Tiny Tiny RSS: Database Updater</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<?= stylesheet_tag("themes/light.css") ?>
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
<link rel="shortcut icon" type="image/png" href="images/favicon.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");
?>
foreach (["lib/dojo/dojo.js",
"lib/dojo/tt-rss-layer.js",
"js/common.js",
"js/utility.js"] as $jsfile) {
echo javascript_tag($jsfile);
} ?>
<?= Config::get_override_links() ?>
<style type="text/css">
span.ok { color : #009000; font-weight : bold; }
span.err { color : #ff0000; font-weight : bold; }
@media (prefers-color-scheme: dark) {
body {
background : #303030;
}
}
body.css_loading * {
display : none;
}
</style>
<script type="text/javascript">
require({cache:{}});
</script>
</head>
<body class="flat ttrss_utility">
<body class="flat ttrss_utility css_loading">
<script type="text/javascript">
const UtilityApp = {
init: function() {
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){
ready(function() {
parser.parse();
});
});
}
}
function confirmOP() {
return confirm("Update the database?");
function confirmDbUpdate() {
return confirm(__("Proceed with update?"));
}
</script>
@ -660,72 +659,66 @@ class Handler_Public extends Handler {
<?php
@$op = clean($_REQUEST["subop"] ?? "");
$updater = new DbUpdater(Db::pdo(), Config::get(Config::DB_TYPE), SCHEMA_VERSION);
if ($op == "performupdate") {
if ($updater->is_update_required()) {
print "<h2>" . T_sprintf("Performing updates to version %d", SCHEMA_VERSION) . "</h2>";
$migrations = Config::get_migrations();
for ($i = $updater->get_schema_version() + 1; $i <= SCHEMA_VERSION; $i++) {
print "<ul>";
print "<li class='text-info'>" . T_sprintf("Updating to version %d", $i) . "</li>";
if ($op == "performupdate") {
if ($migrations->is_migration_needed()) {
?>
print "<li>";
$result = $updater->update_to($i, true);
print "</li>";
<h2><?= T_sprintf("Performing updates to version %d", Config::SCHEMA_VERSION) ?></h2>
if (!$result) {
print "</ul>";
<code><pre class="small pre-wrap"><?php
Debug::set_enabled(true);
Debug::set_loglevel(Debug::LOG_VERBOSE);
$result = $migrations->migrate();
Debug::set_loglevel(Debug::LOG_NORMAL);
Debug::set_enabled(false);
?></pre></code>
print_error("One of the updates failed. Either retry the process or perform updates manually.");
<?php if (!$result) { ?>
<?= format_error("One of migrations failed. Either retry the process or perform updates manually.") ?>
print "<form method='POST'>
<input type='hidden' name='subop' value='performupdate'>
<button type='submit' dojoType='dijit.form.Button' class='alt-danger' onclick='return confirmOP()'>".__("Try again")."</button>
<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>
</form>";
<form method="post">
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
</form>
<?php } else { ?>
<?= format_notice("Update successful.") ?>
return;
} else {
print "<li class='text-success'>" . __("Completed.") . "</li>";
print "</ul>";
}
}
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
<?php }
print_notice("Your Tiny Tiny RSS database is now updated to the latest version.");
} else { ?>
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
<?= format_notice("Database is already up to date.") ?>
} else {
print_notice("Tiny Tiny RSS database is up to date.");
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
<?php
}
} else {
if ($updater->is_update_required()) {
if ($migrations->is_migration_needed()) {
print "<h2>".T_sprintf("Tiny Tiny RSS database needs update to the latest version (%d to %d).",
$updater->get_schema_version(), SCHEMA_VERSION)."</h2>";
?>
<h2><?= T_sprintf("Database schema needs update to the latest version (%d to %d).",
Config::get_schema_version(), Config::SCHEMA_VERSION) ?></h2>
if (Config::get(Config::DB_TYPE) == "mysql") {
print_error("<strong>READ THIS:</strong> Due to MySQL limitations, your database is not completely protected while updating. ".
"Errors may put it in an inconsistent state requiring manual rollback. <strong>BACKUP YOUR DATABASE BEFORE CONTINUING.</strong>");
} else {
print_warning("Please backup your database before proceeding.");
}
<?= format_warning("Please backup your database before proceeding.") ?>
print "<form method='POST'>
<input type='hidden' name='subop' value='performupdate'>
<button type='submit' dojoType='dijit.form.Button' class='alt-danger' onclick='return confirmOP()'>".__("Perform updates")."</button>
</form>";
<form method="post">
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
</form>
} else {
<?php
} else { ?>
print_notice("Tiny Tiny RSS database is up to date.");
<?= format_notice("Database is already up to date.") ?>
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
<?php
}
}
?>
@ -737,27 +730,6 @@ class Handler_Public extends Handler {
<?php
}
function publishOpml() {
$key = clean($_REQUEST["key"]);
$pdo = Db::pdo();
$sth = $pdo->prepare( "SELECT owner_uid
FROM ttrss_access_keys WHERE
access_key = ? AND feed_id = 'OPML:Publish'");
$sth->execute([$key]);
if ($row = $sth->fetch()) {
$owner_uid = $row['owner_uid'];
$opml = new OPML($_REQUEST);
$opml->opml_export("published.opml", $owner_uid, true, false);
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
}
}
function cached() {
list ($cache_dir, $filename) = explode("/", $_GET["file"], 2);
@ -778,7 +750,7 @@ class Handler_Public extends Handler {
$timestamp = date("Y-m-d", strtotime($timestamp));
return "tag:" . parse_url(get_self_url_prefix(), PHP_URL_HOST) . ",$timestamp:/$id";
return "tag:" . parse_url(Config::get_self_url(), PHP_URL_HOST) . ",$timestamp:/$id";
}
// this should be used very carefully because this endpoint is exposed to unauthenticated users
@ -816,9 +788,12 @@ class Handler_Public extends Handler {
}
}
static function _render_login_form() {
static function _render_login_form(string $return_to = "") {
header('Cache-Control: public');
if ($return_to)
$_REQUEST['return'] = $return_to;
require_once "login_form.php";
exit;
}

@ -3,7 +3,11 @@ class Logger {
private static $instance;
private $adapter;
public static $errornames = array(
const LOG_DEST_SQL = "sql";
const LOG_DEST_STDOUT = "stdout";
const LOG_DEST_SYSLOG = "syslog";
const ERROR_NAMES = [
1 => 'E_ERROR',
2 => 'E_WARNING',
4 => 'E_PARSE',
@ -19,10 +23,14 @@ class Logger {
4096 => 'E_RECOVERABLE_ERROR',
8192 => 'E_DEPRECATED',
16384 => 'E_USER_DEPRECATED',
32767 => 'E_ALL');
32767 => 'E_ALL'];
static function log_error(int $errno, string $errstr, string $file, int $line, $context) {
return self::get_instance()->_log_error($errno, $errstr, $file, $line, $context);
}
function log_error($errno, $errstr, $file, $line, $context) {
if ($errno == E_NOTICE) return false;
private function _log_error($errno, $errstr, $file, $line, $context) {
//if ($errno == E_NOTICE) return false;
if ($this->adapter)
return $this->adapter->log_error($errno, $errstr, $file, $line, $context);
@ -30,11 +38,15 @@ class Logger {
return false;
}
function log($errno, $errstr, $context = "") {
static function log(int $errno, string $errstr, $context = "") {
return self::get_instance()->_log($errno, $errstr, $context);
}
private function _log(int $errno, string $errstr, $context = "") {
if ($this->adapter)
return $this->adapter->log_error($errno, $errstr, '', 0, $context);
else
return false;
return user_error($errstr, $errno);
}
private function __clone() {
@ -43,25 +55,32 @@ class Logger {
function __construct() {
switch (Config::get(Config::LOG_DESTINATION)) {
case "sql":
case self::LOG_DEST_SQL:
$this->adapter = new Logger_SQL();
break;
case "syslog":
case self::LOG_DEST_SYSLOG:
$this->adapter = new Logger_Syslog();
break;
case "stdout":
case self::LOG_DEST_STDOUT:
$this->adapter = new Logger_Stdout();
break;
default:
$this->adapter = false;
}
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);
}
public static function get() {
private static function get_instance() : Logger {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
static function get() : Logger {
user_error("Please don't use Logger::get(), call Logger::log(...) instead.", E_USER_DEPRECATED);
return self::get_instance();
}
}

@ -0,0 +1,4 @@
<?php
interface Logger_Adapter {
function log_error(int $errno, string $errstr, string $file, int $line, $context);
}

@ -1,16 +1,20 @@
<?php
class Logger_SQL {
class Logger_SQL implements Logger_Adapter {
private $pdo;
function log_error($errno, $errstr, $file, $line, $context) {
function __construct() {
$conn = get_class($this);
// separate PDO connection object is used for logging
if (!$this->pdo) $this->pdo = Db::instance()->pdo_connect();
ORM::configure(Db::get_dsn(), null, $conn);
ORM::configure('username', Config::get(Config::DB_USER), $conn);
ORM::configure('password', Config::get(Config::DB_PASS), $conn);
ORM::configure('return_result_sets', true, $conn);
}
if ($this->pdo && get_schema_version() > 117) {
function log_error(int $errno, string $errstr, string $file, int $line, $context) {
$owner_uid = $_SESSION["uid"] ?? null;
if (Config::get_schema_version() > 117) {
// limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge
$context = mb_substr($context, 0, 8192);
@ -34,12 +38,23 @@ class Logger_SQL {
$errstr = UConverter::transcode($errstr, 'UTF-8', 'UTF-8');
$context = UConverter::transcode($context, 'UTF-8', 'UTF-8');
$sth = $this->pdo->prepare("INSERT INTO ttrss_error_log
(errno, errstr, filename, lineno, context, owner_uid, created_at) VALUES
(?, ?, ?, ?, ?, ?, NOW())");
$sth->execute([$errno, $errstr, $file, $line, $context, $owner_uid]);
// can't use $_SESSION["uid"] ?? null because what if its, for example, false? or zero?
// this would cause a PDOException on insert below
$owner_uid = !empty($_SESSION["uid"]) ? $_SESSION["uid"] : null;
$entry = ORM::for_table('ttrss_error_log', get_class($this))->create();
$entry->set([
'errno' => $errno,
'errstr' => $errstr,
'filename' => $file,
'lineno' => (int)$line,
'context' => $context,
'owner_uid' => $owner_uid,
'created_at' => Db::NOW(),
]);
return $sth->rowCount();
return $entry->save();
}
return false;

@ -1,7 +1,7 @@
<?php
class Logger_Stdout {
class Logger_Stdout implements Logger_Adapter {
function log_error($errno, $errstr, $file, $line, $context) {
function log_error(int $errno, string $errstr, string $file, int $line, $context) {
switch ($errno) {
case E_ERROR:
@ -21,7 +21,7 @@ class Logger_Stdout {
$priority = LOG_INFO;
}
$errname = Logger::$errornames[$errno] . " ($errno)";
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
print "[EEE] $priority $errname ($file:$line) $errstr\n";

@ -1,7 +1,7 @@
<?php
class Logger_Syslog {
class Logger_Syslog implements Logger_Adapter {
function log_error($errno, $errstr, $file, $line, $context) {
function log_error(int $errno, string $errstr, string $file, int $line, $context) {
switch ($errno) {
case E_ERROR:
@ -21,7 +21,7 @@ class Logger_Syslog {
$priority = LOG_INFO;
}
$errname = Logger::$errornames[$errno] . " ($errno)";
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
syslog($priority, "[tt-rss] $errname ($file:$line) $errstr");

@ -1,26 +1,23 @@
<?php
class Mailer {
// TODO: support HTML mail (i.e. MIME messages)
private $last_error = "Unable to send mail: check local configuration.";
private $last_error = "";
function mail($params) {
$to_name = $params["to_name"];
$to_name = $params["to_name"] ?? "";
$to_address = $params["to_address"];
$subject = $params["subject"];
$message = $params["message"];
$message_html = $params["message_html"];
$from_name = $params["from_name"] ? $params["from_name"] : Config::get(Config::SMTP_FROM_NAME);
$from_address = $params["from_address"] ? $params["from_address"] : Config::get(Config::SMTP_FROM_ADDRESS);
$additional_headers = $params["headers"] ? $params["headers"] : [];
$message_html = $params["message_html"] ?? "";
$from_name = $params["from_name"] ?? Config::get(Config::SMTP_FROM_NAME);
$from_address = $params["from_address"] ?? Config::get(Config::SMTP_FROM_ADDRESS);
$additional_headers = $params["headers"] ?? [];
$from_combined = $from_name ? "$from_name <$from_address>" : $from_address;
$to_combined = $to_name ? "$to_name <$to_address>" : $to_address;
if (Config::get(Config::LOG_SENT_MAIL))
Logger::get()->log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message");
Logger::log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message");
// HOOK_SEND_MAIL plugin instructions:
// 1. return 1 or true if mail is handled
@ -40,11 +37,18 @@ class Mailer {
$headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ];
return mail($to_combined, $subject, $message, implode("\r\n", array_merge($headers, $additional_headers)));
$rc = mail($to_combined, $subject, $message, implode("\r\n", array_merge($headers, $additional_headers)));
if (!$rc) {
$this->set_error(error_get_last()['message']);
}
return $rc;
}
function set_error($message) {
$this->last_error = $message;
user_error("Error sending mail: $message", E_USER_WARNING);
}
function error() {

@ -31,7 +31,7 @@ class OPML extends Handler_Protected {
<body class='claro ttrss_utility'>
<h1>".__('OPML Utility')."</h1><div class='content'>";
Feeds::_add_cat("Imported feeds");
Feeds::_add_cat("Imported feeds", $owner_uid);
$this->opml_notice(__("Importing OPML..."));
@ -48,9 +48,7 @@ class OPML extends Handler_Protected {
// Export
private function opml_export_category($owner_uid, $cat_id, $hide_private_feeds = false, $include_settings = true) {
$cat_id = (int) $cat_id;
private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true) {
if ($hide_private_feeds)
$hide_qpart = "(private IS false AND auth_login = '' AND auth_pass = '')";
@ -126,7 +124,7 @@ class OPML extends Handler_Protected {
return $out;
}
function opml_export($filename, $owner_uid, $hide_private_feeds = false, $include_settings = true, $file_output = false) {
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;
if (!$file_output)
@ -151,9 +149,9 @@ class OPML extends Handler_Protected {
# export tt-rss settings
if ($include_settings) {
$out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".SCHEMA_VERSION."\">";
$out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs WHERE
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE
profile IS NULL AND owner_uid = ? ORDER BY pref_name");
$sth->execute([$owner_uid]);
@ -166,7 +164,7 @@ class OPML extends Handler_Protected {
$out .= "</outline>";
$out .= "<outline text=\"tt-rss-labels\" schema-version=\"".SCHEMA_VERSION."\">";
$out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE
owner_uid = ?");
@ -183,13 +181,13 @@ class OPML extends Handler_Protected {
$out .= "</outline>";
$out .= "<outline text=\"tt-rss-filters\" schema-version=\"".SCHEMA_VERSION."\">";
$out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2
WHERE owner_uid = ? ORDER BY id");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
$line["rules"] = array();
$line["actions"] = array();
@ -298,7 +296,7 @@ class OPML extends Handler_Protected {
// Import
private function opml_import_feed($node, $cat_id, $owner_uid) {
private function opml_import_feed(DOMNode $node, int $cat_id, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$feed_title = mb_substr($attrs->getNamedItem('text')->nodeValue, 0, 250);
@ -318,7 +316,7 @@ class OPML extends Handler_Protected {
if (!$sth->fetch()) {
#$this->opml_notice("[FEED] [$feed_title/$feed_url] dst_CAT=$cat_id");
$this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title));
$this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
if (!$cat_id) $cat_id = null;
@ -338,12 +336,12 @@ class OPML extends Handler_Protected {
$sth->execute([$feed_title, $feed_url, $owner_uid, $cat_id, $site_url, $order_id, $update_interval, $purge_interval]);
} else {
$this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title));
$this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
}
}
}
private function opml_import_label($node, $owner_uid) {
private function opml_import_label(DOMNode $node, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$label_name = $attrs->getNamedItem('label-name')->nodeValue;
@ -351,16 +349,16 @@ class OPML extends Handler_Protected {
$fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue;
$bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue;
if (!Labels::find_id($label_name, $_SESSION['uid'])) {
$this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)));
if (!Labels::find_id($label_name, $owner_uid)) {
$this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)), $nest);
Labels::create($label_name, $fg_color, $bg_color, $owner_uid);
} else {
$this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)));
$this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)), $nest);
}
}
}
private function opml_import_preference($node) {
private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$pref_name = $attrs->getNamedItem('pref-name')->nodeValue;
@ -368,13 +366,13 @@ class OPML extends Handler_Protected {
$pref_value = $attrs->getNamedItem('value')->nodeValue;
$this->opml_notice(T_sprintf("Setting preference key %s to %s",
$pref_name, $pref_value));
$pref_name, $pref_value), $nest);
set_pref($pref_name, $pref_value);
set_pref($pref_name, $pref_value, $owner_uid);
}
}
private function opml_import_filter($node) {
private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$filter_type = $attrs->getNamedItem('filter-type')->nodeValue;
@ -393,23 +391,24 @@ class OPML extends Handler_Protected {
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2 (match_any_rule,enabled,inverse,title,owner_uid)
VALUES (?, ?, ?, ?, ?)");
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $_SESSION['uid']]);
$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([$_SESSION['uid']]);
$sth->execute([$owner_uid]);
$row = $sth->fetch();
$filter_id = $row['id'];
if ($filter_id) {
$this->opml_notice(T_sprintf("Adding filter %s...", $title));
$this->opml_notice(T_sprintf("Adding filter %s...", $title), $nest);
//$this->opml_notice(json_encode($filter));
foreach ($filter["rules"] as $rule) {
$feed_id = null;
$cat_id = null;
if ($rule["match"]) {
if ($rule["match"] ?? false) {
$match_on = [];
@ -420,7 +419,17 @@ class OPML extends Handler_Protected {
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
} else {
if (!$is_cat) {
$match_id = Feeds::_find_by_title($name, $is_cat, $owner_uid);
if ($match_id) {
if ($is_cat) {
array_push($match_on, "CAT:$match_id");
} else {
array_push($match_on, $match_id);
}
}
/*if (!$is_cat) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
@ -441,7 +450,7 @@ class OPML extends Handler_Protected {
array_push($match_on, "CAT:$match_id");
}
}
} */
}
}
@ -458,7 +467,17 @@ class OPML extends Handler_Protected {
} else {
if (!$rule["cat_filter"]) {
$match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $owner_uid);
if ($match_id) {
if ($rule['cat_filter']) {
$cat_id = $match_id;
} else {
$feed_id = $match_id;
}
}
/*if (!$rule["cat_filter"]) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
@ -476,7 +495,7 @@ class OPML extends Handler_Protected {
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
}
} */
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
$reg_exp = $rule["reg_exp"];
@ -507,8 +526,8 @@ class OPML extends Handler_Protected {
}
}
private function opml_import_category($doc, $root_node, $owner_uid, $parent_id) {
$default_cat_id = (int) $this->get_feed_category('Imported feeds', false);
private function opml_import_category(DOMDocument $doc, ?DOMNode $root_node, int $owner_uid, int $parent_id, int $nest) {
$default_cat_id = (int) $this->get_feed_category('Imported feeds', $owner_uid, 0);
if ($root_node) {
$cat_title = mb_substr($root_node->attributes->getNamedItem('text')->nodeValue, 0, 250);
@ -517,14 +536,13 @@ class OPML extends Handler_Protected {
$cat_title = mb_substr($root_node->attributes->getNamedItem('title')->nodeValue, 0, 250);
if (!in_array($cat_title, array("tt-rss-filters", "tt-rss-labels", "tt-rss-prefs"))) {
$cat_id = $this->get_feed_category($cat_title, $parent_id);
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
if ($cat_id === false) {
if ($cat_id === 0) {
$order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue;
if (!$order_id) $order_id = 0;
Feeds::_add_cat($cat_title, $parent_id, $order_id);
$cat_id = $this->get_feed_category($cat_title, $parent_id);
Feeds::_add_cat($cat_title, $owner_uid, $parent_id ? $parent_id : null, (int)$order_id);
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
}
} else {
@ -541,21 +559,21 @@ class OPML extends Handler_Protected {
$cat_title = false;
}
#$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id");
$this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")));
//$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id");
$this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")), $nest);
foreach ($outlines as $node) {
if ($node->hasAttributes() && strtolower($node->tagName) == "outline") {
$attrs = $node->attributes;
$node_cat_title = $attrs->getNamedItem('text')->nodeValue;
$node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false;
if (!$node_cat_title)
$node_cat_title = $attrs->getNamedItem('title')->nodeValue;
$node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false;
$node_feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue;
$node_feed_url = $attrs->getNamedItem('xmlUrl') ? $attrs->getNamedItem('xmlUrl')->nodeValue : false;
if ($node_cat_title && !$node_feed_url) {
$this->opml_import_category($doc, $node, $owner_uid, $cat_id);
$this->opml_import_category($doc, $node, $owner_uid, $cat_id, $nest+1);
} else {
if (!$cat_id) {
@ -566,31 +584,33 @@ class OPML extends Handler_Protected {
switch ($cat_title) {
case "tt-rss-prefs":
$this->opml_import_preference($node);
$this->opml_import_preference($node, $owner_uid, $nest+1);
break;
case "tt-rss-labels":
$this->opml_import_label($node, $owner_uid);
$this->opml_import_label($node, $owner_uid, $nest+1);
break;
case "tt-rss-filters":
$this->opml_import_filter($node);
$this->opml_import_filter($node, $owner_uid, $nest+1);
break;
default:
$this->opml_import_feed($node, $dst_cat_id, $owner_uid);
$this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1);
}
}
}
}
}
function opml_import($owner_uid) {
/** $filename is optional; assumes HTTP upload with $_FILES otherwise */
function opml_import(int $owner_uid, string $filename = "") {
if (!$owner_uid) return;
$doc = false;
if (!$filename) {
if ($_FILES['opml_file']['error'] != 0) {
print_error(T_sprintf("Upload failed with error code %d",
$_FILES['opml_file']['error']));
return;
return false;
}
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
@ -601,62 +621,75 @@ class OPML extends Handler_Protected {
if (!$result) {
print_error(__("Unable to move uploaded file."));
return;
return false;
}
} else {
print_error(__('Error: please upload OPML file.'));
return;
return false;
}
} else {
$tmp_file = $filename;
}
if (!is_readable($tmp_file)) {
$this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename));
return false;
}
$loaded = false;
if (is_file($tmp_file)) {
$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);
unlink($tmp_file);
} else if (empty($doc)) {
print_error(__('Error: unable to find moved OPML file.'));
return;
}
// only remove temporary i.e. HTTP uploaded files
if (!$filename)
unlink($tmp_file);
if ($loaded) {
$this->pdo->beginTransaction();
$this->opml_import_category($doc, false, $owner_uid, false);
$this->pdo->commit();
// we're using ORM while importing so we can't transaction-lock things anymore
//$this->pdo->beginTransaction();
$this->opml_import_category($doc, null, $owner_uid, 0, 0);
//$this->pdo->commit();
} else {
print_error(__('Error while parsing document.'));
}
$this->opml_notice(__('Error while parsing document.'));
return false;
}
private function opml_notice($msg) {
print "$msg<br/>";
return true;
}
static function get_publish_url(){
return get_self_url_prefix() .
"/public.php?op=publishOpml&key=" .
Feeds::_get_access_key('OPML:Publish', false, $_SESSION["uid"]);
private function opml_notice(string $msg, int $prefix_length = 0) {
if (php_sapi_name() == "cli") {
Debug::log(str_repeat(" ", $prefix_length) . $msg);
} else {
// TODO: use better separator i.e. CSS-defined span of certain width or something
print str_repeat("&nbsp;&nbsp;&nbsp;", $prefix_length) . $msg . "<br/>";
}
}
function get_feed_category($feed_cat, $parent_cat_id = false) {
$parent_cat_id = (int) $parent_cat_id;
function get_feed_category(string $feed_cat, int $owner_uid, int $parent_cat_id) : int {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = :title
AND (parent_cat = :parent OR (:parent = 0 AND parent_cat IS NULL))
AND owner_uid = :uid");
$sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $_SESSION['uid']]);
$sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $owner_uid]);
if ($row = $sth->fetch()) {
return $row['id'];
} else {
return false;
return 0;
}
}
}

@ -2,11 +2,10 @@
abstract class Plugin {
const API_VERSION_COMPAT = 1;
/** @var PDO */
/** @var PDO $pdo */
protected $pdo;
/* @var PluginHost $host */
abstract function init($host);
abstract function init(PluginHost $host);
abstract function about();
// return array(1.0, "plugin", "No description", "No author", false);
@ -26,6 +25,10 @@ abstract class Plugin {
return false;
}
function csrf_ignore($method) {
return false;
}
function get_js() {
return "";
}
@ -55,7 +58,4 @@ abstract class Plugin {
return vsprintf($this->__($msgid), $args);
}
function csrf_ignore($method) {
return false;
}
}

@ -23,56 +23,153 @@ class PluginHost {
// Hooks marked with *1 are run in global context and available
// to plugins loaded in config.php only
const HOOK_ARTICLE_BUTTON = "hook_article_button"; // hook_article_button($line)
const HOOK_ARTICLE_FILTER = "hook_article_filter"; // hook_article_filter($article)
const HOOK_PREFS_TAB = "hook_prefs_tab"; // hook_prefs_tab($tab)
const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section"; // hook_prefs_tab_section($section)
const HOOK_PREFS_TABS = "hook_prefs_tabs"; // hook_prefs_tabs()
const HOOK_FEED_PARSED = "hook_feed_parsed"; // hook_feed_parsed($parser, $feed_id)
const HOOK_UPDATE_TASK = "hook_update_task"; //*1 // GLOBAL: hook_update_task($cli_options)
const HOOK_AUTH_USER = "hook_auth_user"; // hook_auth_user($login, $password, $service) (byref)
const HOOK_HOTKEY_MAP = "hook_hotkey_map"; // hook_hotkey_map($hotkeys) (byref)
const HOOK_RENDER_ARTICLE = "hook_render_article"; // hook_render_article($article)
const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm"; // hook_render_article_cdm($article)
const HOOK_FEED_FETCHED = "hook_feed_fetched"; // hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) (byref)
const HOOK_SANITIZE = "hook_sanitize"; // hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) (byref)
const HOOK_RENDER_ARTICLE_API = "hook_render_article_api"; // hook_render_article_api($params)
const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button"; // hook_toolbar_button()
const HOOK_ACTION_ITEM = "hook_action_item"; // hook_action_item()
const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button"; // hook_headline_toolbar_button($feed_id, $is_cat)
const HOOK_HOTKEY_INFO = "hook_hotkey_info"; // hook_hotkey_info($hotkeys) (byref)
const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button"; // hook_article_left_button($row)
const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed"; // hook_prefs_edit_feed($feed_id)
const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed"; // hook_prefs_save_feed($feed_id)
const HOOK_FETCH_FEED = "hook_fetch_feed"; // hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) (byref)
const HOOK_QUERY_HEADLINES = "hook_query_headlines"; // hook_query_headlines($row) (byref)
const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1 // GLOBAL: hook_house_keeping()
const HOOK_SEARCH = "hook_search"; // hook_search($query)
const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures"; // hook__format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images) (byref)
const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed"; // hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) (byref)
const HOOK_HEADLINES_BEFORE = "hook_headlines_before"; // hook_headlines_before($feed, $is_cat, $qfh_ret)
const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure"; // hook_render_enclosure($entry, $id, $rv)
const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action"; // hook_article_filter_action($article, $action)
const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed"; // hook_article_export_feed($line, $feed, $is_cat, $owner_uid) (byref)
const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button"; // hook_main_toolbar_button()
const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry"; // hook_enclosure_entry($entry, $id, $rv) (byref)
const HOOK_FORMAT_ARTICLE = "hook_format_article"; // hook_format_article($html, $row)
const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm"; /* RIP */
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info"; // hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) (byref)
const HOOK_SEND_LOCAL_FILE = "hook_send_local_file"; // hook_send_local_file($filename)
const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed"; // hook_unsubscribe_feed($feed_id, $owner_uid)
const HOOK_SEND_MAIL = "hook_send_mail"; // hook_send_mail($mailer, $params)
const HOOK_FILTER_TRIGGERED = "hook_filter_triggered"; // hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters)
const HOOK_GET_FULL_TEXT = "hook_get_full_text"; // hook_get_full_text($url)
const HOOK_ARTICLE_IMAGE = "hook_article_image"; // hook_article_image($enclosures, $content, $site_url)
const HOOK_FEED_TREE = "hook_feed_tree"; // hook_feed_tree()
const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted"; // hook_iframe_whitelisted($url)
const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported"; // hook_enclosure_imported($enclosure, $feed)
const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map"; // hook_headlines_custom_sort_map()
/** hook_article_button($line) */
const HOOK_ARTICLE_BUTTON = "hook_article_button";
/** hook_article_filter($article) */
const HOOK_ARTICLE_FILTER = "hook_article_filter";
/** hook_prefs_tab($tab) */
const HOOK_PREFS_TAB = "hook_prefs_tab";
/** hook_prefs_tab_section($section) */
const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section";
/** hook_prefs_tabs() */
const HOOK_PREFS_TABS = "hook_prefs_tabs";
/** hook_feed_parsed($parser, $feed_id) */
const HOOK_FEED_PARSED = "hook_feed_parsed";
/** GLOBAL: hook_update_task($cli_options) */
const HOOK_UPDATE_TASK = "hook_update_task"; //*1
/** hook_auth_user($login, $password, $service) (byref) */
const HOOK_AUTH_USER = "hook_auth_user";
/** hook_hotkey_map($hotkeys) (byref) */
const HOOK_HOTKEY_MAP = "hook_hotkey_map";
/** hook_render_article($article) */
const HOOK_RENDER_ARTICLE = "hook_render_article";
/** hook_render_article_cdm($article) */
const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm";
/** hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) (byref) */
const HOOK_FEED_FETCHED = "hook_feed_fetched";
/** hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) (byref) */
const HOOK_SANITIZE = "hook_sanitize";
/** hook_render_article_api($params) */
const HOOK_RENDER_ARTICLE_API = "hook_render_article_api";
/** hook_toolbar_button() */
const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button";
/** hook_action_item() */
const HOOK_ACTION_ITEM = "hook_action_item";
/** hook_headline_toolbar_button($feed_id, $is_cat) */
const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button";
/** hook_hotkey_info($hotkeys) (byref) */
const HOOK_HOTKEY_INFO = "hook_hotkey_info";
/** hook_article_left_button($row) */
const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button";
/** hook_prefs_edit_feed($feed_id) */
const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed";
/** hook_prefs_save_feed($feed_id) */
const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed";
/** hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) (byref) */
const HOOK_FETCH_FEED = "hook_fetch_feed";
/** hook_query_headlines($row) (byref) */
const HOOK_QUERY_HEADLINES = "hook_query_headlines";
/** GLOBAL: hook_house_keeping() */
const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1
/** hook_search($query) */
const HOOK_SEARCH = "hook_search";
/** hook_format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images) (byref) */
const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures";
/** hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) (byref) */
const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed";
/** hook_headlines_before($feed, $is_cat, $qfh_ret) */
const HOOK_HEADLINES_BEFORE = "hook_headlines_before";
/** hook_render_enclosure($entry, $id, $rv) */
const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure";
/** hook_article_filter_action($article, $action) */
const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action";
/** hook_article_export_feed($line, $feed, $is_cat, $owner_uid) (byref) */
const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed";
/** hook_main_toolbar_button() */
const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button";
/** hook_enclosure_entry($entry, $id, $rv) (byref) */
const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry";
/** hook_format_article($html, $row) */
const HOOK_FORMAT_ARTICLE = "hook_format_article";
/** @deprecated removed, do not use */
const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm";
/** hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) (byref) */
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info";
/** hook_send_local_file($filename) */
const HOOK_SEND_LOCAL_FILE = "hook_send_local_file";
/** hook_unsubscribe_feed($feed_id, $owner_uid) */
const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed";
/** hook_send_mail(Mailer $mailer, $params) */
const HOOK_SEND_MAIL = "hook_send_mail";
/** hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) */
const HOOK_FILTER_TRIGGERED = "hook_filter_triggered";
/** hook_get_full_text($url) */
const HOOK_GET_FULL_TEXT = "hook_get_full_text";
/** hook_article_image($enclosures, $content, $site_url) */
const HOOK_ARTICLE_IMAGE = "hook_article_image";
/** hook_feed_tree() */
const HOOK_FEED_TREE = "hook_feed_tree";
/** hook_iframe_whitelisted($url) */
const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted";
/** hook_enclosure_imported($enclosure, $feed) */
const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported";
/** hook_headlines_custom_sort_map() */
const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map";
/** hook_headlines_custom_sort_override($order) */
const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = "hook_headlines_custom_sort_override";
// hook_headlines_custom_sort_override($order)
/** hook_headline_toolbar_select_menu_item($feed_id, $is_cat) */
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM = "hook_headline_toolbar_select_menu_item";
// hook_headline_toolbar_select_menu_item($feed_id, $is_cat)
/** hook_pre_subscribe($url, $auth_login, $auth_pass) (byref) */
const HOOK_PRE_SUBSCRIBE = "hook_pre_subscribe";
const KIND_ALL = 1;
const KIND_SYSTEM = 2;
@ -103,12 +200,12 @@ class PluginHost {
$this->plugins[$name] = $plugin;
}
// needed for compatibility with API 1
/** needed for compatibility with API 1 */
function get_link() {
return false;
}
// needed for compatibility with API 2 (?)
/** needed for compatibility with API 2 (?) */
function get_dbh() {
return false;
}
@ -274,16 +371,14 @@ class PluginHost {
$class = trim($class);
$class_file = strtolower(basename(clean($class)));
if (!is_dir(__DIR__ . "/../plugins/$class_file") &&
!is_dir(__DIR__ . "/../plugins.local/$class_file")) continue;
// try system plugin directory first
$file = __DIR__ . "/../plugins/$class_file/init.php";
$vendor_dir = __DIR__ . "/../plugins/$class_file/vendor";
$file = dirname(__DIR__) . "/plugins/$class_file/init.php";
if (!file_exists($file)) {
$file = __DIR__ . "/../plugins.local/$class_file/init.php";
$vendor_dir = __DIR__ . "/../plugins.local/$class_file/vendor";
$file = dirname(__DIR__) . "/plugins.local/$class_file/init.php";
if (!file_exists($file))
continue;
}
if (!isset($this->plugins[$class])) {
@ -296,27 +391,7 @@ class PluginHost {
if (class_exists($class) && is_subclass_of($class, "Plugin")) {
// register plugin autoloader if necessary, for namespaced classes ONLY
// layout corresponds to tt-rss main /vendor/author/Package/Class.php
if (file_exists($vendor_dir)) {
spl_autoload_register(function($class) use ($vendor_dir) {
if (strpos($class, '\\') !== false) {
list ($namespace, $class_name) = explode('\\', $class, 2);
if ($namespace && $class_name) {
$class_file = "$vendor_dir/$namespace/" . str_replace('\\', '/', $class_name) . ".php";
if (file_exists($class_file))
require_once $class_file;
}
}
});
}
$plugin = new $class($this);
$plugin_api = $plugin->api_version();
if ($plugin_api < self::API_VERSION) {
@ -374,7 +449,7 @@ class PluginHost {
$method = strtolower($method);
if ($this->is_system($sender)) {
if (!is_array($this->handlers[$handler])) {
if (!isset($this->handlers[$handler])) {
$this->handlers[$handler] = array();
}
@ -491,15 +566,70 @@ class PluginHost {
}
}
function set(Plugin $sender, string $name, $value, bool $sync = true) {
// same as set(), but sets data to current preference profile
function profile_set(Plugin $sender, string $name, $value) {
$profile_id = $_SESSION["profile"] ?? null;
if ($profile_id) {
$idx = get_class($sender);
if (!isset($this->storage[$idx])) {
$this->storage[$idx] = [];
}
if (!isset($this->storage[$idx][$profile_id])) {
$this->storage[$idx][$profile_id] = [];
}
$this->storage[$idx][$profile_id][$name] = $value;
$this->save_data(get_class($sender));
} else {
return $this->set($sender, $name, $value);
}
}
function set(Plugin $sender, string $name, $value) {
$idx = get_class($sender);
if (!isset($this->storage[$idx]))
$this->storage[$idx] = array();
$this->storage[$idx][$name] = $value;
$this->save_data(get_class($sender));
}
function set_array(Plugin $sender, array $params) {
$idx = get_class($sender);
if (!isset($this->storage[$idx]))
$this->storage[$idx] = array();
foreach ($params as $name => $value)
$this->storage[$idx][$name] = $value;
if ($sync) $this->save_data(get_class($sender));
$this->save_data(get_class($sender));
}
// same as get(), but sets data to current preference profile
function profile_get(Plugin $sender, string $name, $default_value = false) {
$profile_id = $_SESSION["profile"] ?? null;
if ($profile_id) {
$idx = get_class($sender);
$this->load_data();
if (isset($this->storage[$idx][$profile_id][$name])) {
return $this->storage[$idx][$profile_id][$name];
} else {
return $default_value;
}
} else {
return $this->get($sender, $name, $default_value);
}
}
function get(Plugin $sender, string $name, $default_value = false) {
@ -514,6 +644,14 @@ class PluginHost {
}
}
function get_array(Plugin $sender, string $name, array $default_value = []) {
$tmp = $this->get($sender, $name);
if (!is_array($tmp)) $tmp = $default_value;
return $tmp;
}
function get_all($sender) {
$idx = get_class($sender);
@ -601,7 +739,7 @@ class PluginHost {
// handled by classes/pluginhandler.php, requires valid session
function get_method_url(Plugin $sender, string $method, $params = []) {
return get_self_url_prefix() . "/backend.php?" .
return Config::get_self_url() . "/backend.php?" .
http_build_query(
array_merge(
[
@ -614,7 +752,7 @@ class PluginHost {
// shortcut syntax (disabled for now)
/* function get_method_url(Plugin $sender, string $method, $params) {
return get_self_url_prefix() . "/backend.php?" .
return Config::get_self_url() . "/backend.php?" .
http_build_query(
array_merge(
[
@ -626,7 +764,7 @@ class PluginHost {
// WARNING: endpoint in public.php, exposed to unauthenticated users
function get_public_method_url(Plugin $sender, string $method, $params = []) {
if ($sender->is_public_method($method)) {
return get_self_url_prefix() . "/public.php?" .
return Config::get_self_url() . "/public.php?" .
http_build_query(
array_merge(
[
@ -637,4 +775,15 @@ class PluginHost {
user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private.");
}
}
function get_plugin_dir(Plugin $plugin) {
$ref = new ReflectionClass(get_class($plugin));
return dirname($ref->getFileName());
}
// TODO: use get_plugin_dir()
function is_local(Plugin $plugin) {
$ref = new ReflectionClass(get_class($plugin));
return basename(dirname(dirname($ref->getFileName()))) == "plugins.local";
}
}

@ -1,5 +1,10 @@
<?php
class Pref_Feeds extends Handler_Protected {
const E_ICON_FILE_TOO_LARGE = 'E_ICON_FILE_TOO_LARGE';
const E_ICON_RENAME_FAILED = 'E_ICON_RENAME_FAILED';
const E_ICON_UPLOAD_FAILED = 'E_ICON_UPLOAD_FAILED';
const E_ICON_UPLOAD_SUCCESS = 'E_ICON_UPLOAD_SUCCESS';
function csrf_ignore($method) {
$csrf_ignored = array("index", "getfeedtree", "savefeedorder");
@ -7,29 +12,24 @@ class Pref_Feeds extends Handler_Protected {
}
public static function get_ts_languages() {
$rv = [];
if (Config::get(Config::DB_TYPE) == "pgsql") {
$dbh = Db::pdo();
$res = $dbh->query("SELECT cfgname FROM pg_ts_config");
while ($row = $res->fetch()) {
array_push($rv, ucfirst($row['cfgname']));
}
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 $rv;
return [];
}
function renamecat() {
function renameCat() {
$cat = ORM::for_table("ttrss_feed_categories")
->where("owner_uid", $_SESSION["uid"])
->find_one($_REQUEST['id']);
$title = clean($_REQUEST['title']);
$id = clean($_REQUEST['id']);
if ($title) {
$sth = $this->pdo->prepare("UPDATE ttrss_feed_categories SET
title = ? WHERE id = ? AND owner_uid = ?");
$sth->execute([$title, $id, $_SESSION['uid']]);
if ($cat && $title) {
$cat->title = $title;
$cat->save();
}
}
@ -44,61 +44,60 @@ class Pref_Feeds extends Handler_Protected {
$show_empty_cats = clean($_REQUEST['force_show_empty'] ?? false) ||
(clean($_REQUEST['mode'] ?? 0) != 2 && !$search);
$items = array();
$sth = $this->pdo->prepare("SELECT id, title FROM ttrss_feed_categories
WHERE owner_uid = ? AND parent_cat = ? ORDER BY order_id, title");
$sth->execute([$_SESSION['uid'], $cat_id]);
while ($line = $sth->fetch()) {
$cat = array();
$cat['id'] = 'CAT:' . $line['id'];
$cat['bare_id'] = (int)$line['id'];
$cat['name'] = $line['title'];
$cat['items'] = array();
$cat['checkbox'] = false;
$cat['type'] = 'category';
$cat['unread'] = -1;
$cat['child_unread'] = -1;
$cat['auxcounter'] = -1;
$cat['parent_id'] = $cat_id;
$cat['items'] = $this->get_category_items($line['id']);
$items = [];
$feed_categories = ORM::for_table('ttrss_feed_categories')
->select_many('id', 'title')
->where(['owner_uid' => $_SESSION['uid'], 'parent_cat' => $cat_id])
->order_by_asc('order_id')
->order_by_asc('title')
->find_many();
foreach ($feed_categories as $feed_category) {
$cat = [
'id' => 'CAT:' . $feed_category->id,
'bare_id' => (int)$feed_category->id,
'name' => $feed_category->title,
'items' => $this->get_category_items($feed_category->id),
'checkbox' => false,
'type' => 'category',
'unread' => -1,
'child_unread' => -1,
'auxcounter' => -1,
'parent_id' => $cat_id,
];
$num_children = $this->calculate_children_count($cat);
$cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children);
if ($num_children > 0 || $show_empty_cats)
array_push($items, $cat);
}
$fsth = $this->pdo->prepare("SELECT id, title, last_error,
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval
FROM ttrss_feeds
WHERE cat_id = :cat AND
owner_uid = :uid AND
(:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search))
ORDER BY order_id, title");
$fsth->execute([":cat" => $cat_id, ":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]);
while ($feed_line = $fsth->fetch()) {
$feed = array();
$feed['id'] = 'FEED:' . $feed_line['id'];
$feed['bare_id'] = (int)$feed_line['id'];
$feed['auxcounter'] = -1;
$feed['name'] = $feed_line['title'];
$feed['checkbox'] = false;
$feed['unread'] = -1;
$feed['error'] = $feed_line['last_error'];
$feed['icon'] = Feeds::_get_icon($feed_line['id']);
$feed['param'] = TimeHelper::make_local_datetime(
$feed_line['last_updated'], true);
$feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0);
array_push($items, $feed);
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where(['cat_id' => $cat_id, 'owner_uid' => $_SESSION['uid']])
->order_by_asc('order_id')
->order_by_asc('title');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
}
foreach ($feeds_obj->find_many() as $feed) {
array_push($items, [
'id' => 'FEED:' . $feed->id,
'bare_id' => (int) $feed->id,
'auxcounter' => -1,
'name' => $feed->title,
'checkbox' => false,
'unread' => -1,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
return $items;
@ -122,7 +121,7 @@ class Pref_Feeds extends Handler_Protected {
$root['param'] = 0;
$root['type'] = 'category';
$enable_cats = get_pref('ENABLE_FEED_CATS');
$enable_cats = get_pref(Prefs::ENABLE_FEED_CATS);
if (clean($_REQUEST['mode'] ?? 0) == 2) {
@ -171,27 +170,26 @@ class Pref_Feeds extends Handler_Protected {
ttrss_labels2 WHERE owner_uid = ? ORDER by caption");
$sth->execute([$_SESSION['uid']]);
if (get_pref('ENABLE_FEED_CATS')) {
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
$cat = $this->feedlist_init_cat(-2);
} else {
$cat['items'] = array();
$cat['items'] = [];
}
$num_labels = 0;
while ($line = $sth->fetch()) {
++$num_labels;
$label_id = Labels::label_to_feed_id($line['id']);
$labels = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('caption')
->find_many();
if (count($labels)) {
foreach ($labels as $label) {
$label_id = Labels::label_to_feed_id($label->id);
$feed = $this->feedlist_init_feed($label_id, false, 0);
$feed['fg_color'] = $line['fg_color'];
$feed['bg_color'] = $line['bg_color'];
$feed['fg_color'] = $label->fg_color;
$feed['bg_color'] = $label->bg_color;
array_push($cat['items'], $feed);
}
if ($num_labels) {
if ($enable_cats) {
array_push($root['items'], $cat);
} else {
@ -204,23 +202,26 @@ class Pref_Feeds extends Handler_Protected {
$show_empty_cats = clean($_REQUEST['force_show_empty'] ?? false) ||
(clean($_REQUEST['mode'] ?? 0) != 2 && !$search);
$sth = $this->pdo->prepare("SELECT id, title FROM ttrss_feed_categories
WHERE owner_uid = ? AND parent_cat IS NULL ORDER BY order_id, title");
$sth->execute([$_SESSION['uid']]);
while ($line = $sth->fetch()) {
$cat = array();
$cat['id'] = 'CAT:' . $line['id'];
$cat['bare_id'] = (int)$line['id'];
$cat['auxcounter'] = -1;
$cat['name'] = $line['title'];
$cat['items'] = array();
$cat['checkbox'] = false;
$cat['type'] = 'category';
$cat['unread'] = -1;
$cat['child_unread'] = -1;
$cat['items'] = $this->get_category_items($line['id']);
$feed_categories = ORM::for_table('ttrss_feed_categories')
->select_many('id', 'title')
->where('owner_uid', $_SESSION['uid'])
->where_null('parent_cat')
->order_by_asc('order_id')
->order_by_asc('title')
->find_many();
foreach ($feed_categories as $feed_category) {
$cat = [
'id' => 'CAT:' . $feed_category->id,
'bare_id' => (int) $feed_category->id,
'auxcounter' => -1,
'name' => $feed_category->title,
'items' => $this->get_category_items($feed_category->id),
'checkbox' => false,
'type' => 'category',
'unread' => -1,
'child_unread' => -1,
];
$num_children = $this->calculate_children_count($cat);
$cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children);
@ -228,47 +229,48 @@ class Pref_Feeds extends Handler_Protected {
if ($num_children > 0 || $show_empty_cats)
array_push($root['items'], $cat);
$root['param'] += count($cat['items']);
//$root['param'] += count($cat['items']);
}
/* Uncategorized is a special case */
$cat = [
'id' => 'CAT:0',
'bare_id' => 0,
'auxcounter' => -1,
'name' => __('Uncategorized'),
'items' => [],
'type' => 'category',
'checkbox' => false,
'unread' => -1,
'child_unread' => -1,
];
$cat = array();
$cat['id'] = 'CAT:0';
$cat['bare_id'] = 0;
$cat['auxcounter'] = -1;
$cat['name'] = __("Uncategorized");
$cat['items'] = array();
$cat['type'] = 'category';
$cat['checkbox'] = false;
$cat['unread'] = -1;
$cat['child_unread'] = -1;
$fsth = $this->pdo->prepare("SELECT id, title,last_error,
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval
FROM ttrss_feeds
WHERE cat_id IS NULL AND
owner_uid = :uid AND
(:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search))
ORDER BY order_id, title");
$fsth->execute([":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]);
while ($feed_line = $fsth->fetch()) {
$feed = array();
$feed['id'] = 'FEED:' . $feed_line['id'];
$feed['bare_id'] = (int)$feed_line['id'];
$feed['auxcounter'] = -1;
$feed['name'] = $feed_line['title'];
$feed['checkbox'] = false;
$feed['error'] = $feed_line['last_error'];
$feed['icon'] = Feeds::_get_icon($feed_line['id']);
$feed['param'] = TimeHelper::make_local_datetime(
$feed_line['last_updated'], true);
$feed['unread'] = -1;
$feed['type'] = 'feed';
$feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0);
array_push($cat['items'], $feed);
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where('owner_uid', $_SESSION['uid'])
->where_null('cat_id')
->order_by_asc('order_id')
->order_by_asc('title');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
}
foreach ($feeds_obj->find_many() as $feed) {
array_push($cat['items'], [
'id' => 'FEED:' . $feed->id,
'bare_id' => (int) $feed->id,
'auxcounter' => -1,
'name' => $feed->title,
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
$cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($cat['items'])), count($cat['items']));
@ -280,46 +282,41 @@ class Pref_Feeds extends Handler_Protected {
$root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children);
} else {
$fsth = $this->pdo->prepare("SELECT id, title, last_error,
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval
FROM ttrss_feeds
WHERE owner_uid = :uid AND
(:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search))
ORDER BY order_id, title");
$fsth->execute([":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]);
while ($feed_line = $fsth->fetch()) {
$feed = array();
$feed['id'] = 'FEED:' . $feed_line['id'];
$feed['bare_id'] = (int)$feed_line['id'];
$feed['auxcounter'] = -1;
$feed['name'] = $feed_line['title'];
$feed['checkbox'] = false;
$feed['error'] = $feed_line['last_error'];
$feed['icon'] = Feeds::_get_icon($feed_line['id']);
$feed['param'] = TimeHelper::make_local_datetime(
$feed_line['last_updated'], true);
$feed['unread'] = -1;
$feed['type'] = 'feed';
$feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0);
array_push($root['items'], $feed);
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', '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');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
}
foreach ($feeds_obj->find_many() as $feed) {
array_push($root['items'], [
'id' => 'FEED:' . $feed->id,
'bare_id' => (int) $feed->id,
'auxcounter' => -1,
'name' => $feed->title,
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
$root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($root['items'])), count($root['items']));
}
$fl = array();
$fl['identifier'] = 'id';
$fl['label'] = 'name';
if (clean($_REQUEST['mode'] ?? 0) != 2) {
$fl['items'] = array($root);
} else {
$fl['items'] = $root['items'];
}
return $fl;
return [
'identifier' => 'id',
'label' => 'name',
'items' => clean($_REQUEST['mode'] ?? 0) != 2 ? [$root] : $root['items'],
];
}
function catsortreset() {
@ -352,10 +349,14 @@ class Pref_Feeds extends Handler_Protected {
$parent_qpart = null;
}
$sth = $this->pdo->prepare("UPDATE ttrss_feed_categories
SET parent_cat = ? WHERE id = ? AND
owner_uid = ?");
$sth->execute([$parent_qpart, $bare_item_id, $_SESSION['uid']]);
$feed_category = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $_SESSION['uid'])
->find_one($bare_item_id);
if ($feed_category) {
$feed_category->parent_cat = $parent_qpart;
$feed_category->save();
}
}
$order_id = 1;
@ -373,22 +374,27 @@ class Pref_Feeds extends Handler_Protected {
if (strpos($id, "FEED") === 0) {
$cat_id = ($item_id != "root") ? $bare_item_id : null;
$sth = $this->pdo->prepare("UPDATE ttrss_feeds
SET order_id = ?, cat_id = ?
WHERE id = ? AND owner_uid = ?");
$sth->execute([$order_id, $cat_id ? $cat_id : null, $bare_id, $_SESSION['uid']]);
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->find_one($bare_id);
if ($feed) {
$feed->order_id = $order_id;
$feed->cat_id = ($item_id != "root" && $bare_item_id) ? $bare_item_id : null;
$feed->save();
}
} else if (strpos($id, "CAT:") === 0) {
$this->process_category_order($data_map, $item['_reference'], $item_id,
$nest_level+1);
$sth = $this->pdo->prepare("UPDATE ttrss_feed_categories
SET order_id = ? WHERE id = ? AND
owner_uid = ?");
$sth->execute([$order_id, $bare_id, $_SESSION['uid']]);
$feed_category = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $_SESSION['uid'])
->find_one($bare_id);
if ($feed_category) {
$feed_category->order_id = $order_id;
$feed_category->save();
}
}
}
@ -433,78 +439,67 @@ class Pref_Feeds extends Handler_Protected {
}
}
function removeicon() {
$feed_id = clean($_REQUEST["feed_id"]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
function removeIcon() {
$feed_id = (int) $_REQUEST["feed_id"];
$icon_file = Config::get(Config::ICONS_DIR) . "/$feed_id.ico";
if ($row = $sth->fetch()) {
@unlink(Config::get(Config::ICONS_DIR) . "/$feed_id.ico");
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->find_one($feed_id);
$sth = $this->pdo->prepare("UPDATE ttrss_feeds SET favicon_avg_color = NULL, favicon_last_checked = '1970-01-01'
where id = ?");
$sth->execute([$feed_id]);
if ($feed && file_exists($icon_file)) {
if (unlink($icon_file)) {
$feed->set([
'favicon_avg_color' => null,
'favicon_last_checked' => '1970-01-01',
'favicon_is_custom' => false,
]);
$feed->save();
}
}
}
function uploadicon() {
header("Content-type: text/html");
if (is_uploaded_file($_FILES['icon_file']['tmp_name'])) {
function uploadIcon() {
$feed_id = (int) $_REQUEST['feed_id'];
$tmp_file = tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'icon');
if (!$tmp_file)
return;
$result = move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file);
if (!$result) {
return;
}
} else {
return;
}
$icon_file = $tmp_file;
$feed_id = clean($_REQUEST["feed_id"]);
$rc = 2; // failed
// default value
$rc = self::E_ICON_UPLOAD_FAILED;
if ($icon_file && is_file($icon_file) && $feed_id) {
if (filesize($icon_file) < 65535) {
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->find_one($feed_id);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
if ($feed && $tmp_file && move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file)) {
if (filesize($tmp_file) < Config::get(Config::MAX_FAVICON_FILE_SIZE)) {
if ($row = $sth->fetch()) {
$new_filename = Config::get(Config::ICONS_DIR) . "/$feed_id.ico";
if (file_exists($new_filename)) unlink($new_filename);
if (rename($tmp_file, $new_filename)) {
chmod($new_filename, 0644);
if (rename($icon_file, $new_filename)) {
chmod($new_filename, 644);
$sth = $this->pdo->prepare("UPDATE ttrss_feeds SET
favicon_avg_color = ''
WHERE id = ?");
$sth->execute([$feed_id]);
$feed->set([
'favicon_avg_color' => null,
'favicon_is_custom' => true,
]);
$rc = Feeds::_get_icon($feed_id);
if ($feed->save()) {
$rc = self::E_ICON_UPLOAD_SUCCESS;
}
} else {
$rc = self::E_ICON_RENAME_FAILED;
}
} else {
$rc = 1;
$rc = self::E_ICON_FILE_TOO_LARGE;
}
}
if ($icon_file && is_file($icon_file)) {
unlink($icon_file);
}
if (file_exists($tmp_file))
unlink($tmp_file);
print $rc;
return;
print json_encode(['rc' => $rc, 'icon_url' => Feeds::_get_icon($feed_id)]);
}
function editfeed() {
@ -513,11 +508,11 @@ class Pref_Feeds extends Handler_Protected {
$feed_id = (int)clean($_REQUEST["id"]);
$sth = $this->pdo->prepare("SELECT * FROM ttrss_feeds WHERE id = ? AND
owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
$row = ORM::for_table('ttrss_feeds')
->where("owner_uid", $_SESSION["uid"])
->find_one($feed_id)->as_array();
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
if ($row) {
ob_start();
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_EDIT_FEED, $feed_id);
@ -527,11 +522,11 @@ class Pref_Feeds extends Handler_Protected {
$row["icon"] = Feeds::_get_icon($feed_id);
$local_update_intervals = $update_intervals;
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[get_pref("DEFAULT_UPDATE_INTERVAL")]);
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[get_pref(Prefs::DEFAULT_UPDATE_INTERVAL)]);
if (Config::get(Config::FORCE_ARTICLE_PURGE) == 0) {
$local_purge_intervals = $purge_intervals;
$default_purge_interval = get_pref("PURGE_OLD_DAYS");
$default_purge_interval = get_pref(Prefs::PURGE_OLD_DAYS);
if ($default_purge_interval > 0)
$local_purge_intervals[0] .= " " . T_nsprintf('(%d day)', '(%d days)', $default_purge_interval, $default_purge_interval);
@ -546,7 +541,7 @@ class Pref_Feeds extends Handler_Protected {
print json_encode([
"feed" => $row,
"cats" => [
"enabled" => get_pref('ENABLE_FEED_CATS'),
"enabled" => get_pref(Prefs::ENABLE_FEED_CATS),
"select" => \Controls\select_feeds_cats("cat_id", $row["cat_id"]),
],
"plugin_data" => $plugin_data,
@ -557,7 +552,7 @@ class Pref_Feeds extends Handler_Protected {
],
"lang" => [
"enabled" => Config::get(Config::DB_TYPE) == "pgsql",
"default" => get_pref('DEFAULT_SEARCH_LANGUAGE'),
"default" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE),
"all" => $this::get_ts_languages(),
]
]);
@ -576,10 +571,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("DEFAULT_UPDATE_INTERVAL")]);
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[get_pref(Prefs::DEFAULT_UPDATE_INTERVAL)]);
$local_purge_intervals = $purge_intervals;
$default_purge_interval = get_pref("PURGE_OLD_DAYS");
$default_purge_interval = get_pref(Prefs::PURGE_OLD_DAYS);
if ($default_purge_interval > 0)
$local_purge_intervals[0] .= " " . T_sprintf("(%d days)", $default_purge_interval);
@ -604,7 +599,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('ENABLE_FEED_CATS')) { ?>
<?php if (get_pref(Prefs::ENABLE_FEED_CATS)) { ?>
<fieldset>
<label><?= __('Place in category:') ?></label>
<?= \Controls\select_feeds_cats("cat_id", null, ['disabled' => '1']) ?>
@ -694,7 +689,7 @@ class Pref_Feeds extends Handler_Protected {
$purge_intl = (int) clean($_POST["purge_interval"] ?? 0);
$feed_id = (int) clean($_POST["id"] ?? 0); /* editSave */
$feed_ids = explode(",", clean($_POST["ids"] ?? "")); /* batchEditSave */
$cat_id = (int) clean($_POST["cat_id"]);
$cat_id = (int) clean($_POST["cat_id"] ?? 0);
$auth_login = clean($_POST["auth_login"]);
$auth_pass = clean($_POST["auth_pass"]);
$private = checkbox_to_sql_bool(clean($_POST["private"] ?? ""));
@ -710,7 +705,7 @@ class Pref_Feeds extends Handler_Protected {
$mark_unread_on_update = checkbox_to_sql_bool(
clean($_POST["mark_unread_on_update"] ?? ""));
$feed_language = clean($_POST["feed_language"]);
$feed_language = clean($_POST["feed_language"] ?? "");
if (!$batch) {
@ -720,48 +715,32 @@ class Pref_Feeds extends Handler_Protected {
$reset_basic_info = $orig_feed_url != $feed_url; */
$sth = $this->pdo->prepare("UPDATE ttrss_feeds SET
cat_id = :cat_id,
title = :title,
feed_url = :feed_url,
site_url = :site_url,
update_interval = :upd_intl,
purge_interval = :purge_intl,
auth_login = :auth_login,
auth_pass = :auth_pass,
auth_pass_encrypted = false,
private = :private,
cache_images = :cache_images,
hide_images = :hide_images,
include_in_digest = :include_in_digest,
always_display_enclosures = :always_display_enclosures,
mark_unread_on_update = :mark_unread_on_update,
feed_language = :feed_language
WHERE id = :id AND owner_uid = :uid");
$sth->execute([":title" => $feed_title,
":cat_id" => $cat_id ? $cat_id : null,
":feed_url" => $feed_url,
":site_url" => $site_url,
":upd_intl" => $upd_intl,
":purge_intl" => $purge_intl,
":auth_login" => $auth_login,
":auth_pass" => $auth_pass,
":private" => (int)$private,
":cache_images" => (int)$cache_images,
":hide_images" => (int)$hide_images,
":include_in_digest" => (int)$include_in_digest,
":always_display_enclosures" => (int)$always_display_enclosures,
":mark_unread_on_update" => (int)$mark_unread_on_update,
":feed_language" => $feed_language,
":id" => $feed_id,
":uid" => $_SESSION['uid']]);
/* if ($reset_basic_info) {
RSSUtils::set_basic_feed_info($feed_id);
} */
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->find_one($feed_id);
if ($feed) {
$feed->title = $feed_title;
$feed->cat_id = $cat_id ? $cat_id : null;
$feed->feed_url = $feed_url;
$feed->site_url = $site_url;
$feed->update_interval = $upd_intl;
$feed->purge_interval = $purge_intl;
$feed->auth_login = $auth_login;
$feed->auth_pass = $auth_pass;
$feed->private = (int)$private;
$feed->cache_images = (int)$cache_images;
$feed->hide_images = (int)$hide_images;
$feed->feed_language = $feed_language;
$feed->include_in_digest = (int)$include_in_digest;
$feed->always_display_enclosures = (int)$always_display_enclosures;
$feed->mark_unread_on_update = (int)$mark_unread_on_update;
$feed->save();
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_SAVE_FEED, $feed_id);
}
} else {
$feed_data = array();
@ -830,7 +809,7 @@ class Pref_Feeds extends Handler_Protected {
break;
case "cat_id":
if (get_pref('ENABLE_FEED_CATS')) {
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
if ($cat_id) {
$qpart = "cat_id = " . $this->pdo->quote($cat_id);
} else {
@ -874,14 +853,14 @@ class Pref_Feeds extends Handler_Protected {
function removeCat() {
$ids = explode(",", clean($_REQUEST["ids"]));
foreach ($ids as $id) {
$this->remove_feed_category($id, $_SESSION["uid"]);
Feeds::_remove_cat((int)$id, $_SESSION["uid"]);
}
}
function addCat() {
$feed_cat = clean($_REQUEST["cat"]);
Feeds::_add_cat($feed_cat);
Feeds::_add_cat($feed_cat, $_SESSION['uid']);
}
function importOpml() {
@ -946,7 +925,7 @@ class Pref_Feeds extends Handler_Protected {
</div>
</div>
<?php if (get_pref('ENABLE_FEED_CATS')) { ?>
<?php if (get_pref(Prefs::ENABLE_FEED_CATS)) { ?>
<div dojoType="fox.form.DropDownButton">
<span><?= __('Categories') ?></span>
<div dojoType="dijit.Menu" style="display: none">
@ -1003,10 +982,6 @@ class Pref_Feeds extends Handler_Protected {
private function index_opml() {
?>
<h3><?= __("Using OPML you can export and import your feeds, filters, labels and Tiny Tiny RSS settings.") ?></h3>
<?php print_notice("Only main settings profile can be migrated using OPML.") ?>
<form id='opml_import_form' method='post' enctype='multipart/form-data'>
<label class='dijitButton'><?= __("Choose file...") ?>
<input style='display : none' id='opml_file' name='opml_file' type='file'>
@ -1015,36 +990,27 @@ class Pref_Feeds extends Handler_Protected {
<input type='hidden' name='csrf_token' value="<?= $_SESSION['csrf_token'] ?>">
<input type='hidden' name='method' value='importOpml'>
<button dojoType='dijit.form.Button' class='alt-primary' onclick="return Helpers.OPML.import()" type="submit">
<?= \Controls\icon("file_upload") ?>
<?= __('Import OPML') ?>
</button>
</form>
<hr/>
<?php print_notice("Only main settings profile can be migrated using OPML.") ?>
<form dojoType='dijit.form.Form' id='opmlExportForm' style='display : inline-block'>
<button dojoType='dijit.form.Button' onclick='Helpers.OPML.export()'>
<?= \Controls\icon("file_download") ?>
<?= __('Export OPML') ?>
</button>
<label class='checkbox'>
<?= \Controls\checkbox_tag("include_settings", true, "1") ?>
<?= __("Include settings") ?>
<?= __("Include tt-rss settings") ?>
</label>
</form>
<hr/>
<h2><?= __("Published OPML") ?></h2>
<p>
<?= __('Your OPML can be published publicly and can be subscribed by anyone who knows the URL below.') ?>
<?= __("Published OPML does not include your Tiny Tiny RSS settings, feeds that require authentication or feeds hidden from Popular feeds.") ?>
</p>
<button dojoType='dijit.form.Button' class='alt-primary' onclick="return Helpers.OPML.publish()">
<?= __('Display published OPML URL') ?>
</button>
<?php
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefFeedsOPML");
}
@ -1052,14 +1018,16 @@ class Pref_Feeds extends Handler_Protected {
private function index_shared() {
?>
<h3><?= __('Published articles can be subscribed by anyone who knows the following URL:') ?></h3>
<?= format_notice('Published articles can be subscribed by anyone who knows the following URL:') ?></h3>
<button dojoType='dijit.form.Button' class='alt-primary'
onclick="CommonDialogs.generatedFeed(-2, false)">
<?= \Controls\icon('share') ?>
<?= __('Display URL') ?>
</button>
<button class='alt-danger' dojoType='dijit.form.Button' onclick='return Helpers.Feeds.clearFeedAccessKeys()'>
<?= \Controls\icon('delete') ?>
<?= __('Clear all generated URLs') ?>
</button>
@ -1151,47 +1119,38 @@ class Pref_Feeds extends Handler_Protected {
$interval_qpart = "DATE_SUB(NOW(), INTERVAL 3 MONTH)";
}
$sth = $this->pdo->prepare("SELECT ttrss_feeds.title, ttrss_feeds.site_url,
ttrss_feeds.feed_url, ttrss_feeds.id, MAX(updated) AS last_article
FROM ttrss_feeds, ttrss_entries, ttrss_user_entries WHERE
(SELECT MAX(updated) FROM ttrss_entries, ttrss_user_entries WHERE
ttrss_entries.id = ref_id AND
ttrss_user_entries.feed_id = ttrss_feeds.id) < $interval_qpart
AND ttrss_feeds.owner_uid = ? AND
ttrss_user_entries.feed_id = ttrss_feeds.id AND
ttrss_entries.id = ref_id
GROUP BY ttrss_feeds.title, ttrss_feeds.id, ttrss_feeds.site_url, ttrss_feeds.feed_url
ORDER BY last_article");
$sth->execute([$_SESSION['uid']]);
$inactive_feeds = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('f.id', 'f.title', 'f.site_url', 'f.feed_url')
->select_expr('MAX(e.updated)', 'last_article')
->join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->join('ttrss_entries', ['e.id', '=', 'ue.ref_id'], 'e')
->where('f.owner_uid', $_SESSION['uid'])
->where_raw(
"(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")
->group_by('f.title')
->group_by('f.id')
->group_by('f.site_url')
->group_by('f.feed_url')
->order_by_asc('last_article')
->find_array();
$rv = [];
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$row['last_article'] = TimeHelper::make_local_datetime($row['last_article'], false);
array_push($rv, $row);
foreach ($inactive_feeds as $inactive_feed) {
$inactive_feed['last_article'] = TimeHelper::make_local_datetime($inactive_feed['last_article'], false);
}
print json_encode($rv);
print json_encode($inactive_feeds);
}
function feedsWithErrors() {
$sth = $this->pdo->prepare("SELECT id,title,feed_url,last_error,site_url
FROM ttrss_feeds WHERE last_error != '' AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$rv = [];
while ($row = $sth->fetch()) {
array_push($rv, $row);
}
print json_encode($rv);
}
private function remove_feed_category($id, $owner_uid) {
$sth = $this->pdo->prepare("DELETE FROM ttrss_feed_categories
WHERE id = ? AND owner_uid = ?");
$sth->execute([$id, $owner_uid]);
print json_encode(ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'feed_url', 'last_error', 'site_url')
->where_not_equal('last_error', '')
->where('owner_uid', $_SESSION['uid'])
->find_array());
}
static function remove_feed($id, $owner_uid) {
@ -1237,7 +1196,7 @@ class Pref_Feeds extends Handler_Protected {
function batchSubscribe() {
print json_encode([
"enable_cats" => (int)get_pref('ENABLE_FEED_CATS'),
"enable_cats" => (int)get_pref(Prefs::ENABLE_FEED_CATS),
"cat_select" => \Controls\select_feeds_cats("cat")
]);
}
@ -1273,32 +1232,25 @@ class Pref_Feeds extends Handler_Protected {
}
}
function getOPMLKey() {
print json_encode(["link" => OPML::get_publish_url()]);
}
function regenOPMLKey() {
$this->update_feed_access_key('OPML:Publish',
false, $_SESSION["uid"]);
print json_encode(["link" => OPML::get_publish_url()]);
function clearKeys() {
return Feeds::_clear_access_keys($_SESSION['uid']);
}
function regenFeedKey() {
$feed_id = clean($_REQUEST['id']);
$is_cat = clean($_REQUEST['is_cat']);
$new_key = $this->update_feed_access_key($feed_id, $is_cat, $_SESSION["uid"]);
$new_key = Feeds::_update_access_key($feed_id, $is_cat, $_SESSION["uid"]);
print json_encode(["link" => $new_key]);
}
function getsharedurl() {
function getSharedURL() {
$feed_id = clean($_REQUEST['id']);
$is_cat = clean($_REQUEST['is_cat']) == "true";
$search = clean($_REQUEST['search']);
$link = get_self_url_prefix() . "/public.php?" . http_build_query([
$link = Config::get_self_url() . "/public.php?" . http_build_query([
'op' => 'rss',
'id' => $feed_id,
'is_cat' => (int)$is_cat,
@ -1312,28 +1264,11 @@ class Pref_Feeds extends Handler_Protected {
]);
}
private function update_feed_access_key($feed_id, $is_cat, $owner_uid) {
// clear old value and generate new one
$sth = $this->pdo->prepare("DELETE FROM ttrss_access_keys
WHERE feed_id = ? AND is_cat = ? AND owner_uid = ?");
$sth->execute([$feed_id, bool_to_sql_bool($is_cat), $owner_uid]);
return Feeds::_get_access_key($feed_id, $is_cat, $owner_uid);
}
// Silent
function clearKeys() {
$sth = $this->pdo->prepare("DELETE FROM ttrss_access_keys WHERE
owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
}
private function calculate_children_count($cat) {
$c = 0;
foreach ($cat['items'] ?? [] as $child) {
if ($child['type'] ?? '' == 'category') {
if (($child['type'] ?? '') == 'category') {
$c += $this->calculate_children_count($child);
} else {
$c += 1;

@ -51,8 +51,8 @@ class Pref_Filters extends Handler_Protected {
$filter = array();
$filter["enabled"] = true;
$filter["match_any_rule"] = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"]));
$filter["inverse"] = checkbox_to_sql_bool(clean($_REQUEST["inverse"]));
$filter["match_any_rule"] = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"] ?? false));
$filter["inverse"] = checkbox_to_sql_bool(clean($_REQUEST["inverse"] ?? false));
$filter["rules"] = array();
$filter["actions"] = array("dummy-action");
@ -203,7 +203,7 @@ class Pref_Filters extends Handler_Protected {
} else {
$where = $line["cat_filter"] ?
Feeds::_get_cat_title($line["cat_id"]) :
Feeds::_get_cat_title($line["cat_id"] ?? 0) :
($line["feed_id"] ?
Feeds::_get_title($line["feed_id"]) : __("All feeds"));
}
@ -848,7 +848,7 @@ class Pref_Filters extends Handler_Protected {
}
}
if (get_pref('ENABLE_FEED_CATS')) {
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
if (!$root_id) $root_id = null;

@ -8,14 +8,12 @@ class Pref_Labels extends Handler_Protected {
}
function edit() {
$label_id = clean($_REQUEST['id']);
$label = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->find_one($_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color FROM ttrss_labels2 WHERE
id = ? AND owner_uid = ?");
$sth->execute([$label_id, $_SESSION['uid']]);
if ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
print json_encode($line);
if ($label) {
print json_encode($label->as_array());
}
}
@ -162,7 +160,7 @@ class Pref_Labels extends Handler_Protected {
function add() {
$caption = clean($_REQUEST["caption"]);
$output = clean($_REQUEST["output"]);
$output = clean($_REQUEST["output"] ?? false);
if ($caption) {
if (Labels::create($caption)) {

File diff suppressed because it is too large Load Diff

@ -14,6 +14,20 @@ class Pref_System extends Handler_Administrative {
$this->pdo->query("DELETE FROM ttrss_error_log");
}
function sendTestEmail() {
$mail_address = clean($_REQUEST["mail_address"]);
$mailer = new Mailer();
$rc = $mailer->mail(["to_name" => "",
"to_address" => $mail_address,
"subject" => __("Test message from tt-rss"),
"message" => ("This message confirms that tt-rss can send outgoing mail.")
]);
print json_encode(['rc' => $rc, 'error' => $mailer->error()]);
}
function getphpinfo() {
ob_start();
phpinfo();
@ -28,10 +42,10 @@ class Pref_System extends Handler_Administrative {
switch ($severity) {
case E_USER_ERROR:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE ];
$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_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
break;
}
@ -103,12 +117,12 @@ class Pref_System extends Handler_Administrative {
<table width='100%' class='event-log'>
<tr class='title'>
<td width='5%'><?= __("Error") ?></td>
<td><?= __("Filename") ?></td>
<td><?= __("Message") ?></td>
<td width='5%'><?= __("User") ?></td>
<td width='5%'><?= __("Date") ?></td>
<tr>
<th width='5%'><?= __("Error") ?></th>
<th><?= __("Filename") ?></th>
<th><?= __("Message") ?></th>
<th width='5%'><?= __("User") ?></th>
<th width='5%'><?= __("Date") ?></th>
</tr>
<?php
@ -129,7 +143,7 @@ class Pref_System extends Handler_Administrative {
?>
<tr>
<td class='errno'>
<?= Logger::$errornames[$line["errno"]] . " (" . $line["errno"] . ")" ?>
<?= Logger::ERROR_NAMES[$line["errno"]] . " (" . $line["errno"] . ")" ?>
</td>
<td class='filename'><?= $line["filename"] . ":" . $line["lineno"] ?></td>
<td class='errstr'><?= $line["errstr"] . "\n" . $line["context"] ?></td>
@ -151,16 +165,48 @@ class Pref_System extends Handler_Administrative {
$page = (int) ($_REQUEST["page"] ?? 0);
?>
<div dojoType='dijit.layout.AccordionContainer' region='center'>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event Log') ?>'>
<?php if (Config::get(Config::LOG_DESTINATION) == Logger::LOG_DEST_SQL) { ?>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event log') ?>'>
<?php
if (Config::get(Config::LOG_DESTINATION) == "sql") {
$this->_log_viewer($page, $severity);
} else {
print_notice("Please set Config::get(Config::LOG_DESTINATION) to 'sql' in config.php to enable database logging.");
}
?>
</div>
<?php } ?>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">mail</i> <?= __('Mail configuration') ?>'>
<div dojoType="dijit.layout.ContentPane">
<form dojoType="dijit.form.Form">
<script type="dojo/method" event="onSubmit" args="evt">
evt.preventDefault();
if (this.validate()) {
xhr.json("backend.php", this.getValues(), (reply) => {
const msg = App.byId("mail-test-result");
if (reply.rc) {
msg.innerHTML = __("Mail sent.");
msg.className = 'alert alert-success';
} else {
msg.innerHTML = reply.error;
msg.className = 'alert alert-danger';
}
msg.show();
})
}
</script>
<?= \Controls\hidden_tag("op", "pref-system") ?>
<?= \Controls\hidden_tag("method", "sendTestEmail") ?>
<fieldset>
<label><?= __("To:") ?></label>
<?= \Controls\input_tag("mail_address", "", "text", ['required' => 1]) ?>
<?= \Controls\submit_tag(__("Send test email")) ?>
<span style="display: none; margin-left : 10px" class="alert alert-error" id="mail-test-result">...</span>
</fieldset>
</form>
</div>
</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'))

@ -1,22 +1,20 @@
<?php
class Pref_Users extends Handler_Administrative {
function csrf_ignore($method) {
$csrf_ignored = array("index");
return array_search($method, $csrf_ignored) !== false;
return $method == "index";
}
function edit() {
global $access_level_names;
$id = (int)clean($_REQUEST["id"]);
$user = ORM::for_table('ttrss_users')
->select_expr("id,login,access_level,email,full_name,otp_enabled")
->find_one((int)$_REQUEST["id"])
->as_array();
$sth = $this->pdo->prepare("SELECT id, login, access_level, email FROM ttrss_users WHERE id = ?");
$sth->execute([$id]);
global $access_level_names;
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
if ($user) {
print json_encode([
"user" => $row,
"user" => $user,
"access_level_names" => $access_level_names
]);
}
@ -106,31 +104,32 @@ class Pref_Users extends Handler_Administrative {
}
function editSave() {
$login = clean($_REQUEST["login"]);
$uid = clean($_REQUEST["id"]);
$access_level = (int) clean($_REQUEST["access_level"]);
$email = clean($_REQUEST["email"]);
$id = (int)$_REQUEST['id'];
$password = clean($_REQUEST["password"]);
$user = ORM::for_table('ttrss_users')->find_one($id);
// no blank usernames
if ($user) {
$login = clean($_REQUEST["login"]);
if ($id == 1) $login = "admin";
if (!$login) return;
// forbid renaming admin
if ($uid == 1) $login = "admin";
$user->login = mb_strtolower($login);
$user->access_level = (int) clean($_REQUEST["access_level"]);
$user->email = clean($_REQUEST["email"]);
$user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"] ?? "");
if ($password) {
$salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$pwd_hash = encrypt_password($password, $salt, true);
$pass_query_part = "pwd_hash = ".$this->pdo->quote($pwd_hash).",
salt = ".$this->pdo->quote($salt).",";
} else {
$pass_query_part = "";
// force new OTP secret when next enabled
if (Config::get_schema_version() >= 143 && !$user->otp_enabled) {
$user->otp_secret = null;
}
$sth = $this->pdo->prepare("UPDATE ttrss_users SET $pass_query_part login = LOWER(?),
access_level = ?, email = ?, otp_enabled = false WHERE id = ?");
$sth->execute([$login, $access_level, $email, $uid]);
$user->save();
}
if ($password) {
UserHelper::reset_password($id, false, $password);
}
}
function remove() {
@ -152,24 +151,25 @@ class Pref_Users extends Handler_Administrative {
function add() {
$login = clean($_REQUEST["login"]);
$tmp_user_pwd = make_password();
$salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$pwd_hash = encrypt_password($tmp_user_pwd, $salt, true);
if (!$login) return; // no blank usernames
if (!UserHelper::find_user_by_login($login)) {
$sth = $this->pdo->prepare("INSERT INTO ttrss_users
(login,pwd_hash,access_level,last_login,created, salt)
VALUES (LOWER(?), ?, 0, null, NOW(), ?)");
$sth->execute([$login, $pwd_hash, $salt]);
$new_password = make_password();
if ($new_uid = UserHelper::find_user_by_login($login)) {
$user = ORM::for_table('ttrss_users')->create();
print T_sprintf("Added user %s with password %s",
$login, $tmp_user_pwd);
$user->salt = UserHelper::get_salt();
$user->login = mb_strtolower($login);
$user->pwd_hash = UserHelper::hash_password($new_password, $user->salt);
$user->access_level = 0;
$user->created = Db::NOW();
$user->save();
if ($new_uid = UserHelper::find_user_by_login($login)) {
print T_sprintf("Added user %s with password %s",
$login, $new_password);
} else {
print T_sprintf("Could not create user %s", $login);
}
@ -200,11 +200,10 @@ class Pref_Users extends Handler_Administrative {
$sort = "login";
}
$sort = $this->_validate_field($sort,
["login", "access_level", "created", "num_feeds", "created", "last_login"], "login");
if (!in_array($sort, ["login", "access_level", "created", "num_feeds", "created", "last_login"]))
$sort = "login";
if ($sort != "login") $sort = "$sort DESC";
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
@ -249,42 +248,41 @@ class Pref_Users extends Handler_Administrative {
<table width='100%' class='users-list' id='users-list'>
<tr class='title'>
<td align='center' width='5%'> </td>
<td width='20%'><a href='#' onclick="Users.reload('login')"><?= ('Login') ?></a></td>
<td width='20%'><a href='#' onclick="Users.reload('access_level')"><?= ('Access Level') ?></a></td>
<td width='10%'><a href='#' onclick="Users.reload('num_feeds')"><?= ('Subscribed feeds') ?></a></td>
<td width='20%'><a href='#' onclick="Users.reload('created')"><?= ('Registered') ?></a></td>
<td width='20%'><a href='#' onclick="Users.reload('last_login')"><?= ('Last login') ?></a></td>
<tr>
<th></th>
<th><a href='#' onclick="Users.reload('login')"><?= ('Login') ?></a></th>
<th><a href='#' onclick="Users.reload('access_level')"><?= ('Access Level') ?></a></th>
<th><a href='#' onclick="Users.reload('num_feeds')"><?= ('Subscribed feeds') ?></a></th>
<th><a href='#' onclick="Users.reload('created')"><?= ('Registered') ?></a></th>
<th><a href='#' onclick="Users.reload('last_login')"><?= ('Last login') ?></a></th>
</tr>
<?php
$sth = $this->pdo->prepare("SELECT
tu.id,
login,access_level,email,
".SUBSTRING_FOR_DATE."(last_login,1,16) as last_login,
".SUBSTRING_FOR_DATE."(created,1,16) as created,
(SELECT COUNT(id) FROM ttrss_feeds WHERE owner_uid = tu.id) AS num_feeds
FROM
ttrss_users tu
WHERE
(:search = '' OR login LIKE :search) AND tu.id > 0
ORDER BY $sort");
$sth->execute([":search" => $user_search ? "%$user_search%" : ""]);
while ($row = $sth->fetch()) { ?>
<tr data-row-id='<?= $row["id"] ?>' onclick='Users.edit(<?= $row["id"] ?>)' title="<?= __('Click to edit') ?>">
<td align='center'>
$users = ORM::for_table('ttrss_users')
->table_alias('u')
->left_outer_join("ttrss_feeds", ["owner_uid", "=", "u.id"], 'f')
->select_expr('u.*,COUNT(f.id) AS num_feeds')
->where_like("login", $user_search ? "%$user_search%" : "%")
->order_by_expr($sort)
->group_by_expr('u.id')
->find_many();
foreach ($users as $user) { ?>
<tr data-row-id='<?= $user["id"] ?>' onclick='Users.edit(<?= $user["id"] ?>)' title="<?= __('Click to edit') ?>">
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this); event.stopPropagation();'
dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td><i class='material-icons'>person</i> <?= htmlspecialchars($row["login"]) ?></td>
<td><?= $access_level_names[$row["access_level"]] ?></td>
<td><?= $row["num_feeds"] ?></td>
<td><?= TimeHelper::make_local_datetime($row["created"], false) ?></td>
<td><?= TimeHelper::make_local_datetime($row["last_login"], false) ?></td>
<td width='30%'>
<i class='material-icons'>person</i>
<strong><?= htmlspecialchars($user["login"]) ?></strong>
</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>
</tr>
<?php } ?>
</table>
@ -294,11 +292,4 @@ class Pref_Users extends Handler_Administrative {
<?php
}
private function _validate_field($string, $allowed, $default = "") {
if (in_array($string, $allowed))
return $string;
else
return $default;
}
}

@ -0,0 +1,413 @@
<?php
class Prefs {
// (this is the database-backed version of Config.php)
const PURGE_OLD_DAYS = "PURGE_OLD_DAYS";
const DEFAULT_UPDATE_INTERVAL = "DEFAULT_UPDATE_INTERVAL";
//const DEFAULT_ARTICLE_LIMIT = "DEFAULT_ARTICLE_LIMIT";
//const ALLOW_DUPLICATE_POSTS = "ALLOW_DUPLICATE_POSTS";
const ENABLE_FEED_CATS = "ENABLE_FEED_CATS";
const SHOW_CONTENT_PREVIEW = "SHOW_CONTENT_PREVIEW";
const SHORT_DATE_FORMAT = "SHORT_DATE_FORMAT";
const LONG_DATE_FORMAT = "LONG_DATE_FORMAT";
const COMBINED_DISPLAY_MODE = "COMBINED_DISPLAY_MODE";
const HIDE_READ_FEEDS = "HIDE_READ_FEEDS";
const ON_CATCHUP_SHOW_NEXT_FEED = "ON_CATCHUP_SHOW_NEXT_FEED";
const FEEDS_SORT_BY_UNREAD = "FEEDS_SORT_BY_UNREAD";
const REVERSE_HEADLINES = "REVERSE_HEADLINES";
const DIGEST_ENABLE = "DIGEST_ENABLE";
const CONFIRM_FEED_CATCHUP = "CONFIRM_FEED_CATCHUP";
const CDM_AUTO_CATCHUP = "CDM_AUTO_CATCHUP";
const _DEFAULT_VIEW_MODE = "_DEFAULT_VIEW_MODE";
const _DEFAULT_VIEW_LIMIT = "_DEFAULT_VIEW_LIMIT";
//const _PREFS_ACTIVE_TAB = "_PREFS_ACTIVE_TAB";
//const STRIP_UNSAFE_TAGS = "STRIP_UNSAFE_TAGS";
const BLACKLISTED_TAGS = "BLACKLISTED_TAGS";
const FRESH_ARTICLE_MAX_AGE = "FRESH_ARTICLE_MAX_AGE";
const DIGEST_CATCHUP = "DIGEST_CATCHUP";
const CDM_EXPANDED = "CDM_EXPANDED";
const PURGE_UNREAD_ARTICLES = "PURGE_UNREAD_ARTICLES";
const HIDE_READ_SHOWS_SPECIAL = "HIDE_READ_SHOWS_SPECIAL";
const VFEED_GROUP_BY_FEED = "VFEED_GROUP_BY_FEED";
const STRIP_IMAGES = "STRIP_IMAGES";
const _DEFAULT_VIEW_ORDER_BY = "_DEFAULT_VIEW_ORDER_BY";
const ENABLE_API_ACCESS = "ENABLE_API_ACCESS";
//const _COLLAPSED_SPECIAL = "_COLLAPSED_SPECIAL";
//const _COLLAPSED_LABELS = "_COLLAPSED_LABELS";
//const _COLLAPSED_UNCAT = "_COLLAPSED_UNCAT";
//const _COLLAPSED_FEEDLIST = "_COLLAPSED_FEEDLIST";
//const _MOBILE_ENABLE_CATS = "_MOBILE_ENABLE_CATS";
//const _MOBILE_SHOW_IMAGES = "_MOBILE_SHOW_IMAGES";
//const _MOBILE_HIDE_READ = "_MOBILE_HIDE_READ";
//const _MOBILE_SORT_FEEDS_UNREAD = "_MOBILE_SORT_FEEDS_UNREAD";
//const _MOBILE_BROWSE_CATS = "_MOBILE_BROWSE_CATS";
//const _THEME_ID = "_THEME_ID";
const USER_TIMEZONE = "USER_TIMEZONE";
const USER_STYLESHEET = "USER_STYLESHEET";
//const SORT_HEADLINES_BY_FEED_DATE = "SORT_HEADLINES_BY_FEED_DATE";
const SSL_CERT_SERIAL = "SSL_CERT_SERIAL";
const DIGEST_PREFERRED_TIME = "DIGEST_PREFERRED_TIME";
//const _PREFS_SHOW_EMPTY_CATS = "_PREFS_SHOW_EMPTY_CATS";
const _DEFAULT_INCLUDE_CHILDREN = "_DEFAULT_INCLUDE_CHILDREN";
//const AUTO_ASSIGN_LABELS = "AUTO_ASSIGN_LABELS";
const _ENABLED_PLUGINS = "_ENABLED_PLUGINS";
//const _MOBILE_REVERSE_HEADLINES = "_MOBILE_REVERSE_HEADLINES";
const USER_CSS_THEME = "USER_CSS_THEME";
const USER_LANGUAGE = "USER_LANGUAGE";
const DEFAULT_SEARCH_LANGUAGE = "DEFAULT_SEARCH_LANGUAGE";
const _PREFS_MIGRATED = "_PREFS_MIGRATED";
const HEADLINES_NO_DISTINCT = "HEADLINES_NO_DISTINCT";
const DEBUG_HEADLINE_IDS = "DEBUG_HEADLINE_IDS";
const DISABLE_CONDITIONAL_COUNTERS = "DISABLE_CONDITIONAL_COUNTERS";
const WIDESCREEN_MODE = "WIDESCREEN_MODE";
const CDM_ENABLE_GRID = "CDM_ENABLE_GRID";
private const _DEFAULTS = [
Prefs::PURGE_OLD_DAYS => [ 60, Config::T_INT ],
Prefs::DEFAULT_UPDATE_INTERVAL => [ 30, Config::T_INT ],
//Prefs::DEFAULT_ARTICLE_LIMIT => [ 30, Config::T_INT ],
//Prefs::ALLOW_DUPLICATE_POSTS => [ false, Config::T_BOOL ],
Prefs::ENABLE_FEED_CATS => [ true, Config::T_BOOL ],
Prefs::SHOW_CONTENT_PREVIEW => [ true, Config::T_BOOL ],
Prefs::SHORT_DATE_FORMAT => [ "M d, G:i", Config::T_STRING ],
Prefs::LONG_DATE_FORMAT => [ "D, M d Y - G:i", Config::T_STRING ],
Prefs::COMBINED_DISPLAY_MODE => [ true, Config::T_BOOL ],
Prefs::HIDE_READ_FEEDS => [ false, Config::T_BOOL ],
Prefs::ON_CATCHUP_SHOW_NEXT_FEED => [ false, Config::T_BOOL ],
Prefs::FEEDS_SORT_BY_UNREAD => [ false, Config::T_BOOL ],
Prefs::REVERSE_HEADLINES => [ false, Config::T_BOOL ],
Prefs::DIGEST_ENABLE => [ false, Config::T_BOOL ],
Prefs::CONFIRM_FEED_CATCHUP => [ true, Config::T_BOOL ],
Prefs::CDM_AUTO_CATCHUP => [ false, Config::T_BOOL ],
Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_STRING ],
Prefs::_DEFAULT_VIEW_LIMIT => [ 30, Config::T_INT ],
//Prefs::_PREFS_ACTIVE_TAB => [ "", Config::T_STRING ],
//Prefs::STRIP_UNSAFE_TAGS => [ true, Config::T_BOOL ],
Prefs::BLACKLISTED_TAGS => [ 'main, generic, misc, uncategorized, blog, blogroll, general, news', Config::T_STRING ],
Prefs::FRESH_ARTICLE_MAX_AGE => [ 24, Config::T_INT ],
Prefs::DIGEST_CATCHUP => [ false, Config::T_BOOL ],
Prefs::CDM_EXPANDED => [ true, Config::T_BOOL ],
Prefs::PURGE_UNREAD_ARTICLES => [ true, Config::T_BOOL ],
Prefs::HIDE_READ_SHOWS_SPECIAL => [ true, Config::T_BOOL ],
Prefs::VFEED_GROUP_BY_FEED => [ false, Config::T_BOOL ],
Prefs::STRIP_IMAGES => [ false, Config::T_BOOL ],
Prefs::_DEFAULT_VIEW_ORDER_BY => [ "default", Config::T_STRING ],
Prefs::ENABLE_API_ACCESS => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_SPECIAL => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_LABELS => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_UNCAT => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_FEEDLIST => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_ENABLE_CATS => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_SHOW_IMAGES => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_HIDE_READ => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_SORT_FEEDS_UNREAD => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_BROWSE_CATS => [ true, Config::T_BOOL ],
//Prefs::_THEME_ID => [ 0, Config::T_BOOL ],
Prefs::USER_TIMEZONE => [ "Automatic", Config::T_STRING ],
Prefs::USER_STYLESHEET => [ "", Config::T_STRING ],
//Prefs::SORT_HEADLINES_BY_FEED_DATE => [ false, Config::T_BOOL ],
Prefs::SSL_CERT_SERIAL => [ "", Config::T_STRING ],
Prefs::DIGEST_PREFERRED_TIME => [ "00:00", Config::T_STRING ],
//Prefs::_PREFS_SHOW_EMPTY_CATS => [ false, Config::T_BOOL ],
Prefs::_DEFAULT_INCLUDE_CHILDREN => [ false, Config::T_BOOL ],
//Prefs::AUTO_ASSIGN_LABELS => [ false, Config::T_BOOL ],
Prefs::_ENABLED_PLUGINS => [ "", Config::T_STRING ],
//Prefs::_MOBILE_REVERSE_HEADLINES => [ false, Config::T_BOOL ],
Prefs::USER_CSS_THEME => [ "" , Config::T_STRING ],
Prefs::USER_LANGUAGE => [ "" , Config::T_STRING ],
Prefs::DEFAULT_SEARCH_LANGUAGE => [ "" , Config::T_STRING ],
Prefs::_PREFS_MIGRATED => [ false, Config::T_BOOL ],
Prefs::HEADLINES_NO_DISTINCT => [ false, Config::T_BOOL ],
Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ],
Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ],
Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ],
Prefs::CDM_ENABLE_GRID => [ false, Config::T_BOOL ],
];
const _PROFILE_BLACKLIST = [
//Prefs::ALLOW_DUPLICATE_POSTS,
Prefs::PURGE_OLD_DAYS,
Prefs::PURGE_UNREAD_ARTICLES,
Prefs::DIGEST_ENABLE,
Prefs::DIGEST_CATCHUP,
Prefs::BLACKLISTED_TAGS,
Prefs::ENABLE_API_ACCESS,
//Prefs::UPDATE_POST_ON_CHECKSUM_CHANGE,
Prefs::DEFAULT_UPDATE_INTERVAL,
Prefs::USER_TIMEZONE,
//Prefs::SORT_HEADLINES_BY_FEED_DATE,
Prefs::SSL_CERT_SERIAL,
Prefs::DIGEST_PREFERRED_TIME,
Prefs::_PREFS_MIGRATED
];
private static $instance;
private $cache = [];
/** @var PDO */
private $pdo;
public static function get_instance() : Prefs {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
static function is_valid(string $pref_name) {
return isset(self::_DEFAULTS[$pref_name]);
}
static function get_default(string $pref_name) {
if (self::is_valid($pref_name))
return self::_DEFAULTS[$pref_name][0];
else
return null;
}
function __construct() {
$this->pdo = Db::pdo();
if (!empty($_SESSION["uid"])) {
$owner_uid = (int) $_SESSION["uid"];
$profile_id = $_SESSION["profile"] ?? null;
$this->cache_all($owner_uid, $profile_id);
$this->migrate($owner_uid, $profile_id);
};
}
private function __clone() {
//
}
static function get_all(int $owner_uid, int $profile_id = null) {
return self::get_instance()->_get_all($owner_uid, $profile_id);
}
private function _get_all(int $owner_uid, int $profile_id = null) {
$rv = [];
$ref = new ReflectionClass(get_class($this));
foreach ($ref->getConstants() as $const => $cvalue) {
if (isset($this::_DEFAULTS[$const])) {
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
array_push($rv, [
"pref_name" => $const,
"value" => $this->_get($const, $owner_uid, $profile_id),
"type_hint" => $type_hint,
]);
}
}
return $rv;
}
private function cache_all(int $owner_uid, $profile_id = null) {
if (!$profile_id) $profile_id = null;
// fill cache with defaults
$ref = new ReflectionClass(get_class($this));
foreach ($ref->getConstants() as $const => $cvalue) {
if (isset($this::_DEFAULTS[$const])) {
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
$this->_set_cache($const, $def_val, $owner_uid, $profile_id);
}
}
if (get_schema_version() >= 141) {
// fill in any overrides from the database
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2
WHERE owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
while ($row = $sth->fetch()) {
$this->_set_cache($row["pref_name"], $row["value"], $owner_uid, $profile_id);
}
}
}
static function get(string $pref_name, int $owner_uid, int $profile_id = null) {
return self::get_instance()->_get($pref_name, $owner_uid, $profile_id);
}
private function _get(string $pref_name, int $owner_uid, int $profile_id = null) {
if (isset(self::_DEFAULTS[$pref_name])) {
if (!$profile_id || in_array($pref_name, self::_PROFILE_BLACKLIST)) $profile_id = null;
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
$cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id);
if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) {
$cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id);
return Config::cast_to($cached_value, $type_hint);
} else if (get_schema_version() >= 141) {
$sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2
WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$this->_set_cache($pref_name, $row["value"], $owner_uid, $profile_id);
return Config::cast_to($row["value"], $type_hint);
} else {
$this->_set_cache($pref_name, $def_val, $owner_uid, $profile_id);
return $def_val;
}
} else {
return Config::cast_to($def_val, $type_hint);
}
} else {
user_error("Attempt to get invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
}
return null;
}
private function _is_cached(string $pref_name, int $owner_uid, int $profile_id = null) {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
return isset($this->cache[$cache_key]);
}
private function _get_cache(string $pref_name, int $owner_uid, int $profile_id = null) {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
if (isset($this->cache[$cache_key]))
return $this->cache[$cache_key];
return null;
}
private function _set_cache(string $pref_name, $value, int $owner_uid, int $profile_id = null) {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
$this->cache[$cache_key] = $value;
}
static function set(string $pref_name, $value, int $owner_uid, int $profile_id = null, bool $strip_tags = true) {
return self::get_instance()->_set($pref_name, $value, $owner_uid, $profile_id);
}
private function _delete(string $pref_name, int $owner_uid, int $profile_id = null) {
$sth = $this->pdo->prepare("DELETE FROM ttrss_user_prefs2
WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
}
private function _set(string $pref_name, $value, int $owner_uid, int $profile_id = null, bool $strip_tags = true) {
if (!$profile_id) $profile_id = null;
if ($profile_id && in_array($pref_name, self::_PROFILE_BLACKLIST))
return false;
if (isset(self::_DEFAULTS[$pref_name])) {
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
if ($strip_tags)
$value = trim(strip_tags($value));
$value = Config::cast_to($value, $type_hint);
// is this a good idea or not? probably not (user-set value remains user-set even if its at default)
//if ($value == $def_val)
// return $this->_delete($pref_name, $owner_uid, $profile_id);
if ($value == $this->_get($pref_name, $owner_uid, $profile_id))
return false;
$this->_set_cache($pref_name, $value, $owner_uid, $profile_id);
$sth = $this->pdo->prepare("SELECT COUNT(pref_name) AS count FROM ttrss_user_prefs2
WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
if ($row = $sth->fetch()) {
if ($row["count"] == 0) {
$sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs2
(pref_name, value, owner_uid, profile)
VALUES
(:name, :value, :uid, :profile)");
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
} else {
$sth = $this->pdo->prepare("UPDATE ttrss_user_prefs2
SET value = :value
WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
}
}
} else {
user_error("Attempt to set invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
}
return false;
}
function migrate(int $owner_uid, int $profile_id = null) {
if (get_schema_version() < 141)
return;
if (!$profile_id) $profile_id = null;
if (!$this->_get(Prefs::_PREFS_MIGRATED, $owner_uid, $profile_id)) {
$in_nested_tr = false;
try {
$this->pdo->beginTransaction();
} catch (PDOException $e) {
$in_nested_tr = true;
}
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs
WHERE owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
if (isset(self::_DEFAULTS[$row["pref_name"]])) {
list ($def_val, $type_hint) = self::_DEFAULTS[$row["pref_name"]];
$user_val = Config::cast_to($row["value"], $type_hint);
if ($user_val != $def_val) {
$this->_set($row["pref_name"], $user_val, $owner_uid, $profile_id);
}
}
}
$this->_set(Prefs::_PREFS_MIGRATED, "1", $owner_uid, $profile_id);
if (!$in_nested_tr)
$this->pdo->commit();
Logger::log(E_USER_NOTICE, sprintf("Migrated preferences of user %d (profile %d)", $owner_uid, $profile_id));
}
}
static function reset(int $owner_uid, int $profile_id = null) {
if (!$profile_id) $profile_id = null;
$sth = Db::pdo()->prepare("DELETE FROM ttrss_user_prefs2
WHERE owner_uid = :uid AND pref_name != :mig_key AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "mig_key" => self::_PREFS_MIGRATED, "profile" => $profile_id]);
}
}

@ -7,6 +7,36 @@ class RPC extends Handler_Protected {
return array_search($method, $csrf_ignored) !== false;
}*/
private function _translations_as_array() {
global $text_domains;
$rv = [];
foreach (array_keys($text_domains) as $domain) {
/** @var gettext_reader $l10n */
$l10n = _get_reader($domain);
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
$key = explode(chr(0), $orig);
$rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular
$rv[$key[1]] = _ngettext($key[0], $key[1], 2); // Plural
} else {
$translation = _dgettext($domain,$orig);
$rv[$orig] = $translation;
}
}
}
}
return $rv;
}
function togglepref() {
$key = clean($_REQUEST["key"]);
set_pref($key, !get_pref($key));
@ -20,7 +50,7 @@ class RPC extends Handler_Protected {
$key = clean($_REQUEST['key']);
$value = $_REQUEST['value'];
set_pref($key, $value, false, $key != 'USER_STYLESHEET');
set_pref($key, $value, $_SESSION["uid"], $key != 'USER_STYLESHEET');
print json_encode(array("param" =>$key, "value" => $value));
}
@ -66,7 +96,7 @@ class RPC extends Handler_Protected {
function getRuntimeInfo() {
$reply = [
'runtime-info' => $this->make_runtime_info()
'runtime-info' => $this->_make_runtime_info()
];
print json_encode($reply);
@ -91,8 +121,8 @@ class RPC extends Handler_Protected {
else
$label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? []));
// @phpstan-ignore-next-line
$counters = is_array($feed_ids) ? Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
$counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ?
Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
$reply = [
'counters' => $counters,
@ -122,7 +152,9 @@ class RPC extends Handler_Protected {
if (count($ids) > 0)
$this->markArticlesById($ids, $cmode);
print json_encode(["message" => "UPDATE_COUNTERS", "feeds" => Article::_feeds_of($ids)]);
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function publishSelected() {
@ -132,28 +164,42 @@ class RPC extends Handler_Protected {
if (count($ids) > 0)
$this->publishArticlesById($ids, $cmode);
print json_encode(["message" => "UPDATE_COUNTERS", "feeds" => Article::_feeds_of($ids)]);
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function sanityCheck() {
$_SESSION["hasSandbox"] = clean($_REQUEST["hasSandbox"]) === "true";
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
$client_location = $_REQUEST["clientLocation"];
$error = Errors::E_SUCCESS;
$error_params = [];
$client_scheme = parse_url($client_location, PHP_URL_SCHEME);
$server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME);
if (get_schema_version(true) != SCHEMA_VERSION) {
if (Config::is_migration_needed()) {
$error = Errors::E_SCHEMA_MISMATCH;
} else if ($client_scheme != $server_scheme) {
$error = Errors::E_URL_SCHEME_MISMATCH;
$error_params["client_scheme"] = $client_scheme;
$error_params["server_scheme"] = $server_scheme;
$error_params["self_url_path"] = Config::get_self_url();
}
if ($error == Errors::E_SUCCESS) {
$reply = [];
$reply['init-params'] = $this->make_init_params();
$reply['runtime-info'] = $this->make_runtime_info();
$reply['init-params'] = $this->_make_init_params();
$reply['runtime-info'] = $this->_make_runtime_info();
$reply['translations'] = $this->_translations_as_array();
print json_encode($reply);
} else {
print Errors::to_json($error);
print Errors::to_json($error, $error_params);
}
}
@ -189,48 +235,52 @@ class RPC extends Handler_Protected {
//print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function setpanelmode() {
function setWidescreen() {
$wide = (int) clean($_REQUEST["wide"]);
// FIXME should this use SESSION_COOKIE_LIFETIME and be renewed periodically?
setcookie("ttrss_widescreen", (string)$wide,
time() + 86400*365);
set_pref(Prefs::WIDESCREEN_MODE, $wide);
print json_encode(array("wide" => $wide));
print json_encode(["wide" => $wide]);
}
static function updaterandomfeed_real() {
$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 ((
ttrss_feeds.update_interval = 0
AND ttrss_feeds.last_updated < NOW() - CAST((ttrss_user_prefs.value || ' minutes') AS INTERVAL)
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 (
ttrss_feeds.update_interval > 0
AND ttrss_feeds.last_updated < NOW() - CAST((ttrss_feeds.update_interval || ' minutes') AS INTERVAL)
update_interval > 0
AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL)
) OR (
ttrss_feeds.update_interval >= 0
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 ((
ttrss_feeds.update_interval = 0
AND ttrss_feeds.last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(ttrss_user_prefs.value, SIGNED INTEGER) MINUTE)
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 (
ttrss_feeds.update_interval > 0
AND ttrss_feeds.last_updated < DATE_SUB(NOW(), INTERVAL ttrss_feeds.update_interval MINUTE)
update_interval > 0
AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE)
) OR (
ttrss_feeds.update_interval >= 0
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 (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < NOW() - INTERVAL '5 minutes')";
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')";
} else {
$updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))";
$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();
@ -238,24 +288,24 @@ class RPC extends Handler_Protected {
$pdo = Db::pdo();
// we could be invoked from public.php with no active session
if ($_SESSION["uid"]) {
$owner_check_qpart = "AND ttrss_feeds.owner_uid = ".$pdo->quote($_SESSION["uid"]);
if (!empty($_SESSION["uid"])) {
$owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]);
} else {
$owner_check_qpart = "";
}
// We search for feed needing update.
$res = $pdo->query("SELECT ttrss_feeds.feed_url,ttrss_feeds.id
$query = "SELECT f.feed_url,f.id
FROM
ttrss_feeds, ttrss_users, ttrss_user_prefs
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
ttrss_feeds.owner_uid = ttrss_users.id
AND ttrss_users.id = ttrss_user_prefs.owner_uid
AND ttrss_user_prefs.pref_name = 'DEFAULT_UPDATE_INTERVAL'
f.owner_uid = u.id
$owner_check_qpart
$update_limit_qpart
$updstart_thresh_qpart
ORDER BY $random_qpart LIMIT 30");
ORDER BY $random_qpart LIMIT 30";
$res = $pdo->query($query);
$num_updated = 0;
@ -332,13 +382,13 @@ class RPC extends Handler_Protected {
}
function log() {
$msg = clean($_REQUEST['msg']);
$file = basename(clean($_REQUEST['file']));
$line = (int) clean($_REQUEST['line']);
$context = clean($_REQUEST['context']);
$msg = clean($_REQUEST['msg'] ?? "");
$file = basename(clean($_REQUEST['file'] ?? ""));
$line = (int) clean($_REQUEST['line'] ?? 0);
$context = clean($_REQUEST['context'] ?? "");
if ($msg) {
Logger::get()->log_error(E_USER_WARNING,
Logger::log_error(E_USER_WARNING,
$msg, 'client-js:' . $file, $line, $context);
echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
@ -346,12 +396,12 @@ class RPC extends Handler_Protected {
}
function checkforupdates() {
$rv = [];
$rv = ["changeset" => [], "plugins" => []];
$git_timestamp = false;
$git_commit = false;
$version = Config::get_version(false);
get_version($git_commit, $git_timestamp);
$git_timestamp = $version["timestamp"] ?? false;
$git_commit = $version["commit"] ?? false;
if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= 10 && $git_timestamp) {
$content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
@ -363,22 +413,25 @@ class RPC extends Handler_Protected {
if ($git_timestamp < (int)$content["changeset"]["timestamp"] &&
$git_commit != $content["changeset"]["id"]) {
$rv = $content["changeset"];
$rv["changeset"] = $content["changeset"];
}
}
}
$rv["plugins"] = Pref_Prefs::_get_updated_plugins();
}
print json_encode($rv);
}
private function make_init_params() {
private function _make_init_params() {
$params = array();
foreach (array("ON_CATCHUP_SHOW_NEXT_FEED", "HIDE_READ_FEEDS",
"ENABLE_FEED_CATS", "FEEDS_SORT_BY_UNREAD", "CONFIRM_FEED_CATCHUP",
"CDM_AUTO_CATCHUP", "FRESH_ARTICLE_MAX_AGE",
"HIDE_READ_SHOWS_SPECIAL", "COMBINED_DISPLAY_MODE") as $param) {
foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS,
Prefs::ENABLE_FEED_CATS, Prefs::FEEDS_SORT_BY_UNREAD,
Prefs::CONFIRM_FEED_CATCHUP, Prefs::CDM_AUTO_CATCHUP,
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);
}
@ -387,14 +440,14 @@ class RPC extends Handler_Protected {
$params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES);
$params["icons_url"] = Config::get(Config::ICONS_URL);
$params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME);
$params["default_view_mode"] = get_pref("_DEFAULT_VIEW_MODE");
$params["default_view_limit"] = (int) get_pref("_DEFAULT_VIEW_LIMIT");
$params["default_view_order_by"] = get_pref("_DEFAULT_VIEW_ORDER_BY");
$params["bw_limit"] = (int) $_SESSION["bw_limit"];
$params["is_default_pw"] = Pref_Prefs::isdefaultpassword();
$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["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( "USER_CSS_THEME", false, false);
$theme = get_pref(Prefs::USER_CSS_THEME);
$params["theme"] = theme_exists($theme) ? $theme : "";
$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
@ -412,13 +465,16 @@ class RPC extends Handler_Protected {
$max_feed_id = $row["mid"];
$num_feeds = $row["nf"];
$params["self_url_prefix"] = get_self_url_prefix();
$params["self_url_prefix"] = Config::get_self_url();
$params["max_feed_id"] = (int) $max_feed_id;
$params["num_feeds"] = (int) $num_feeds;
$params["hotkeys"] = $this->get_hotkeys_map();
$params["widescreen"] = (int) ($_COOKIE["ttrss_widescreen"] ?? 0);
$params["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE);
$params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE);
$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"]);
return $params;
@ -428,13 +484,15 @@ class RPC extends Handler_Protected {
if (file_exists($filename)) {
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if ($ext == "svg") $ext = "svg+xml";
return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename));
} else {
return "";
}
}
static function make_runtime_info() {
static function _make_runtime_info() {
$data = array();
$pdo = Db::pdo();
@ -449,7 +507,7 @@ class RPC extends Handler_Protected {
$data["max_feed_id"] = (int) $max_feed_id;
$data["num_feeds"] = (int) $num_feeds;
$data['cdm_expanded'] = get_pref('CDM_EXPANDED');
$data['cdm_expanded'] = get_pref(Prefs::CDM_EXPANDED);
$data["labels"] = Labels::get_all($_SESSION["uid"]);
if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= 10) {
@ -464,6 +522,7 @@ class RPC extends Handler_Protected {
WHERE
errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
$log_interval 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();
@ -506,7 +565,9 @@ class RPC extends Handler_Protected {
$hotkeys = array(
__("Navigation") => array(
"next_feed" => __("Open next feed"),
"next_unread_feed" => __("Open next unread feed"),
"prev_feed" => __("Open previous feed"),
"prev_unread_feed" => __("Open previous unread feed"),
"next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"),
"prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"),
"next_headlines_page" => __("Scroll headlines by one page down"),
@ -550,6 +611,7 @@ class RPC extends Handler_Protected {
"feed_catchup" => __("Mark as read"),
"feed_reverse" => __("Reverse headlines"),
"feed_toggle_vgroup" => __("Toggle headline grouping"),
"feed_toggle_grid" => __("Toggle grid view"),
"feed_debug_update" => __("Debug feed update"),
"feed_debug_viewfeed" => __("Debug viewfeed()"),
"catchup_all" => __("Mark all feeds as read"),
@ -584,7 +646,9 @@ class RPC extends Handler_Protected {
static function get_hotkeys_map() {
$hotkeys = array(
"k" => "next_feed",
"K" => "next_unread_feed",
"j" => "prev_feed",
"J" => "prev_unread_feed",
"n" => "next_article_noscroll",
"p" => "prev_article_noscroll",
"N" => "article_page_down",
@ -610,6 +674,7 @@ class RPC extends Handler_Protected {
"a e" => "toggle_full_text",
"e" => "email_article",
"a q" => "close_article",
"a s" => "article_span_grid",
"a a" => "select_all",
"a u" => "select_unread",
"a U" => "select_marked",
@ -623,8 +688,9 @@ class RPC extends Handler_Protected {
"f q" => "feed_catchup",
"f x" => "feed_reverse",
"f g" => "feed_toggle_vgroup",
"f G" => "feed_toggle_grid",
"f D" => "feed_debug_update",
"f G" => "feed_debug_viewfeed",
"f %" => "feed_debug_viewfeed",
"f C" => "toggle_combined_mode",
"f c" => "toggle_cdm_expanded",
"Q" => "catchup_all",

File diff suppressed because it is too large Load Diff

@ -49,6 +49,10 @@ class Sanitizer {
return false;
}
private static function is_prefix_https() {
return parse_url(Config::get(Config::SELF_URL_PATH), PHP_URL_SCHEME) == 'https';
}
public static function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) {
if (!$owner && isset($_SESSION["uid"]))
@ -60,15 +64,17 @@ class Sanitizer {
$doc->loadHTML('<?xml encoding="UTF-8">' . $res);
$xpath = new DOMXPath($doc);
$rewrite_base_url = $site_url ? $site_url : get_self_url_prefix();
// is it a good idea to possibly rewrite urls to our own prefix?
// $rewrite_base_url = $site_url ? $site_url : Config::get_self_url();
$rewrite_base_url = $site_url ? $site_url : "http://domain.invalid/";
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src])');
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])');
foreach ($entries as $entry) {
if ($entry->hasAttribute('href')) {
$entry->setAttribute('href',
rewrite_relative_url($rewrite_base_url, $entry->getAttribute('href')));
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('href'), $entry->tagName, "href"));
$entry->setAttribute('rel', 'noopener noreferrer');
$entry->setAttribute("target", "_blank");
@ -76,7 +82,7 @@ class Sanitizer {
if ($entry->hasAttribute('src')) {
$entry->setAttribute('src',
rewrite_relative_url($rewrite_base_url, $entry->getAttribute('src')));
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('src'), $entry->tagName, "src"));
}
if ($entry->nodeName == 'img') {
@ -88,14 +94,19 @@ class Sanitizer {
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
for ($i = 0; $i < count($matches); $i++) {
$matches[$i]["url"] = rewrite_relative_url($rewrite_base_url, $matches[$i]["url"]);
$matches[$i]["url"] = UrlHelper::rewrite_relative($rewrite_base_url, $matches[$i]["url"]);
}
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
}
if ($entry->hasAttribute('poster')) {
$entry->setAttribute('poster',
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('poster'), $entry->tagName, "poster"));
}
if ($entry->hasAttribute('src') &&
($owner && get_pref("STRIP_IMAGES", $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) {
($owner && get_pref(Prefs::STRIP_IMAGES, $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) {
$p = $doc->createElement('p');
@ -125,7 +136,7 @@ class Sanitizer {
if (!self::iframe_whitelisted($entry)) {
$entry->setAttribute('sandbox', 'allow-scripts');
} else {
if (is_prefix_https()) {
if (self::is_prefix_https()) {
$entry->setAttribute("src",
str_replace("http://", "https://",
$entry->getAttribute("src")));

@ -7,16 +7,16 @@ class TimeHelper {
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('SHORT_DATE_FORMAT', $owner_uid);
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
if (strpos((strtolower($format)), "a") === false)
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('SHORT_DATE_FORMAT', $owner_uid);
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
return date($format, $timestamp);
} else {
$format = get_pref('LONG_DATE_FORMAT', $owner_uid);
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
return date($format, $timestamp);
}
}
@ -37,7 +37,7 @@ class TimeHelper {
# We store date in UTC internally
$dt = new DateTime($timestamp, $utc_tz);
$user_tz_string = get_pref('USER_TIMEZONE', $owner_uid);
$user_tz_string = get_pref(Prefs::USER_TIMEZONE, $owner_uid);
if ($user_tz_string != 'Automatic') {
@ -59,9 +59,9 @@ class TimeHelper {
$tz_offset, $owner_uid, $eta_min);
} else {
if ($long)
$format = get_pref('LONG_DATE_FORMAT', $owner_uid);
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
else
$format = get_pref('SHORT_DATE_FORMAT', $owner_uid);
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
return date($format, $user_timestamp);
}

@ -1,5 +1,20 @@
<?php
class UrlHelper {
const EXTRA_HREF_SCHEMES = [
"magnet",
"mailto",
"tel"
];
static $fetch_last_error;
static $fetch_last_error_code;
static $fetch_last_error_content;
static $fetch_last_content_type;
static $fetch_last_modified;
static $fetch_effective_url;
static $fetch_effective_ip_addr;
static $fetch_curl_used;
static function build_url($parts) {
$tmp = $parts['scheme'] . "://" . $parts['host'];
@ -11,34 +26,51 @@ class UrlHelper {
}
/**
* Converts a (possibly) relative URL to a absolute one.
* Converts a (possibly) relative URL to a absolute one, using provided base URL.
* Provides some exceptions for additional schemes like data: if called with owning element/attribute.
*
* @param string $url Base URL (i.e. from where the document is)
* @param string $base_url Base URL (i.e. from where the document is)
* @param string $rel_url Possibly relative URL in the document
* @param string $owner_element Owner element tag name (i.e. "a") (optional)
* @param string $owner_attribute Owner attribute (i.e. "href") (optional)
*
* @return string Absolute URL
*/
public static function rewrite_relative($url, $rel_url) {
public static function rewrite_relative($base_url, $rel_url, string $owner_element = "", string $owner_attribute = "") {
$rel_parts = parse_url($rel_url);
if (!empty($rel_parts['host']) && !empty($rel_parts['scheme'])) {
return self::validate($rel_url);
// protocol-relative URL (rare but they exist)
} else if (strpos($rel_url, "//") === 0) {
# protocol-relative URL (rare but they exist)
return self::validate("https:" . $rel_url);
} else if (strpos($rel_url, "magnet:") === 0) {
# allow magnet links
// allow some extra schemes for A href
} else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) &&
$owner_element == "a" &&
$owner_attribute == "href") {
return $rel_url;
// allow limited subset of inline base64-encoded images for IMG elements
} else if (($rel_parts["scheme"] ?? "") == "data" &&
preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) &&
$owner_element == "img" &&
$owner_attribute == "src") {
return $rel_url;
} else {
$parts = parse_url($url);
$base_parts = parse_url($base_url);
$rel_parts['host'] = $parts['host'];
$rel_parts['scheme'] = $parts['scheme'];
$rel_parts['host'] = $base_parts['host'];
$rel_parts['scheme'] = $base_parts['scheme'];
if (isset($rel_parts['path'])) {
if (strpos($rel_parts['path'], '/') !== 0)
$rel_parts['path'] = '/' . $rel_parts['path'];
// experimental: if relative url path is not absolute (i.e. starting with /) concatenate it using base url path
// (i'm not sure if it's a good idea)
if (strpos($rel_parts['path'], '/') !== 0) {
$rel_parts['path'] = with_trailing_slash($base_parts['path'] ?? "") . $rel_parts['path'];
}
$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
@ -158,23 +190,14 @@ class UrlHelper {
public static function fetch($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*/) {
global $fetch_last_error;
global $fetch_last_error_code;
global $fetch_last_error_content;
global $fetch_last_content_type;
global $fetch_last_modified;
global $fetch_effective_url;
global $fetch_effective_ip_addr;
global $fetch_curl_used;
$fetch_last_error = false;
$fetch_last_error_code = -1;
$fetch_last_error_content = "";
$fetch_last_content_type = "";
$fetch_curl_used = false;
$fetch_last_modified = "";
$fetch_effective_url = "";
$fetch_effective_ip_addr = "";
self::$fetch_last_error = false;
self::$fetch_last_error_code = -1;
self::$fetch_last_error_content = "";
self::$fetch_last_content_type = "";
self::$fetch_curl_used = false;
self::$fetch_last_modified = "";
self::$fetch_effective_url = "";
self::$fetch_effective_ip_addr = "";
if (!is_array($options)) {
@ -219,7 +242,7 @@ class UrlHelper {
$url = self::validate($url, true);
if (!$url) {
$fetch_last_error = "Requested URL failed extended validation.";
self::$fetch_last_error = "Requested URL failed extended validation.";
return false;
}
@ -227,13 +250,13 @@ class UrlHelper {
$ip_addr = gethostbyname($url_host);
if (!$ip_addr || strpos($ip_addr, "127.") === 0) {
$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
return false;
}
if (function_exists('curl_init') && !ini_get("open_basedir")) {
$fetch_curl_used = true;
self::$fetch_curl_used = true;
$ch = curl_init($url);
@ -258,8 +281,7 @@ class UrlHelper {
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent :
SELF_USER_AGENT);
curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent : Config::get_user_agent());
curl_setopt($ch, CURLOPT_ENCODING, "");
if ($http_referrer)
@ -271,10 +293,15 @@ class UrlHelper {
// holy shit closures in php
// download & upload are *expected* sizes respectively, could be zero
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use( &$max_size) {
Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use(&$max_size, $url) {
//Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
if ($downloaded > $max_size) {
Debug::log("curl: reached max size of $max_size bytes requesting $url, aborting.", Debug::LOG_VERBOSE);
return 1;
}
return ($downloaded > $max_size) ? 1 : 0; // if max size is set, abort when exceeding it
return 0;
});
}
@ -306,13 +333,13 @@ class UrlHelper {
list ($key, $value) = explode(": ", $header);
if (strtolower($key) == "last-modified") {
$fetch_last_modified = $value;
self::$fetch_last_modified = $value;
}
}
if (substr(strtolower($header), 0, 7) == 'http/1.') {
$fetch_last_error_code = (int) substr($header, 9, 3);
$fetch_last_error = $header;
self::$fetch_last_error_code = (int) substr($header, 9, 3);
self::$fetch_last_error = $header;
}
}
@ -322,39 +349,39 @@ class UrlHelper {
}
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
self::$fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
self::$fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
if (!self::validate($fetch_effective_url, true)) {
$fetch_last_error = "URL received after redirection failed extended validation.";
if (!self::validate(self::$fetch_effective_url, true)) {
self::$fetch_last_error = "URL received after redirection failed extended validation.";
return false;
}
$fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST));
self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST));
if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) {
$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)";
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.")";
return false;
}
$fetch_last_error_code = $http_code;
self::$fetch_last_error_code = $http_code;
if ($http_code != 200 || $type && strpos($fetch_last_content_type, "$type") === false) {
if ($http_code != 200 || $type && strpos(self::$fetch_last_content_type, "$type") === false) {
if (curl_errno($ch) != 0) {
$fetch_last_error .= "; " . curl_errno($ch) . " " . curl_error($ch);
self::$fetch_last_error .= "; " . curl_errno($ch) . " " . curl_error($ch);
}
$fetch_last_error_content = $contents;
self::$fetch_last_error_content = $contents;
curl_close($ch);
return false;
}
if (!$contents) {
$fetch_last_error = curl_errno($ch) . " " . curl_error($ch);
self::$fetch_last_error = curl_errno($ch) . " " . curl_error($ch);
curl_close($ch);
return false;
}
@ -372,7 +399,7 @@ class UrlHelper {
return $contents;
} else {
$fetch_curl_used = false;
self::$fetch_curl_used = false;
if ($login && $pass){
$url_parts = array();
@ -417,18 +444,18 @@ class UrlHelper {
$old_error = error_get_last();
$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT));
self::$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT));
if (!self::validate($fetch_effective_url, true)) {
$fetch_last_error = "URL received after redirection failed extended validation.";
if (!self::validate(self::$fetch_effective_url, true)) {
self::$fetch_last_error = "URL received after redirection failed extended validation.";
return false;
}
$fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST));
self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST));
if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) {
$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)";
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.")";
return false;
}
@ -442,30 +469,30 @@ class UrlHelper {
$key = strtolower($key);
if ($key == 'content-type') {
$fetch_last_content_type = $value;
self::$fetch_last_content_type = $value;
// don't abort here b/c there might be more than one
// e.g. if we were being redirected -- last one is the right one
} else if ($key == 'last-modified') {
$fetch_last_modified = $value;
self::$fetch_last_modified = $value;
} else if ($key == 'location') {
$fetch_effective_url = $value;
self::$fetch_effective_url = $value;
}
}
if (substr(strtolower($header), 0, 7) == 'http/1.') {
$fetch_last_error_code = (int) substr($header, 9, 3);
$fetch_last_error = $header;
self::$fetch_last_error_code = (int) substr($header, 9, 3);
self::$fetch_last_error = $header;
}
}
if ($fetch_last_error_code != 200) {
if (self::$fetch_last_error_code != 200) {
$error = error_get_last();
if ($error['message'] != $old_error['message']) {
$fetch_last_error .= "; " . $error["message"];
if (($error['message'] ?? '') != ($old_error['message'] ?? '')) {
self::$fetch_last_error .= "; " . $error["message"];
}
$fetch_last_error_content = $data;
self::$fetch_last_error_content = $data;
return false;
}
@ -482,4 +509,26 @@ class UrlHelper {
}
}
public static function url_to_youtube_vid($url) {
$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-]+)/",
"/\/\/youtu.be\/([\w-]+)/",
];
foreach ($regexps as $re) {
$matches = [];
if (preg_match($re, $url, $matches)) {
return $matches[1];
}
}
return false;
}
}

@ -1,6 +1,22 @@
<?php
use OTPHP\TOTP;
class UserHelper {
const HASH_ALGO_SSHA512 = 'SSHA-512';
const HASH_ALGO_SSHA256 = 'SSHA-256';
const HASH_ALGO_MODE2 = 'MODE2';
const HASH_ALGO_SHA1X = 'SHA1X';
const HASH_ALGO_SHA1 = 'SHA1';
const HASH_ALGOS = [
self::HASH_ALGO_SSHA512,
self::HASH_ALGO_SSHA256,
self::HASH_ALGO_MODE2,
self::HASH_ALGO_SHA1X,
self::HASH_ALGO_SHA1
];
static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null) {
if (!Config::get(Config::SINGLE_USER_MODE)) {
$user_id = false;
@ -18,35 +34,35 @@ class UserHelper {
if ($user_id && !$check_only) {
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
session_regenerate_id(true);
$user = ORM::for_table('ttrss_users')->find_one($user_id);
if ($user) {
$_SESSION["uid"] = $user_id;
$_SESSION["auth_module"] = $auth_module;
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT login,access_level,pwd_hash FROM ttrss_users
WHERE id = ?");
$sth->execute([$user_id]);
$row = $sth->fetch();
$_SESSION["name"] = $row["login"];
$_SESSION["access_level"] = $row["access_level"];
$_SESSION["name"] = $user->login;
$_SESSION["access_level"] = $user->access_level;
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
$usth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
$usth->execute([$user_id]);
$_SESSION["ip_address"] = UserHelper::get_user_ip();
$_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']);
$_SESSION["pwd_hash"] = $row["pwd_hash"];
$_SESSION["pwd_hash"] = $user->pwd_hash;
Pref_Prefs::_init_user_prefs($_SESSION["uid"]);
$user->last_login = Db::NOW();
$user->save();
return true;
}
return false;
}
if ($login && $password && !$user_id && !$check_only)
Logger::log(E_USER_WARNING, "Failed login attempt for $login (service: $service) from " . UserHelper::get_user_ip());
return false;
} else {
@ -59,13 +75,11 @@ class UserHelper {
$_SESSION["auth_module"] = false;
if (!$_SESSION["csrf_token"])
if (empty($_SESSION["csrf_token"]))
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
$_SESSION["ip_address"] = UserHelper::get_user_ip();
Pref_Prefs::_init_user_prefs($_SESSION["uid"]);
return true;
}
}
@ -74,8 +88,8 @@ class UserHelper {
if (!$pluginhost) $pluginhost = PluginHost::getInstance();
if ($owner_uid && SCHEMA_VERSION >= 100 && empty($_SESSION["safe_mode"])) {
$plugins = get_pref("_ENABLED_PLUGINS", $owner_uid);
if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) {
$plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
$pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid);
@ -89,17 +103,20 @@ class UserHelper {
$pdo = Db::pdo();
if (Config::get(Config::SINGLE_USER_MODE)) {
@session_start();
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
self::authenticate("admin", null);
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
} else {
if (!\Sessions\validate_session()) $_SESSION["uid"] = false;
if (!\Sessions\validate_session())
$_SESSION["uid"] = null;
if (empty($_SESSION["uid"])) {
if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
$_SESSION["ref_schema_version"] = get_schema_version(true);
$_SESSION["ref_schema_version"] = get_schema_version();
} else {
self::authenticate(null, null, true);
}
@ -113,8 +130,9 @@ class UserHelper {
} else {
/* bump login timestamp */
$sth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
$sth->execute([$_SESSION['uid']]);
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
$user->last_login = Db::NOW();
$user->save();
$_SESSION["last_login_update"] = time();
}
@ -127,7 +145,7 @@ class UserHelper {
}
static function print_user_stylesheet() {
$value = get_pref('USER_STYLESHEET');
$value = get_pref(Prefs::USER_STYLESHEET);
if ($value) {
print "<style type='text/css' id='user_css_style'>";
@ -142,20 +160,29 @@ class UserHelper {
if (isset($_SERVER[$hdr]))
return $_SERVER[$hdr];
}
}
static function find_user_by_login(string $login) {
$pdo = Db::pdo();
return null;
}
$sth = $pdo->prepare("SELECT id FROM ttrss_users WHERE
LOWER(login) = LOWER(?)");
$sth->execute([$login]);
static function get_login_by_id(int $id) {
$user = ORM::for_table('ttrss_users')
->find_one($id);
if ($row = $sth->fetch()) {
return $row["id"];
if ($user)
return $user->login;
else
return null;
}
return false;
static function find_user_by_login(string $login) {
$user = ORM::for_table('ttrss_users')
->where('login', $login)
->find_one();
if ($user)
return $user->id;
else
return null;
}
static function logout() {
@ -169,34 +196,167 @@ class UserHelper {
session_commit();
}
static function reset_password($uid, $format_output = false) {
static function get_salt() {
return substr(bin2hex(get_random_bytes(125)), 0, 250);
}
$pdo = Db::pdo();
static function reset_password($uid, $format_output = false, $new_password = "") {
$sth = $pdo->prepare("SELECT login FROM ttrss_users WHERE id = ?");
$sth->execute([$uid]);
$user = ORM::for_table('ttrss_users')->find_one($uid);
$message = "";
if ($row = $sth->fetch()) {
if ($user) {
$login = $row["login"];
$login = $user->login;
$new_salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$tmp_user_pwd = make_password();
$new_salt = self::get_salt();
$tmp_user_pwd = $new_password ? $new_password : make_password();
$pwd_hash = encrypt_password($tmp_user_pwd, $new_salt, true);
$pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]);
$sth = $pdo->prepare("UPDATE ttrss_users
SET pwd_hash = ?, salt = ?, otp_enabled = false
WHERE id = ?");
$sth->execute([$pwd_hash, $new_salt, $uid]);
$user->pwd_hash = $pwd_hash;
$user->salt = $new_salt;
$user->save();
$message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>");
} else {
$message = __("User not found");
}
if ($format_output)
print_notice($message);
else
print $message;
}
static function check_otp(int $owner_uid, int $otp_check) : bool {
$otp = TOTP::create(self::get_otp_secret($owner_uid, true));
return $otp->now() == $otp_check;
}
static function disable_otp(int $owner_uid) : bool {
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($user) {
$user->otp_enabled = false;
// force new OTP secret when next enabled
if (Config::get_schema_version() >= 143) {
$user->otp_secret = null;
}
$user->save();
return true;
} else {
return false;
}
}
static function enable_otp(int $owner_uid, int $otp_check) : bool {
$secret = self::get_otp_secret($owner_uid);
if ($secret) {
$otp = TOTP::create($secret);
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($otp->now() == $otp_check && $user) {
$user->otp_enabled = true;
$user->save();
return true;
}
}
return false;
}
static function is_otp_enabled(int $owner_uid) : bool {
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($user) {
return $user->otp_enabled;
} else {
return false;
}
}
static function get_otp_secret(int $owner_uid, bool $show_if_enabled = false) {
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($user) {
$salt_based_secret = mb_substr(sha1($user->salt), 0, 12);
if (Config::get_schema_version() >= 143) {
$secret = $user->otp_secret;
if (empty($secret)) {
/* migrate secret if OTP is already enabled, otherwise make a new one */
if ($user->otp_enabled) {
$user->otp_secret = $salt_based_secret;
} else {
$user->otp_secret = bin2hex(get_random_bytes(10));
}
$user->save();
$secret = $user->otp_secret;
}
} else {
$secret = $salt_based_secret;
}
if (!$user->otp_enabled || $show_if_enabled) {
return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret);
}
}
return null;
}
static function is_default_password() {
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if ($authenticator &&
method_exists($authenticator, "check_password") &&
$authenticator->check_password($_SESSION["uid"], "password")) {
return true;
}
return false;
}
static function hash_password(string $pass, string $salt, string $algo = "") {
if (!$algo) $algo = self::HASH_ALGOS[0];
$pass_hash = "";
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)
return "$algo:$pass_hash";
else
return false;
}
}

@ -0,0 +1,11 @@
{
"require": {
"spomky-labs/otphp": "^10.0",
"chillerlan/php-qrcode": "^3.3",
"mervick/material-design-icons": "^2.2",
"j4mie/idiorm": "^1.5"
},
"require-dev": {
"phpstan/phpstan": "^0.12.99"
}
}

669
composer.lock generated

@ -0,0 +1,669 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "76e40cf59f811ee42d14ac41159c570a",
"packages": [
{
"name": "beberlei/assert",
"version": "v3.2.7",
"source": {
"type": "git",
"url": "https://github.com/beberlei/assert.git",
"reference": "d63a6943fc4fd1a2aedb65994e3548715105abcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/beberlei/assert/zipball/d63a6943fc4fd1a2aedb65994e3548715105abcf",
"reference": "d63a6943fc4fd1a2aedb65994e3548715105abcf",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"php": "^7"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan-shim": "*",
"phpunit/phpunit": ">=6.0.0 <8"
},
"suggest": {
"ext-intl": "Needed to allow Assertion::count(), Assertion::isCountable(), Assertion::minCount(), and Assertion::maxCount() to operate on ResourceBundles"
},
"type": "library",
"autoload": {
"psr-4": {
"Assert\\": "lib/Assert"
},
"files": [
"lib/Assert/functions.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de",
"role": "Lead Developer"
},
{
"name": "Richard Quadling",
"email": "rquadling@gmail.com",
"role": "Collaborator"
}
],
"description": "Thin assertion library for input validation in business models.",
"keywords": [
"assert",
"assertion",
"validation"
],
"support": {
"issues": "https://github.com/beberlei/assert/issues",
"source": "https://github.com/beberlei/assert/tree/v3"
},
"time": "2019-12-19T17:51:41+00:00"
},
{
"name": "chillerlan/php-qrcode",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/chillerlan/php-qrcode.git",
"reference": "d8bf297e6843a53aeaa8f3285ce04fc349d133d6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/d8bf297e6843a53aeaa8f3285ce04fc349d133d6",
"reference": "d8bf297e6843a53aeaa8f3285ce04fc349d133d6",
"shasum": ""
},
"require": {
"chillerlan/php-settings-container": "^1.2",
"ext-mbstring": "*",
"php": "^7.2"
},
"require-dev": {
"phpunit/phpunit": "^8.5",
"setasign/fpdf": "^1.8.2"
},
"suggest": {
"chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.",
"setasign/fpdf": "Required to use the QR FPDF output."
},
"type": "library",
"autoload": {
"psr-4": {
"chillerlan\\QRCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kazuhiko Arase",
"homepage": "https://github.com/kazuhikoarase"
},
{
"name": "Smiley",
"email": "smiley@chillerlan.net",
"homepage": "https://github.com/codemasher"
},
{
"name": "Contributors",
"homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors"
}
],
"description": "A QR code generator. PHP 7.2+",
"homepage": "https://github.com/chillerlan/php-qrcode",
"keywords": [
"phpqrcode",
"qr",
"qr code",
"qrcode",
"qrcode-generator"
],
"support": {
"issues": "https://github.com/chillerlan/php-qrcode/issues",
"source": "https://github.com/chillerlan/php-qrcode/tree/3.4.0"
},
"funding": [
{
"url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4",
"type": "custom"
},
{
"url": "https://ko-fi.com/codemasher",
"type": "ko_fi"
}
],
"time": "2020-11-18T20:51:41+00:00"
},
{
"name": "chillerlan/php-settings-container",
"version": "1.2.1",
"source": {
"type": "git",
"url": "https://github.com/chillerlan/php-settings-container.git",
"reference": "b9b0431dffd74102ee92348a63b4c33fc8ba639b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/b9b0431dffd74102ee92348a63b4c33fc8ba639b",
"reference": "b9b0431dffd74102ee92348a63b4c33fc8ba639b",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.2"
},
"require-dev": {
"phpunit/phpunit": "^8.3"
},
"type": "library",
"autoload": {
"psr-4": {
"chillerlan\\Settings\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Smiley",
"email": "smiley@chillerlan.net",
"homepage": "https://github.com/codemasher"
}
],
"description": "A container class for immutable settings objects. Not a DI container. PHP 7.2+",
"homepage": "https://github.com/chillerlan/php-settings-container",
"keywords": [
"PHP7",
"Settings",
"container",
"helper"
],
"support": {
"issues": "https://github.com/chillerlan/php-settings-container/issues",
"source": "https://github.com/chillerlan/php-settings-container"
},
"time": "2019-09-10T00:09:44+00:00"
},
{
"name": "j4mie/idiorm",
"version": "v1.5.7",
"source": {
"type": "git",
"url": "https://github.com/j4mie/idiorm.git",
"reference": "d23f97053ef5d0b988a02c6a71eb5c6118b2f5b4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/j4mie/idiorm/zipball/d23f97053ef5d0b988a02c6a71eb5c6118b2f5b4",
"reference": "d23f97053ef5d0b988a02c6a71eb5c6118b2f5b4",
"shasum": ""
},
"require": {
"php": ">=5.2.0"
},
"require-dev": {
"ext-pdo_sqlite": "*",
"phpunit/phpunit": "^4.8"
},
"type": "library",
"autoload": {
"classmap": [
"idiorm.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause",
"BSD-3-Clause",
"BSD-4-Clause"
],
"authors": [
{
"name": "Jamie Matthews",
"email": "jamie.matthews@gmail.com",
"homepage": "http://j4mie.org",
"role": "Developer"
},
{
"name": "Simon Holywell",
"email": "treffynnon@php.net",
"homepage": "http://simonholywell.com",
"role": "Maintainer"
},
{
"name": "Durham Hale",
"email": "me@durhamhale.com",
"homepage": "http://durhamhale.com",
"role": "Maintainer"
}
],
"description": "A lightweight nearly-zero-configuration object-relational mapper and fluent query builder for PHP5",
"homepage": "http://j4mie.github.com/idiormandparis",
"keywords": [
"idiorm",
"orm",
"query builder"
],
"support": {
"issues": "https://github.com/j4mie/idiorm/issues",
"source": "https://github.com/j4mie/idiorm"
},
"time": "2020-04-29T00:37:09+00:00"
},
{
"name": "mervick/material-design-icons",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/mervick/material-design-icons.git",
"reference": "635435c8d3df3a6da3241648caf8a65d1c07cc1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mervick/material-design-icons/zipball/635435c8d3df3a6da3241648caf8a65d1c07cc1a",
"reference": "635435c8d3df3a6da3241648caf8a65d1c07cc1a",
"shasum": ""
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT",
"CC-BY-4.0"
],
"authors": [
{
"name": "Andrey Izman",
"email": "izmanw@gmail.com"
}
],
"description": "Google Material Design Icons For Using With Bootstrap",
"homepage": "http://github.com/mervick/material-design-icons",
"keywords": [
"bootstrap",
"google",
"icons",
"icons-web-font",
"material",
"material-design",
"web-font"
],
"support": {
"issues": "https://github.com/mervick/material-design-icons/issues",
"source": "http://github.com/mervick/material-design-icons"
},
"time": "2016-02-22T01:05:40+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "f34c2b11eb9d2c9318e13540a1dbc2a3afbd939c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/f34c2b11eb9d2c9318e13540a1dbc2a3afbd939c",
"reference": "f34c2b11eb9d2c9318e13540a1dbc2a3afbd939c",
"shasum": ""
},
"require": {
"php": "^7|^8"
},
"require-dev": {
"phpunit/phpunit": "^6|^7|^8|^9",
"vimeo/psalm": "^1|^2|^3|^4"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2020-12-06T15:14:20+00:00"
},
{
"name": "spomky-labs/otphp",
"version": "v10.0.1",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/otphp.git",
"reference": "f44cce5a9db4b8da410215d992110482c931232f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/f44cce5a9db4b8da410215d992110482c931232f",
"reference": "f44cce5a9db4b8da410215d992110482c931232f",
"shasum": ""
},
"require": {
"beberlei/assert": "^3.0",
"ext-mbstring": "*",
"paragonie/constant_time_encoding": "^2.0",
"php": "^7.2|^8.0",
"thecodingmachine/safe": "^0.1.14|^1.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.0",
"phpstan/phpstan": "^0.12",
"phpstan/phpstan-beberlei-assert": "^0.12",
"phpstan/phpstan-deprecation-rules": "^0.12",
"phpstan/phpstan-phpunit": "^0.12",
"phpstan/phpstan-strict-rules": "^0.12",
"phpunit/phpunit": "^8.0",
"thecodingmachine/phpstan-safe-rule": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"v10.0": "10.0.x-dev",
"v9.0": "9.0.x-dev",
"v8.3": "8.3.x-dev"
}
},
"autoload": {
"psr-4": {
"OTPHP\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/Spomky-Labs/otphp/contributors"
}
],
"description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator",
"homepage": "https://github.com/Spomky-Labs/otphp",
"keywords": [
"FreeOTP",
"RFC 4226",
"RFC 6238",
"google authenticator",
"hotp",
"otp",
"totp"
],
"support": {
"issues": "https://github.com/Spomky-Labs/otphp/issues",
"source": "https://github.com/Spomky-Labs/otphp/tree/v10.0.1"
},
"time": "2020-01-28T09:24:19+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v1.3.3",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc",
"reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"require-dev": {
"phpstan/phpstan": "^0.12",
"squizlabs/php_codesniffer": "^3.2",
"thecodingmachine/phpstan-strict-rules": "^0.12"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "0.1-dev"
}
},
"autoload": {
"psr-4": {
"Safe\\": [
"lib/",
"deprecated/",
"generated/"
]
},
"files": [
"deprecated/apc.php",
"deprecated/libevent.php",
"deprecated/mssql.php",
"deprecated/stats.php",
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/ingres-ii.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/msql.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/mysqlndMs.php",
"generated/mysqlndQc.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/password.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pdf.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/simplexml.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v1.3.3"
},
"time": "2020-10-28T17:51:34+00:00"
}
],
"packages-dev": [
{
"name": "phpstan/phpstan",
"version": "0.12.99",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4d40f1d759942f523be267a1bab6884f46ca3f7",
"reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7",
"shasum": ""
},
"require": {
"php": "^7.1|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "0.12-dev"
}
},
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"support": {
"issues": "https://github.com/phpstan/phpstan/issues",
"source": "https://github.com/phpstan/phpstan/tree/0.12.99"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
},
{
"url": "https://www.patreon.com/phpstan",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan",
"type": "tidelift"
}
],
"time": "2021-09-12T20:09:55+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
}

@ -12,8 +12,8 @@ function swallowError(error) {
gulp.task('less', function(cb) {
gulp
.src(['themes/compact.less', 'themes/compact_night.less',
'themes/light.less', 'themes/night_blue.less', 'themes/night.less'])
.pipe(less())
'themes/light.less', 'themes/light-high-contrast.less', 'themes/night_blue.less', 'themes/night.less'])
.pipe(less({javascriptEnabled: true}))
.on('error', swallowError)
.pipe(
gulp.dest(function(f) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

@ -0,0 +1,17 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#257aa7">
<g fill="none" fill-rule="evenodd">
<g stroke-width="8" transform="matrix(0.83009609,0,0,0.83009609,4.0582705,4.0582705)">
<circle stroke-opacity=".5" cx="18" cy="18" r="18"/>
<path d="M 36,18 C 36,8.06 27.94,0 18,0">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"/>
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 740 B

@ -0,0 +1,33 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg width="120" height="30" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#257aa7">
<circle cx="15" cy="15" r="15">
<animate attributeName="r" from="15" to="15"
begin="0s" dur="0.8s"
values="15;9;15" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="fill-opacity" from="1" to="1"
begin="0s" dur="0.8s"
values="1;.5;1" calcMode="linear"
repeatCount="indefinite" />
</circle>
<circle cx="60" cy="15" r="9" fill-opacity="0.3">
<animate attributeName="r" from="9" to="9"
begin="0s" dur="0.8s"
values="9;15;9" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="fill-opacity" from="0.5" to="0.5"
begin="0s" dur="0.8s"
values=".5;1;.5" calcMode="linear"
repeatCount="indefinite" />
</circle>
<circle cx="105" cy="15" r="15">
<animate attributeName="r" from="15" to="15"
begin="0s" dur="0.8s"
values="15;9;15" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="fill-opacity" from="1" to="1"
begin="0s" dur="0.8s"
values="1;.5;1" calcMode="linear"
repeatCount="indefinite" />
</circle>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -1,24 +1,17 @@
<?php
spl_autoload_register(function($class) {
$namespace = '';
$class_name = $class;
if (strpos($class, '\\') !== false)
list ($namespace, $class_name) = explode('\\', $class, 2);
$root_dir = dirname(__DIR__); // we were in tt-rss/include
$root_dir = dirname(__DIR__); // we're in tt-rss/include
// - internal tt-rss classes are loaded from classes/ and use special naming logic instead of namespaces
// - plugin classes are loaded by PluginHandler from plugins.local/ and plugins/
// 1. third party libraries with namespaces are loaded from vendor/
// 2. internal tt-rss classes are loaded from classes/ and use special naming logic instead of namespaces
// 3. plugin classes are loaded by PluginHandler from plugins.local/ and plugins/ (TODO: use generic autoloader?)
if ($namespace && $class_name) {
$class_file = "$root_dir/vendor/$namespace/" . str_replace('\\', '/', $class_name) . ".php";
} else {
$class_file = "$root_dir/classes/" . str_replace("_", "/", strtolower($class)) . ".php";
}
if (file_exists($class_file))
include $class_file;
});
// also pull composer autoloader
require_once "vendor/autoload.php";

@ -2,7 +2,7 @@
namespace Controls;
function attributes_to_string(array $attributes) {
$rv = "";
$rv = [];
foreach ($attributes as $k => $v) {
@ -10,10 +10,10 @@
if ($k === "disabled" && !sql_bool_to_bool($v))
continue;
$rv .= "$k=\"" . htmlspecialchars($v) . "\"";
array_push($rv, "$k=\"" . htmlspecialchars($v) . "\"");
}
return $rv;
return implode(" ", $rv);
}
// shortcut syntax (disabled)

@ -1,27 +1,32 @@
<?php
function stylesheet_tag($filename, $id = false) {
$timestamp = filemtime($filename);
function stylesheet_tag($filename, $attributes = []) {
$id_part = $id ? "id=\"$id\"" : "";
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"href" => "$filename?" . filemtime($filename),
"rel" => "stylesheet",
"type" => "text/css",
"data-orig-href" => $filename
],
$attributes));
return "<link rel=\"stylesheet\" $id_part type=\"text/css\" data-orig-href=\"$filename\" href=\"$filename?$timestamp\"/>\n";
return "<link $attributes_str/>\n";
}
function javascript_tag($filename) {
$query = "";
function javascript_tag($filename, $attributes = []) {
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"src" => "$filename?" . filemtime($filename),
"type" => "text/javascript",
"charset" => "utf-8"
],
$attributes));
if (!(strpos($filename, "?") === false)) {
$query = substr($filename, strpos($filename, "?")+1);
$filename = substr($filename, 0, strpos($filename, "?"));
}
$timestamp = filemtime($filename);
if ($query) $timestamp .= "&$query";
return "<script type=\"text/javascript\" charset=\"utf-8\" src=\"$filename?$timestamp\"></script>\n";
return "<script $attributes_str></script>\n";
}
function format_warning($msg, $id = "") {
@ -47,268 +52,3 @@ function print_warning($msg) {
function print_error($msg) {
return print format_error($msg);
}
// the following is deprecated and will be eventually removed
/*function print_select($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
print "<select name=\"$name\" id=\"$id\" $attributes>";
foreach ($values as $v) {
if ($v == $default)
$sel = "selected=\"1\"";
else
$sel = "";
$v = trim($v);
print "<option value=\"$v\" $sel>$v</option>";
}
print "</select>";
}
function print_select_hash($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
print "<select name=\"$name\" id='$id' $attributes>";
foreach (array_keys($values) as $v) {
if ($v == $default)
$sel = 'selected="selected"';
else
$sel = "";
$v = trim($v);
print "<option $sel value=\"$v\">".$values[$v]."</option>";
}
print "</select>";
}
function format_hidden($name, $value) {
return "<input dojoType=\"dijit.form.TextBox\" style=\"display : none\" name=\"$name\" value=\"$value\">";
}
function print_hidden($name, $value) {
print format_hidden($name, $value);
}
function format_checkbox($id, $checked, $value = "", $attributes = "") {
$checked_str = $checked ? "checked" : "";
$value_str = $value ? "value=\"$value\"" : "";
return "<input dojoType=\"dijit.form.CheckBox\" id=\"$id\" $value_str $checked_str $attributes name=\"$id\">";
}
function print_checkbox($id, $checked, $value = "", $attributes = "") {
print format_checkbox($id, $checked, $value, $attributes);
}
function format_button($type, $value, $attributes = "") {
return "<button dojoType=\"dijit.form.Button\" $attributes type=\"$type\">$value</button>";
}
function print_button($type, $value, $attributes = "") {
print format_button($type, $value, $attributes);
}
function print_feed_multi_select($id, $default_ids = [],
$attributes = "", $include_all_feeds = true,
$root_id = null, $nest_level = 0) {
$pdo = Db::pdo();
print_r(in_array("CAT:6",$default_ids));
if (!$root_id) {
print "<select multiple=\true\" id=\"$id\" name=\"$id\" $attributes>";
if ($include_all_feeds) {
$is_selected = (in_array("0", $default_ids)) ? "selected=\"1\"" : "";
print "<option $is_selected value=\"0\">".__('All feeds')."</option>";
}
}
if (get_pref('ENABLE_FEED_CATS')) {
if (!$root_id) $root_id = null;
$sth = $pdo->prepare("SELECT id,title,
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE
c2.parent_cat = ttrss_feed_categories.id) AS num_children
FROM ttrss_feed_categories
WHERE owner_uid = :uid AND
(parent_cat = :root_id OR (:root_id IS NULL AND parent_cat IS NULL)) ORDER BY title");
$sth->execute([":uid" => $_SESSION['uid'], ":root_id" => $root_id]);
while ($line = $sth->fetch()) {
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = "" . $line["title"];
$is_selected = in_array("CAT:".$line["id"], $default_ids) ? "selected=\"1\"" : "";
printf("<option $is_selected value='CAT:%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
if ($line["num_children"] > 0)
print_feed_multi_select($id, $default_ids, $attributes,
$include_all_feeds, $line["id"], $nest_level+1);
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE cat_id = ? AND owner_uid = ? ORDER BY title");
$f_sth->execute([$line['id'], $_SESSION['uid']]);
while ($fline = $f_sth->fetch()) {
$is_selected = (in_array($fline["id"], $default_ids)) ? "selected=\"1\"" : "";
$fline["title"] = "" . $fline["title"];
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = "" . $fline["title"];
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
if (!$root_id) {
$is_selected = in_array("CAT:0", $default_ids) ? "selected=\"1\"" : "";
printf("<option $is_selected value='CAT:0'>%s</option>",
__("Uncategorized"));
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE cat_id IS NULL AND owner_uid = ? ORDER BY title");
$f_sth->execute([$_SESSION['uid']]);
while ($fline = $f_sth->fetch()) {
$is_selected = in_array($fline["id"], $default_ids) ? "selected=\"1\"" : "";
$fline["title"] = "" . $fline["title"];
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = "" . $fline["title"];
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
} else {
$sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$_SESSION['uid']]);
while ($line = $sth->fetch()) {
$is_selected = (in_array($line["id"], $default_ids)) ? "selected=\"1\"" : "";
printf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
}
}
if (!$root_id) {
print "</select>";
}
}
function print_feed_cat_select($id, $default_id, $attributes, $include_all_cats = true,
$root_id = null, $nest_level = 0) {
print format_feed_cat_select($id, $default_id, $attributes, $include_all_cats, $root_id, $nest_level);
}
function format_feed_cat_select($id, $default_id, $attributes, $include_all_cats = true,
$root_id = null, $nest_level = 0) {
$ret = "";
if (!$root_id) {
$ret .= "<select id=\"$id\" name=\"$id\" default=\"$default_id\" $attributes>";
}
$pdo = Db::pdo();
if (!$root_id) $root_id = null;
$sth = $pdo->prepare("SELECT id,title,
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE
c2.parent_cat = ttrss_feed_categories.id) AS num_children
FROM ttrss_feed_categories
WHERE owner_uid = :uid AND
(parent_cat = :root_id OR (:root_id IS NULL AND parent_cat IS NULL)) ORDER BY title");
$sth->execute([":uid" => $_SESSION['uid'], ":root_id" => $root_id]);
$found = 0;
while ($line = $sth->fetch()) {
++$found;
if ($line["id"] == $default_id) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = "" . $line["title"];
if ($line["title"])
$ret .= sprintf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
if ($line["num_children"] > 0)
$ret .= format_feed_cat_select($id, $default_id, $attributes,
$include_all_cats, $line["id"], $nest_level+1);
}
if (!$root_id) {
if ($include_all_cats) {
if ($found > 0) {
$ret .= "<option disabled=\"1\">―――――――――――――――</option>";
}
if ($default_id == 0) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
$ret .= "<option $is_selected value=\"0\">".__('Uncategorized')."</option>";
}
$ret .= "</select>";
}
return $ret;
}
function print_label_select($name, $value, $attributes = "") {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$_SESSION['uid']]);
print "<select default=\"$value\" name=\"" . htmlspecialchars($name) .
"\" $attributes>";
while ($line = $sth->fetch()) {
$issel = ($line["caption"] == $value) ? "selected=\"1\"" : "";
print "<option value=\"".htmlspecialchars($line["caption"])."\"
$issel>" . htmlspecialchars($line["caption"]) . "</option>";
}
# print "<option value=\"ADD_LABEL\">" .__("Add label...") . "</option>";
print "</select>";
}
*/

@ -8,7 +8,7 @@ function format_backtrace($trace) {
if (isset($e["file"]) && isset($e["line"])) {
$fmt_args = [];
if (is_array($e["args"])) {
if (is_array($e["args"] ?? false)) {
foreach ($e["args"] as $a) {
if (is_object($a)) {
array_push($fmt_args, "{" . get_class($a) . "}");
@ -40,13 +40,13 @@ function format_backtrace($trace) {
}
function ttrss_error_handler($errno, $errstr, $file, $line) {
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
/*if (version_compare(PHP_VERSION, '8.0.0', '<')) {
if (error_reporting() == 0 || !$errno) return false;
} else {
if (!(error_reporting() & $errno)) return false;
}
if (error_reporting() == 0 || !$errno) return false;
if (error_reporting() == 0 || !$errno) return false;*/
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
@ -54,12 +54,12 @@ function ttrss_error_handler($errno, $errstr, $file, $line) {
$errstr = truncate_middle($errstr, 16384, " (...) ");
if (class_exists("Logger"))
return Logger::get()->log_error($errno, $errstr, $file, $line, $context);
return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
else
return false;
}
function ttrss_fatal_handler() {
global $last_query;
$error = error_get_last();
if ($error !== NULL) {
@ -74,10 +74,8 @@ function ttrss_fatal_handler() {
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
if ($last_query) $errstr .= " [Last query: $last_query]";
if (class_exists("Logger"))
return Logger::get()->log_error($errno, $errstr, $file, $line, $context);
return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
}
return false;

@ -1,15 +1,9 @@
<?php
define('SCHEMA_VERSION', 140);
define('LABEL_BASE_INDEX', -1024);
define('PLUGIN_FEED_BASE_INDEX', -128);
$fetch_last_error = false;
$fetch_last_error_code = false;
$fetch_last_content_type = false;
$fetch_last_error_content = false; // curl only for the time being
$fetch_effective_url = false;
$fetch_curl_used = false;
/** constant is @deprecated, use Config::SCHEMA_VERSION instead */
define('SCHEMA_VERSION', Config::SCHEMA_VERSION);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
@ -42,12 +36,12 @@
define('SUBSTRING_FOR_DATE', 'SUBSTRING');
}
function get_pref($pref_name, $user_id = false, $die_on_error = false) {
return Db_Prefs::get()->read($pref_name, $user_id, $die_on_error);
function get_pref(string $pref_name, int $owner_uid = null) {
return Prefs::get($pref_name, $owner_uid ? $owner_uid : $_SESSION["uid"], $_SESSION["profile"] ?? null);
}
function set_pref($pref_name, $value, $user_id = false, $strip_tags = true) {
return Db_Prefs::get()->write($pref_name, $value, $user_id, $strip_tags);
function set_pref(string $pref_name, $value, int $owner_uid = null, bool $strip_tags = true) {
return Prefs::set($pref_name, $value, $owner_uid ? $owner_uid : $_SESSION["uid"], $_SESSION["profile"] ?? null, $strip_tags);
}
function get_translations() {
@ -140,7 +134,7 @@
}
if (!empty($_SESSION["uid"]) && get_schema_version() >= 120) {
$pref_locale = get_pref("USER_LANGUAGE", $_SESSION["uid"]);
$pref_locale = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]);
if (!empty($pref_locale) && $pref_locale != 'auto') {
$selected_locale = $pref_locale;
@ -163,75 +157,73 @@
require_once 'controls.php';
require_once 'controls_compat.php';
define('SELF_USER_AGENT', 'Tiny Tiny RSS/' . get_version() . ' (http://tt-rss.org/)');
ini_set('user_agent', SELF_USER_AGENT);
$schema_version = false;
ini_set('user_agent', Config::get_user_agent());
/* compat shims */
/** function is @deprecated by Config::get_version() */
function get_version() {
return Config::get_version();
}
/** function is @deprecated by Config::get_schema_version() */
function get_schema_version() {
return Config::get_schema_version();
}
/** function is @deprecated by Debug::log() */
function _debug($msg) {
Debug::log($msg);
}
// @deprecated
/** function is @deprecated */
function getFeedUnread($feed, $is_cat = false) {
return Feeds::_get_counters($feed, $is_cat, true, $_SESSION["uid"]);
}
// @deprecated
/** function is @deprecated by Sanitizer::sanitize() */
function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) {
return Sanitizer::sanitize($str, $force_remove_images, $owner, $site_url, $highlight_words, $article_id);
}
// @deprecated
/** function is @deprecated by UrlHelper::fetch() */
function fetch_file_contents($params) {
return UrlHelper::fetch($params);
}
// @deprecated
function rewrite_relative_url($url, $rel_url) {
return UrlHelper::rewrite_relative($url, $rel_url);
/** function is @deprecated by UrlHelper::rewrite_relative() */
function rewrite_relative_url($base_url, $rel_url) {
return UrlHelper::rewrite_relative($base_url, $rel_url);
}
// @deprecated
/** function is @deprecated by UrlHelper::validate() */
function validate_url($url) {
return UrlHelper::validate($url);
}
// @deprecated
/** function is @deprecated by UserHelper::authenticate() */
function authenticate_user($login, $password, $check_only = false, $service = false) {
return UserHelper::authenticate($login, $password, $check_only, $service);
}
// @deprecated
/** function is @deprecated by TimeHelper::smart_date_time() */
function smart_date_time($timestamp, $tz_offset = 0, $owner_uid = false, $eta_min = false) {
return TimeHelper::smart_date_time($timestamp, $tz_offset, $owner_uid, $eta_min);
}
// @deprecated
/** function is @deprecated by TimeHelper::make_local_datetime() */
function make_local_datetime($timestamp, $long, $owner_uid = false, $no_smart_dt = false, $eta_min = false) {
return TimeHelper::make_local_datetime($timestamp, $long, $owner_uid, $no_smart_dt, $eta_min);
}
/* end compat shims */
function get_ssl_certificate_id() {
if ($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"] ?? false) {
return sha1($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"] .
$_SERVER["REDIRECT_SSL_CLIENT_V_START"] .
$_SERVER["REDIRECT_SSL_CLIENT_V_END"] .
$_SERVER["REDIRECT_SSL_CLIENT_S_DN"]);
}
if ($_SERVER["SSL_CLIENT_M_SERIAL"] ?? false) {
return sha1($_SERVER["SSL_CLIENT_M_SERIAL"] .
$_SERVER["SSL_CLIENT_V_START"] .
$_SERVER["SSL_CLIENT_V_END"] .
$_SERVER["SSL_CLIENT_S_DN"]);
}
return "";
// this returns Config::SELF_URL_PATH sans ending slash
/** function is @deprecated by Config::get_self_url() */
function get_self_url_prefix() {
return Config::get_self_url();
}
/* end compat shims */
// this is used for user http parameters unless HTML code is actually needed
function clean($param) {
if (is_array($param)) {
@ -243,6 +235,14 @@
}
}
function with_trailing_slash(string $str) : string {
if (substr($str, -1) === "/") {
return $str;
} else {
return "$str/";
}
}
function make_password($length = 12) {
$password = "";
$possible = "0123456789abcdfghjkmnpqrstvwxyzABCDFGHJKMNPQRSTVWXYZ*%+^";
@ -305,24 +305,6 @@
return $s ? 1 : 0;
}
// Session caching removed due to causing wrong redirects to upgrade
// script when get_schema_version() is called on an obsolete session
// created on a previous schema version.
function get_schema_version($nocache = false) {
global $schema_version;
$pdo = Db::pdo();
if (!$schema_version && !$nocache) {
$row = $pdo->query("SELECT schema_version FROM ttrss_version")->fetch();
$version = $row["schema_version"];
$schema_version = $version;
return $version;
} else {
return $schema_version;
}
}
function file_is_locked($filename) {
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/$filename")) {
if (function_exists('flock')) {
@ -387,34 +369,6 @@
return vsprintf(_ngettext(array_shift($args), array_shift($args), array_shift($args)), $args);
}
function is_server_https() {
return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) ||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https');
}
function is_prefix_https() {
return parse_url(Config::get(Config::SELF_URL_PATH), PHP_URL_SCHEME) == 'https';
}
// this returns Config::get(Config::SELF_URL_PATH) sans ending slash
function get_self_url_prefix() {
if (strrpos(Config::get(Config::SELF_URL_PATH), "/") === strlen(Config::get(Config::SELF_URL_PATH))-1) {
return substr(Config::get(Config::SELF_URL_PATH), 0, strlen(Config::get(Config::SELF_URL_PATH))-1);
} else {
return Config::get(Config::SELF_URL_PATH);
}
}
function encrypt_password($pass, $salt = '', $mode2 = false) {
if ($salt && $mode2) {
return "MODE2:" . hash('sha256', $salt . $pass);
} else if ($salt) {
return "SHA1X:" . sha1("$salt:$pass");
} else {
return "SHA1:" . sha1($pass);
}
} // function encrypt_password
function init_plugins() {
PluginHost::getInstance()->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
@ -459,60 +413,14 @@
return in_array($interface, class_implements($class));
}
function T_js_decl($s1, $s2) {
if ($s1 && $s2) {
$s1 = preg_replace("/\n/", "", $s1);
$s2 = preg_replace("/\n/", "", $s2);
$s1 = preg_replace("/\"/", "\\\"", $s1);
$s2 = preg_replace("/\"/", "\\\"", $s2);
return "T_messages[\"$s1\"] = \"$s2\";\n";
}
}
function init_js_translations() {
print 'var T_messages = new Object();
function __(msg) {
if (T_messages[msg]) {
return T_messages[msg];
} else {
return msg;
}
}
function ngettext(msg1, msg2, n) {
return __((parseInt(n) > 1) ? msg2 : msg1);
}';
global $text_domains;
foreach (array_keys($text_domains) as $domain) {
$l10n = _get_reader($domain);
for ($i = 0; $i < $l10n->total; $i++) {
$orig = $l10n->get_original_string($i);
if(strpos($orig, "\000") !== false) { // Plural forms
$key = explode(chr(0), $orig);
print T_js_decl($key[0], _ngettext($key[0], $key[1], 1)); // Singular
print T_js_decl($key[1], _ngettext($key[0], $key[1], 2)); // Plural
} else {
$translation = _dgettext($domain,$orig);
print T_js_decl($orig, $translation);
}
}
}
}
function get_theme_path($theme) {
$check = "themes/$theme";
if (file_exists($check)) return $check;
$check = "themes.local/$theme";
if (file_exists($check)) return $check;
return "";
}
function theme_exists($theme) {
@ -535,63 +443,3 @@
return $ts;
}
/* for package maintainers who don't use git: if version_static.txt exists in tt-rss root
directory, its contents are displayed instead of git commit-based version, this could be generated
based on source git tree commit used when creating the package */
function get_version(&$git_commit = false, &$git_timestamp = false, &$last_error = false) {
global $ttrss_version;
if (is_array($ttrss_version) && isset($ttrss_version['version'])) {
$git_commit = $ttrss_version['commit'];
$git_timestamp = $ttrss_version['timestamp'];
$last_error = $ttrss_version['last_error'] ?? "";
return $ttrss_version['version'];
} else {
$ttrss_version = [];
}
$ttrss_version['version'] = "UNKNOWN (Unsupported)";
date_default_timezone_set('UTC');
$root_dir = dirname(__DIR__);
if (PHP_OS === "Darwin") {
$ttrss_version['version'] = "UNKNOWN (Unsupported, Darwin)";
} else if (file_exists("$root_dir/version_static.txt")) {
$ttrss_version['version'] = trim(file_get_contents("$root_dir/version_static.txt")) . " (Unsupported)";
} else if (is_dir("$root_dir/.git")) {
$rc = 0;
$output = [];
$cwd = getcwd();
chdir($root_dir);
exec('git --no-pager log --pretty="version: %ct %h" -n1 HEAD 2>&1', $output, $rc);
chdir($cwd);
if (is_array($output) && count($output) > 0) {
list ($test, $timestamp, $commit) = explode(" ", $output[0], 3);
if ($test == "version:") {
$git_commit = $commit;
$git_timestamp = $timestamp;
$ttrss_version['version'] = strftime("%y.%m", $timestamp) . "-$commit";
$ttrss_version['commit'] = $commit;
$ttrss_version['timestamp'] = $timestamp;
}
}
if (!isset($ttrss_version['commit'])) {
$last_error = "Unable to determine version (using $root_dir): RC=$rc; OUTPUT=" . implode("\n", $output);
$ttrss_version["last_error"] = $last_error;
user_error($last_error, E_USER_WARNING);
}
}
return $ttrss_version['version'];
}

@ -15,9 +15,7 @@
} ?>
<?php if (theme_exists(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))) {
echo stylesheet_tag(get_theme_path(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET)));
} ?>
<?= Config::get_override_links() ?>
<style type="text/css">
@media (prefers-color-scheme: dark) {
@ -85,7 +83,7 @@
</script>
<?php $return = urlencode(make_self_url()) ?>
<?php $return = urlencode(!empty($_REQUEST['return']) ? $_REQUEST['return'] : with_trailing_slash(Config::make_self_url())) ?>
<div class="container">

@ -1,215 +0,0 @@
<?php
/* WARNING! If you modify this file, you are ON YOUR OWN! */
function make_self_url() {
$proto = is_server_https() ? 'https' : 'http';
return $proto . '://' . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"];
}
function make_self_url_path() {
if (!isset($_SERVER["HTTP_HOST"])) return false;
$proto = is_server_https() ? 'https' : 'http';
$url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
return $url_path;
}
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([Config::get(Config::DB_NAME)]);
$bad_tables = [];
while ($line = $sth->fetch()) {
array_push($bad_tables, $line);
}
return $bad_tables;
}
function initial_sanity_check() {
$errors = array();
if (!file_exists("config.php")) {
array_push($errors, "Configuration file not found. Looks like you forgot to copy config.php-dist to config.php and edit it.");
} else {
if (!file_exists("config.php")) {
array_push($errors, "Please copy config.php-dist to config.php");
}
if (strpos(Config::get(Config::PLUGINS), "auth_") === false) {
array_push($errors, "Please enable at least one authentication module via Config::get(Config::PLUGINS) constant in config.php");
}
if (function_exists('posix_getuid') && posix_getuid() == 0) {
array_push($errors, "Please don't run this script as root.");
}
if (version_compare(PHP_VERSION, '7.0.0', '<')) {
array_push($errors, "PHP version 7.0.0 or newer required. You're using " . PHP_VERSION . ".");
}
if (!class_exists("UConverter")) {
array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module.");
}
if (!is_writable(Config::get(Config::CACHE_DIR) . "/images")) {
array_push($errors, "Image cache is not writable (chmod -R 777 ".Config::get(Config::CACHE_DIR)."/images)");
}
if (!is_writable(Config::get(Config::CACHE_DIR) . "/upload")) {
array_push($errors, "Upload cache is not writable (chmod -R 777 ".Config::get(Config::CACHE_DIR)."/upload)");
}
if (!is_writable(Config::get(Config::CACHE_DIR) . "/export")) {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".Config::get(Config::CACHE_DIR)."/export)");
}
if (Config::get(Config::SINGLE_USER_MODE) && class_exists("PDO")) {
$pdo = Db::pdo();
$res = $pdo->query("SELECT id FROM ttrss_users WHERE id = 1");
if (!$res->fetch()) {
array_push($errors, "Config::get(Config::SINGLE_USER_MODE) is enabled in config.php but default admin account is not found.");
}
}
if (php_sapi_name() != "cli") {
$ref_self_url_path = make_self_url_path();
if ($ref_self_url_path) {
$ref_self_url_path = preg_replace("/\w+\.php$/", "", $ref_self_url_path);
}
if (Config::get(Config::SELF_URL_PATH) == "http://example.org/tt-rss/") {
$hint = $ref_self_url_path ? "(possible value: <b>$ref_self_url_path</b>)" : "";
array_push($errors,
"Please set Config::get(Config::SELF_URL_PATH) to the correct value for your server: $hint");
}
if ($ref_self_url_path &&
(!defined('_SKIP_SELF_URL_PATH_CHECKS') || !_SKIP_SELF_URL_PATH_CHECKS) &&
Config::get(Config::SELF_URL_PATH) != $ref_self_url_path && Config::get(Config::SELF_URL_PATH) != mb_substr($ref_self_url_path, 0, mb_strlen($ref_self_url_path)-1)) {
array_push($errors,
"Please set Config::get(Config::SELF_URL_PATH) to the correct value detected for your server: <b>$ref_self_url_path</b> (you're using: <b>" . Config::get(Config::SELF_URL_PATH) . "</b>)");
}
}
if (!is_writable(Config::get(Config::ICONS_DIR))) {
array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".Config::get(Config::ICONS_DIR).").\n");
}
if (!is_writable(Config::get(Config::LOCK_DIRECTORY))) {
array_push($errors, "Config::get(Config::LOCK_DIRECTORY) defined in config.php is not writable (chmod -R 777 ".Config::get(Config::LOCK_DIRECTORY).").\n");
}
if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) {
array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL.");
}
if (!function_exists("json_encode")) {
array_push($errors, "PHP support for JSON is required, but was not found.");
}
if (!class_exists("PDO")) {
array_push($errors, "PHP support for PDO is required but was not found.");
}
if (!function_exists("mb_strlen")) {
array_push($errors, "PHP support for mbstring functions is required but was not found.");
}
if (!function_exists("hash")) {
array_push($errors, "PHP support for hash() function is required but was not found.");
}
if (ini_get("safe_mode")) {
array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss.");
}
if (!function_exists("mime_content_type")) {
array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module.");
}
if (!class_exists("DOMDocument")) {
array_push($errors, "PHP support for DOMDocument is required, but was not found.");
}
if (Config::get(Config::DB_TYPE) == "mysql") {
$bad_tables = 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") { ?>
<!DOCTYPE html>
<html>
<head>
<title>Startup failed</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link rel="stylesheet" type="text/css" href="themes/light.css">
</head>
<body class='sanity_failed claro ttrss_utility'>
<div class="content">
<h1>Startup failed</h1>
<p>Tiny Tiny RSS was unable to start properly. This usually means a misconfiguration or an incomplete upgrade. Please fix
errors indicated by the following messages:</p>
<?php foreach ($errors as $error) { echo format_error($error); } ?>
<p>You might want to check tt-rss <a href="https://tt-rss.org/wiki.php">wiki</a> or the
<a href="https://community.tt-rss.org/">forums</a> for more information. Please search the forums before creating new topic
for your question.</p>
</div>
</body>
</html>
<?php
die;
} else if (count($errors) > 0) {
echo "Tiny Tiny RSS was unable to start properly. This usually means a misconfiguration or an incomplete upgrade.\n";
echo "Please fix errors indicated by the following messages:\n\n";
foreach ($errors as $error) {
echo " * " . strip_tags($error)."\n";
}
echo "\nYou might want to check tt-rss wiki or the forums for more information.\n";
echo "Please search the forums before creating new topic for your question.\n";
exit(-1);
}
}
initial_sanity_check();
?>

@ -9,7 +9,7 @@
$session_expire = min(2147483647 - time() - 1, max(\Config::get(\Config::SESSION_COOKIE_LIFETIME), 86400));
$session_name = \Config::get(\Config::SESSION_NAME);
if (is_server_https()) {
if (\Config::is_server_https()) {
ini_set("session.cookie_secure", "true");
}
@ -19,59 +19,32 @@
ini_set("session.gc_maxlifetime", $session_expire);
ini_set("session.cookie_lifetime", "0");
function session_get_schema_version() {
global $schema_version;
if (!$schema_version) {
$row = \Db::pdo()->query("SELECT schema_version FROM ttrss_version")->fetch();
$version = $row["schema_version"];
$schema_version = $version;
return $version;
} else {
return $schema_version;
}
}
// prolong PHP session cookie
if (isset($_COOKIE[$session_name]))
setcookie($session_name,
$_COOKIE[$session_name],
time() + $session_expire,
ini_get("session.cookie_path"),
ini_get("session.cookie_domain"),
ini_get("session.cookie_secure"),
ini_get("session.cookie_httponly"));
function validate_session() {
if (\Config::get(\Config::SINGLE_USER_MODE)) return true;
if (isset($_SESSION["ref_schema_version"]) && $_SESSION["ref_schema_version"] != session_get_schema_version()) {
$_SESSION["login_error_msg"] =
__("Session failed to validate (schema version changed)");
return false;
}
$pdo = \Db::pdo();
if (!empty($_SESSION["uid"])) {
$user = \ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
if ($_SESSION["user_agent"] != sha1($_SERVER['HTTP_USER_AGENT'])) {
$_SESSION["login_error_msg"] = __("Session failed to validate (UA changed).");
return false;
}
$sth = $pdo->prepare("SELECT pwd_hash FROM ttrss_users WHERE id = ?");
$sth->execute([$_SESSION['uid']]);
// user not found
if ($row = $sth->fetch()) {
$pwd_hash = $row["pwd_hash"];
if ($pwd_hash != $_SESSION["pwd_hash"]) {
$_SESSION["login_error_msg"] =
__("Session failed to validate (password changed)");
if ($user) {
if ($user->pwd_hash != $_SESSION["pwd_hash"]) {
$_SESSION["login_error_msg"] = __("Session failed to validate (password changed)");
return false;
}
} else {
$_SESSION["login_error_msg"] =
__("Session failed to validate (user not found)");
$_SESSION["login_error_msg"] = __("Session failed to validate (user not found)");
return false;
}
}
@ -142,16 +115,17 @@
return true;
}
if (!\Config::get(\Config::SINGLE_USER_MODE)) {
if (\Config::get_schema_version() >= 0) {
session_set_save_handler('\Sessions\ttrss_open',
'\Sessions\ttrss_close', '\Sessions\ttrss_read',
'\Sessions\ttrss_write', '\Sessions\ttrss_destroy',
'\Sessions\ttrss_gc');
register_shutdown_function('session_write_close');
}
if (!defined('NO_SESSION_AUTOSTART')) {
if (isset($_COOKIE[session_name()])) {
@session_start();
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
}
}
}

@ -13,14 +13,14 @@
require_once "autoload.php";
require_once "sessions.php";
require_once "functions.php";
require_once "sanity_check.php";
Config::sanity_check();
if (!init_plugins()) return;
UserHelper::login_sequence();
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
@ -29,15 +29,13 @@
<meta name="viewport" content="initial-scale=1,width=device-width" />
<?php if ($_SESSION["uid"] && empty($_SESSION["safe_mode"])) {
$theme = get_pref("USER_CSS_THEME", false, false);
$theme = get_pref(Prefs::USER_CSS_THEME);
if ($theme && theme_exists("$theme")) {
echo stylesheet_tag(get_theme_path($theme), 'theme_css');
echo stylesheet_tag(get_theme_path($theme), ['id' => 'theme_css']);
}
} ?>
<?php if (theme_exists(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))) {
echo stylesheet_tag(get_theme_path(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET)));
} ?>
<?= Config::get_override_links() ?>
<script type="text/javascript">
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
@ -97,8 +95,6 @@
}
}
}
init_js_translations();
?>
</script>
@ -114,19 +110,31 @@
}
</style>
<noscript>
<?= stylesheet_tag("themes/light.css") ?>
<style type="text/css">
body.css_loading noscript {
display : block;
margin : 16px;
}
</style>
</noscript>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="referrer" content="no-referrer"/>
</head>
<body class="flat ttrss_main ttrss_index css_loading">
<div id="overlay" style="display : block">
<noscript class="alert alert-error"><?= ('Javascript is disabled. Please enable it.') ?></noscript>
<div id="overlay">
<div id="overlay_inner">
<?= __("Loading, please wait...") ?>
<div dojoType="dijit.ProgressBar" places="0" style="width : 300px" id="loading_bar"
progress="0" maximum="100">
</div>
<noscript><br/><?php print_error('Javascript is disabled. Please enable it.') ?></noscript>
</div>
</div>
@ -135,9 +143,10 @@
<div id="main" dojoType="dijit.layout.BorderContainer">
<div id="feeds-holder" dojoType="dijit.layout.ContentPane" region="leading" style="width : 20%" splitter="true">
<div id="feedlistLoading">
<img src='images/indicator_tiny.gif'/>
<?= __("Loading, please wait..."); ?></div>
<div id="feedlistLoading" class="text-center text-muted text-small">
<img class="icon-three-dots" src="images/three-dots.svg?2">
<?= __("Loading, please wait..."); ?>
</div>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_FEED_TREE, function ($result) {
echo $result;
@ -150,30 +159,35 @@
<div id="toolbar-frame" dojoType="dijit.layout.ContentPane" region="top">
<div id="toolbar" dojoType="fox.Toolbar">
<i class="material-icons net-alert" style="display : none"
title="<?= __("Communication problem with server.") ?>">error_outline</i>
<i class="material-icons log-alert" style="display : none" onclick="App.openPreferences('system')"
title="<?= __("Recent entries found in event log.") ?>">warning</i>
<i id="updates-available" class="material-icons icon-new-version" style="display : none"
title="<?= __('Updates are available from Git.') ?>">new_releases</i>
<!-- order 0, default -->
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_MAIN_TOOLBAR_BUTTON, function ($result) {
echo $result;
});
?>
<div id="toolbar-headlines" dojoType="fox.Toolbar" style="order : 10">
<!-- order 5: alert icons -->
</div>
<i class="material-icons net-alert" style="display : none; order : 5"
title="<?= __("Communication problem with server.") ?>">error_outline</i>
<form id="toolbar-main" action="" style="order : 20" onsubmit='return false'>
<i class="material-icons log-alert" style="display : none; order : 5" onclick="App.openPreferences('system')"
title="<?= __("Recent entries found in event log.") ?>">warning</i>
<i id="updates-available" class="material-icons icon-new-version" style="display : none; order: 5"
title="<?= __('Updates are available from Git.') ?>">new_releases</i>
<!-- order 10: headlines toolbar -->
<div id="toolbar-headlines" dojoType="fox.Toolbar" style="order : 10"> </div>
<!-- order 20: main toolbar contents (dropdowns) -->
<form id="toolbar-main" dojoType="dijit.form.Form" action="" style="order : 20" onsubmit="return false">
<select name="view_mode" title="<?= __('Show articles') ?>"
onchange="App.onViewModeChanged()"
onchange="Feeds.onViewModeChanged()"
dojoType="fox.form.Select">
<option selected="selected" value="adaptive"><?= __('Adaptive') ?></option>
<option value="all_articles"><?= __('All Articles') ?></option>
@ -181,11 +195,10 @@
<option value="published"><?= __('Published') ?></option>
<option value="unread"><?= __('Unread') ?></option>
<option value="has_note"><?= __('With Note') ?></option>
<!-- <option value="noscores"><?= __('Ignore Scoring') ?></option> -->
</select>
<select title="<?= __('Sort articles') ?>"
onchange="App.onViewModeChanged()"
onchange="Feeds.onViewModeChanged()"
dojoType="fox.form.Select" name="order_by">
<option selected="selected" value="default"><?= __('Default') ?></option>
@ -202,7 +215,7 @@
?>
</select>
<div dojoType="fox.form.ComboButton" onclick="Feeds.catchupCurrent()">
<div class="catchup-button" dojoType="fox.form.ComboButton" onclick="Feeds.catchupCurrent()">
<span><?= __('Mark as read') ?></span>
<div dojoType="dijit.DropDownMenu">
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1day')">
@ -219,6 +232,8 @@
</form>
<!-- toolbar actions dropdown: order 30 -->
<div class="action-chooser" style="order : 30">
<?php

@ -17,6 +17,24 @@ const App = {
hotkey_actions: {},
is_prefs: false,
LABEL_BASE_INDEX: -1024,
_translations: {},
Hash: {
get: function() {
return dojo.queryToObject(window.location.hash.substring(1));
},
set: function(params) {
const obj = dojo.queryToObject(window.location.hash.substring(1));
window.location.hash = dojo.objectToQuery({...obj, ...params});
}
},
l10n: {
ngettext: function(msg1, msg2, n) {
return self.__((parseInt(n) > 1) ? msg2 : msg1);
},
__: function(msg) {
return App._translations[msg] ? App._translations[msg] : msg;
}
},
FormFields: {
attributes_to_string: function(attributes) {
return Object.keys(attributes).map((k) =>
@ -43,8 +61,9 @@ const App = {
return this.button_tag(value, "", {...{onclick: "App.dialogOf(this).hide()"}, ...attributes});
},
checkbox_tag: function(name, checked = false, value = "", attributes = {}, id = "") {
// checked !== '0' prevents mysql "boolean" false to be implicitly cast as true
return `<input dojoType="dijit.form.CheckBox" type="checkbox" name="${App.escapeHtml(name)}"
${checked ? "checked" : ""}
${checked !== '0' && checked ? "checked" : ""}
${value ? `value="${App.escapeHtml(value)}"` : ""}
${this.attributes_to_string(attributes)} id="${App.escapeHtml(id)}">`
},
@ -409,7 +428,7 @@ const App = {
if (error && error.code && error.code != App.Error.E_SUCCESS) {
console.warn("handleRpcJson: fatal error", error);
this.Error.fatal(error.code);
this.Error.fatal(error.code, error.params);
return false;
}
@ -495,9 +514,12 @@ const App = {
this.LABEL_BASE_INDEX = parseInt(params[k]);
break;
case "cdm_auto_catchup":
if (params[k] == 1) {
const hl = App.byId("headlines-frame");
if (hl) hl.addClassName("auto_catchup");
{
const headlines = App.byId("headlines-frame");
// we could be in preferences
if (headlines)
headlines.setAttribute("data-auto-catchup", params[k] ? "true" : "false");
}
break;
case "hotkeys":
@ -525,12 +547,20 @@ const App = {
PluginHost.run(PluginHost.HOOK_PARAMS_LOADED, this._initParams);
}
const translations = reply['translations'];
if (translations) {
console.log('reading translations...');
App._translations = translations;
}
this.initSecondStage();
},
Error: {
E_SUCCESS: "E_SUCCESS",
E_UNAUTHORIZED: "E_UNAUTHORIZED",
E_SCHEMA_MISMATCH: "E_SCHEMA_MISMATCH",
E_URL_SCHEME_MISMATCH: "E_URL_SCHEME_MISMATCH",
fatal: function (error, params = {}) {
if (error == App.Error.E_UNAUTHORIZED) {
window.location.href = "index.php";
@ -538,9 +568,14 @@ const App = {
} else if (error == App.Error.E_SCHEMA_MISMATCH) {
window.location.href = "public.php?op=dbupdate";
return;
} else if (error == App.Error.E_URL_SCHEME_MISMATCH) {
params.description = __("URL scheme reported by your browser (%a) doesn't match server-configured SELF_URL_PATH (%b), check X-Forwarded-Proto.")
.replace("%a", params.client_scheme)
.replace("%b", params.server_scheme);
params.info = `SELF_URL_PATH: ${params.self_url_path}\nCLIENT_LOCATION: ${document.location.href}`
}
return this.report(__("Fatal error: %s").replace("%s", error),
return this.report(error,
{...{title: __("Fatal error")}, ...params});
},
report: function(error, params = {}) {
@ -571,10 +606,13 @@ const App = {
<div class='exception-contents'>
<h3>${message}</h3>
<header>${__('Stack trace')}</header>
${params.description ? `<p>${params.description}</p>` : ''}
${error.stack ?
`<header>${__('Stack trace')}</header>
<section>
<textarea readonly='readonly'>${error.stack}</textarea>
</section>
</section>` : ''}
${params && params.info ?
`
@ -634,7 +672,8 @@ const App = {
op: "rpc",
method: "sanityCheck",
clientTzOffset: new Date().getTimezoneOffset() * 60,
hasSandbox: "sandbox" in document.createElement("iframe")
hasSandbox: "sandbox" in document.createElement("iframe"),
clientLocation: window.location.href
};
xhr.json("backend.php", params, (reply) => {
@ -649,15 +688,16 @@ const App = {
checkBrowserFeatures: function() {
let errorMsg = "";
['MutationObserver'].forEach(function(wf) {
if (!(wf in window)) {
errorMsg = `Browser feature check failed: <code>window.${wf}</code> not found.`;
['MutationObserver', 'requestIdleCallback'].forEach((t) => {
if (!(t in window)) {
errorMsg = `Browser check failed: <code>window.${t}</code> not found.`;
throw new Error(errorMsg);
}
});
if (errorMsg) {
this.Error.fatal(errorMsg, {info: navigator.userAgent});
if (typeof Promise.allSettled == "undefined") {
errorMsg = `Browser check failed: <code>Promise.allSettled</code> is not defined.`;
throw new Error(errorMsg);
}
return errorMsg == "";
@ -741,18 +781,15 @@ const App = {
}
});
const toolbar = document.forms["toolbar-main"];
dijit.getEnclosingWidget(toolbar.view_mode).attr('value',
this.getInitParam("default_view_mode"));
dijit.getEnclosingWidget(toolbar.order_by).attr('value',
this.getInitParam("default_view_order_by"));
dijit.byId('toolbar-main').setValues({
view_mode: this.getInitParam("default_view_mode"),
order_by: this.getInitParam("default_view_order_by")
});
this.setLoadingProgress(50);
this._widescreen_mode = this.getInitParam("widescreen");
this.switchPanelMode(this._widescreen_mode);
this.setWidescreen(this._widescreen_mode);
Headlines.initScrollHandler();
@ -785,10 +822,23 @@ const App = {
.then((reply) => {
console.log('update reply', reply);
if (reply.id) {
App.byId("updates-available").show();
const icon = App.byId("updates-available");
if (reply.changeset.id || reply.plugins.length > 0) {
icon.show();
const tips = [];
if (reply.changeset.id)
tips.push(__("Updates for Tiny Tiny RSS are available."));
if (reply.plugins.length > 0)
tips.push(__("Updates for some local plugins are available."));
icon.setAttribute("title", tips.join("\n"));
} else {
App.byId("updates-available").hide();
icon.hide();
}
});
},
@ -801,13 +851,6 @@ const App = {
document.title = tmp;
},
onViewModeChanged: function() {
const view_mode = document.forms["toolbar-main"].view_mode.value;
App.findAll("body")[0].setAttribute("view-mode", view_mode);
return Feeds.reloadCurrent('');
},
hotkeyHandler: function(event) {
if (event.target.nodeName == "INPUT" || event.target.nodeName == "TEXTAREA") return;
@ -827,48 +870,51 @@ const App = {
}
}
},
switchPanelMode: function(wide) {
setWidescreen: function(wide) {
const article_id = Article.getActive();
const headlines_frame = App.byId("headlines-frame");
const content_insert = dijit.byId("content-insert");
// TODO: setStyle stuff should probably be handled by CSS
if (wide) {
dijit.byId("headlines-wrap-inner").attr("design", 'sidebar');
dijit.byId("content-insert").attr("region", "trailing");
content_insert.attr("region", "trailing");
dijit.byId("content-insert").domNode.setStyle({width: '50%',
content_insert.domNode.setStyle({width: '50%',
height: 'auto',
borderTopWidth: '0px' });
if (parseInt(Cookie.get("ttrss_ci_width")) > 0) {
dijit.byId("content-insert").domNode.setStyle(
content_insert.domNode.setStyle(
{width: Cookie.get("ttrss_ci_width") + "px" });
}
App.byId("headlines-frame").setStyle({ borderBottomWidth: '0px' });
App.byId("headlines-frame").addClassName("wide");
headlines_frame.setStyle({ borderBottomWidth: '0px' });
} else {
dijit.byId("content-insert").attr("region", "bottom");
content_insert.attr("region", "bottom");
dijit.byId("content-insert").domNode.setStyle({width: 'auto',
content_insert.domNode.setStyle({width: 'auto',
height: '50%',
borderTopWidth: '0px'});
if (parseInt(Cookie.get("ttrss_ci_height")) > 0) {
dijit.byId("content-insert").domNode.setStyle(
content_insert.domNode.setStyle(
{height: Cookie.get("ttrss_ci_height") + "px" });
}
App.byId("headlines-frame").setStyle({ borderBottomWidth: '1px' });
App.byId("headlines-frame").removeClassName("wide");
headlines_frame.setStyle({ borderBottomWidth: '1px' });
}
headlines_frame.setAttribute("data-is-wide-screen", wide ? "true" : "false");
Article.close();
if (article_id) Article.view(article_id);
xhr.post("backend.php", {op: "rpc", method: "setpanelmode", wide: wide ? 1 : 0});
xhr.post("backend.php", {op: "rpc", method: "setWidescreen", wide: wide ? 1 : 0});
},
initHotkeyActions: function() {
if (this.is_prefs) {
@ -892,16 +938,32 @@ const App = {
} else {
this.hotkey_actions["next_feed"] = () => {
const rv = dijit.byId("feedTree").getNextFeed(
const [feed, is_cat] = Feeds.getNextFeed(
Feeds.getActive(), Feeds.activeIsCat());
if (rv) Feeds.open({feed: rv[0], is_cat: rv[1], delayed: true})
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["next_unread_feed"] = () => {
const [feed, is_cat] = Feeds.getNextFeed(
Feeds.getActive(), Feeds.activeIsCat(), true);
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["prev_feed"] = () => {
const rv = dijit.byId("feedTree").getPreviousFeed(
const [feed, is_cat] = Feeds.getPreviousFeed(
Feeds.getActive(), Feeds.activeIsCat());
if (rv) Feeds.open({feed: rv[0], is_cat: rv[1], delayed: true})
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["prev_unread_feed"] = () => {
const [feed, is_cat] = Feeds.getPreviousFeed(
Feeds.getActive(), Feeds.activeIsCat(), true);
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["next_article_or_scroll"] = (event) => {
if (this.isCombinedMode())
@ -1063,6 +1125,12 @@ const App = {
this.hotkey_actions["feed_reverse"] = () => {
Headlines.reverse();
};
this.hotkey_actions["feed_toggle_grid"] = () => {
xhr.json("backend.php", {op: "rpc", method: "togglepref", key: "CDM_ENABLE_GRID"}, (reply) => {
App.setInitParam("cdm_enable_grid", reply.value);
Headlines.renderAgain();
})
};
this.hotkey_actions["feed_toggle_vgroup"] = () => {
xhr.post("backend.php", {op: "rpc", method: "togglepref", key: "VFEED_GROUP_BY_FEED"}, () => {
Feeds.reloadCurrent();
@ -1128,7 +1196,7 @@ const App = {
Cookie.set("ttrss_ci_width", 0);
Cookie.set("ttrss_ci_height", 0);
this.switchPanelMode(this._widescreen_mode);
this.setWidescreen(this._widescreen_mode);
} else {
alert(__("Widescreen is not available in combined mode."));
}
@ -1155,6 +1223,9 @@ const App = {
Headlines.renderAgain();
});
};
this.hotkey_actions["article_span_grid"] = () => {
Article.cdmToggleGridSpan(Article.getActive());
};
}
},
openPreferences: function(tab) {
@ -1218,7 +1289,7 @@ const App = {
Cookie.set("ttrss_ci_width", 0);
Cookie.set("ttrss_ci_height", 0);
this.switchPanelMode(this._widescreen_mode);
this.setWidescreen(this._widescreen_mode);
} else {
alert(__("Widescreen is not available in combined mode."));
}
@ -1229,6 +1300,6 @@ const App = {
default:
console.log("quickMenuGo: unknown action: " + opid);
}
}
},
}

@ -93,6 +93,16 @@ const Article = {
w.opener = null;
w.location = url;
},
cdmToggleGridSpan: function(id) {
const row = App.byId(`RROW-${id}`);
if (row) {
row.toggleClassName('grid-span-row');
this.setActive(id);
this.cdmMoveToId(id);
}
},
cdmUnsetActive: function (event) {
const row = App.byId(`RROW-${Article.getActive()}`);
@ -144,10 +154,15 @@ const Article = {
).join(", ") : `${__("no tags")}`}</span>`;
},
renderLabels: function(id, labels) {
return `<span class="labels" data-labels-for="${id}">${labels.map((label) => `
<span class="label" data-label-id="${label[0]}"
style="color : ${label[2]}; background-color : ${label[3]}">${App.escapeHtml(label[1])}</span>`
).join("")}</span>`;
return `<span class="labels" data-labels-for="${id}">
${labels.map((label) => `
<a href="#" class="label" data-label-id="${label[0]}"
style="color : ${label[2]}; background-color : ${label[3]}"
onclick="event.stopPropagation(); Feeds.open({feed:'${label[0]}'})">
${App.escapeHtml(label[1])}
</a>`
).join("")}
</span>`;
},
renderEnclosures: function (enclosures) {
return `
@ -240,12 +255,12 @@ const Article = {
return comments;
},
unpack: function(row) {
if (row.hasAttribute("data-content")) {
if (row.getAttribute("data-is-packed") == "1") {
console.log("unpacking: " + row.id);
const container = row.querySelector(".content-inner");
container.innerHTML = row.getAttribute("data-content").trim();
container.innerHTML = row.getAttribute("data-content").trim() + row.getAttribute("data-rendered-enclosures").trim();
dojo.parser.parse(container);
@ -257,18 +272,23 @@ const Article = {
if (App.isCombinedMode() && App.byId("main").hasClassName("expandable"))
row.setAttribute("data-content-original", row.getAttribute("data-content"));
row.removeAttribute("data-content");
row.setAttribute("data-is-packed", "0");
PluginHost.run(PluginHost.HOOK_ARTICLE_RENDERED_CDM, row);
}
},
pack: function(row) {
if (row.hasAttribute("data-content-original")) {
if (row.getAttribute("data-is-packed") != "1") {
console.log("packing", row.id);
row.setAttribute("data-content", row.getAttribute("data-content-original"));
row.removeAttribute("data-content-original");
row.setAttribute("data-is-packed", "1");
const content_inner = row.querySelector(".content-inner");
row.querySelector(".content-inner").innerHTML = "&nbsp;";
// missing in unexpanded mode
if (content_inner)
content_inner.innerHTML = `<div class="text-center text-muted">
${__("Loading, please wait...")}
</div>`
}
},
view: function (id, no_expand) {
@ -317,7 +337,7 @@ const Article = {
},
editTags: function (id) {
const dialog = new fox.SingleUseDialog({
title: __("Edit article Tags"),
title: __("Article tags"),
content: `
${App.FormFields.hidden_tag("id", id.toString())}
${App.FormFields.hidden_tag("op", "article")}
@ -329,7 +349,7 @@ const Article = {
<section>
<textarea dojoType='dijit.form.SimpleTextarea' rows='4' disabled='true'
id='tags_str' name='tags_str'></textarea>
id='tags_str' name='tags_str'>${__("Loading, please wait...")}</textarea>
<div class='autocomplete' id='tags_choices' style='display:none'></div>
</section>
@ -384,10 +404,12 @@ const Article = {
const ctr = App.byId("headlines-frame");
const row = App.byId(`RROW-${id}`);
if (!row || !ctr) return;
if (ctr && row) {
const grid_gap = parseInt(window.getComputedStyle(ctr).gridGap) || 0;
if (force_to_top || !App.Scrollable.fitsInContainer(row, ctr)) {
ctr.scrollTop = row.offsetTop;
ctr.scrollTop = row.offsetTop - grid_gap;
}
}
},
setActive: function (id) {
@ -396,6 +418,8 @@ const Article = {
App.findAll("div[id*=RROW][class*=active]").forEach((row) => {
row.removeClassName("active");
if (App.isCombinedMode() && !App.getInitParam("cdm_expanded"))
Article.pack(row);
});

@ -3,7 +3,7 @@
/* eslint-disable new-cap */
/* eslint-disable no-new */
/* global __, dojo, dijit, Notify, App, Feeds, xhrPost, xhr, Tables, fox */
/* global __, dojo, dijit, Notify, App, Feeds, xhr, Tables, fox */
/* exported CommonDialogs */
const CommonDialogs = {
@ -16,7 +16,7 @@ const CommonDialogs = {
{op: "feeds", method: "subscribeToFeed"},
(reply) => {
const dialog = new fox.SingleUseDialog({
title: __("Subscribe to Feed"),
title: __("Subscribe to feed"),
content: `
<form onsubmit='return false'>
@ -33,7 +33,7 @@ const CommonDialogs = {
<section>
<fieldset>
<div style='float : right'><img style='display : none' id='feed_add_spinner' src='images/indicator_white.gif'></div>
<div class='pull-right'><img style='display : none' id='feed_add_spinner' src='${App.getInitParam('icon_oval')}'></div>
<input style='font-size : 16px; width : 500px;'
placeHolder="${__("Feed or site URL")}"
dojoType='dijit.form.ValidationTextBox'
@ -181,7 +181,7 @@ const CommonDialogs = {
}
} catch (e) {
console.error(transport.responseText);
console.error(reply);
App.Error.report(e);
}
});
@ -248,7 +248,7 @@ const CommonDialogs = {
${reply.map((row) => `
<tr data-row-id='${row.id}'>
<td width='5%' align='center'>
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this)' dojoType="dijit.form.CheckBox"
type="checkbox">
</td>
@ -333,8 +333,12 @@ const CommonDialogs = {
const dialog = new fox.SingleUseDialog({
id: "feedEditDlg",
title: __("Edit Feed"),
title: __("Edit feed"),
feed_title: "",
E_ICON_FILE_TOO_LARGE: 'E_ICON_FILE_TOO_LARGE',
E_ICON_RENAME_FAILED: 'E_ICON_RENAME_FAILED',
E_ICON_UPLOAD_FAILED: 'E_ICON_UPLOAD_FAILED',
E_ICON_UPLOAD_SUCCESS: 'E_ICON_UPLOAD_SUCCESS',
unsubscribe: function() {
if (confirm(__("Unsubscribe from %s?").replace("%s", this.feed_title))) {
dialog.hide();
@ -361,20 +365,18 @@ const CommonDialogs = {
xhr.open( 'POST', 'backend.php', true );
xhr.onload = function () {
console.log(this.responseText);
const ret = JSON.parse(this.responseText);
// TODO: make a notice box within panel content
switch (parseInt(this.responseText)) {
case 1:
Notify.error("Upload failed: icon is too big.");
switch (ret.rc) {
case dialog.E_ICON_FILE_TOO_LARGE:
alert(__("Icon file is too large."));
break;
case 2:
Notify.error("Upload failed.");
case dialog.E_ICON_UPLOAD_FAILED:
alert(__("Upload failed."));
break;
default:
case dialog.E_ICON_UPLOAD_SUCCESS:
{
Notify.info("Upload complete.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
@ -383,12 +385,16 @@ const CommonDialogs = {
const icon = dialog.domNode.querySelector(".feedIcon");
if (icon) {
icon.src = this.responseText;
icon.src = ret.icon_url;
icon.show();
}
input.value = "";
}
break;
default:
alert(this.responseText);
break;
}
};
@ -400,9 +406,7 @@ const CommonDialogs = {
if (confirm(__("Remove stored feed icon?"))) {
Notify.progress("Removing feed icon...", true);
const query = {op: "pref-feeds", method: "removeicon", feed_id: id};
xhr.post("backend.php", query, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "removeicon", feed_id: id}, () => {
Notify.info("Feed icon removed.");
if (App.isPrefs())
@ -473,8 +477,8 @@ const CommonDialogs = {
<section>
<fieldset>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Feed Title")}"
style='font-size : 16px; width: 500px' name='title' value="${App.escapeHtml(feed.title)}">
placeHolder="${__("Feed title")}"
style='font-size : 16px; width: 530px' name='title' value="${App.escapeHtml(feed.title)}">
</fieldset>
<fieldset>
@ -565,19 +569,21 @@ const CommonDialogs = {
<div dojoType="dijit.layout.ContentPane" title="${__('Icon')}">
<div><img class='feedIcon' style="${feed.icon ? "" : "display : none"}" src="${feed.icon ? App.escapeHtml(feed.icon) : ""}"></div>
<label class="dijitButton">${__("Upload new icon...")}
<label class="dijitButton">
${App.FormFields.icon("file_upload")}
${__("Upload new icon...")}
<input style="display: none" type="file" onchange="App.dialogOf(this).uploadIcon(this)">
</label>
${App.FormFields.submit_tag(__("Remove"), {class: "alt-danger", onclick: "App.dialogOf(this).removeIcon("+feed_id+")"})}
${App.FormFields.submit_tag(App.FormFields.icon("delete") + " " + __("Remove"), {class: "alt-danger", onclick: "App.dialogOf(this).removeIcon("+feed_id+")"})}
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Plugins')}">
${reply.plugin_data}
</div>
</div>
<footer>
${App.FormFields.button_tag(__("Unsubscribe"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).unsubscribe()"})}
${App.FormFields.submit_tag(__("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.button_tag(App.FormFields.icon("delete") + " " + __("Unsubscribe"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).unsubscribe()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
@ -634,6 +640,7 @@ const CommonDialogs = {
onclick='window.open("https://tt-rss.org/wiki/GeneratedFeeds")'>
<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")}
${__('Generate new URL')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>

@ -11,7 +11,7 @@ const Filters = {
const dialog = new fox.SingleUseDialog({
id: "filterEditDlg",
title: filter_id ? __("Edit Filter") : __("Create Filter"),
title: filter_id ? __("Edit filter") : __("Create new filter"),
ACTION_TAG: 4,
ACTION_SCORE: 6,
ACTION_LABEL: 7,
@ -38,16 +38,19 @@ const Filters = {
console.log("got results:" + result.length);
App.byId("prefFilterProgressMsg").innerHTML = __("Looking for articles (%d processed, %f found)...")
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);
console.log(offset + " " + test_dialog.max_offset);
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("table", { innerHTML: result[i]});
const tmp = dojo.create("div", { innerHTML: result[i]});
App.byId("prefFilterTestResultList").innerHTML += tmp.innerHTML;
results_list.innerHTML += tmp.innerHTML;
}
if (test_dialog.results < 30 && offset < test_dialog.max_offset) {
@ -60,14 +63,15 @@ const Filters = {
} else {
// all done
Element.hide("prefFilterLoadingIndicator");
test_dialog.domNode.querySelector(".loading-indicator").hide();
if (test_dialog.results == 0) {
App.byId("prefFilterTestResultList").innerHTML = `<tr><td align='center'>
${__('No recent articles matching this filter have been found.')}</td></tr>`;
App.byId("prefFilterProgressMsg").innerHTML = "Articles matching this filter:";
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 {
App.byId("prefFilterProgressMsg").innerHTML = __("Found %d articles matching this filter:")
loading_message.innerHTML = __("Found %d articles matching this filter:")
.replace("%d", test_dialog.results);
}
@ -75,7 +79,7 @@ const Filters = {
} else if (!result) {
console.log("getTestResults: can't parse results object");
Element.hide("prefFilterLoadingIndicator");
test_dialog.domNode.querySelector(".loading-indicator").hide();
Notify.error("Error while trying to get filter test results.");
} else {
console.log("getTestResults: dialog closed, bailing out.");
@ -86,12 +90,12 @@ const Filters = {
});
},
content: `
<div>
<img id='prefFilterLoadingIndicator' src='images/indicator_tiny.gif'>&nbsp;
<span id='prefFilterProgressMsg'>Looking for articles...</span>
<div class="text-muted">
<img class="loading-indicator icon-three-dots" src="${App.getInitParam("icon_three_dots")}">
<span class="loading-message">${__("Looking for articles...")}</span>
</div>
<ul class='panel panel-scrollable list list-unstyled' id='prefFilterTestResultList'></ul>
<ul class='panel panel-scrollable list list-unstyled filter-results-list'></ul>
<footer class='text-center'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>${__('Close this window')}</button>
@ -115,7 +119,7 @@ const Filters = {
const li = document.createElement('li');
li.addClassName("rule");
li.innerHTML = `${App.FormFields.checkbox_tag("", false, {onclick: 'Lists.onRowChecked(this)'})}
li.innerHTML = `${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class="name" onclick='App.dialogOf(this).onRuleClicked(this)'>${reply}</span>
<span class="payload" >${App.FormFields.hidden_tag("rule[]", rule)}</span>`;
@ -147,7 +151,7 @@ const Filters = {
const li = document.createElement('li');
li.addClassName("action");
li.innerHTML = `${App.FormFields.checkbox_tag("", false, {onclick: 'Lists.onRowChecked(this)'})}
li.innerHTML = `${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class="name" onclick='App.dialogOf(this).onActionClicked(this)'>${reply}</span>
<span class="payload">${App.FormFields.hidden_tag("action[]", action)}</span>`;
@ -229,7 +233,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')"})}
${App.FormFields.submit_tag(__("Save rule"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
@ -313,7 +317,7 @@ const Filters = {
"filterDlg_actionParamPlugin")}
</section>
<footer>
${App.FormFields.submit_tag(__("Save action"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
@ -511,13 +515,13 @@ const Filters = {
<footer>
${filter_id ?
`
${App.FormFields.button_tag(__("Remove"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).removeFilter()"})}
${App.FormFields.button_tag(__("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(__("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.button_tag(App.FormFields.icon("delete") + " " + __("Remove"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).removeFilter()"})}
${App.FormFields.button_tag(App.FormFields.icon("check_circle") + " " + __("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
` : `
${App.FormFields.button_tag(__("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(__("Create"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.button_tag(App.FormFields.icon("check_circle") + " " + __("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(App.FormFields.icon("add") + " " + __("Create"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
`}
</footer>

@ -54,45 +54,6 @@ define(["dojo/_base/declare", "dijit/tree/ForestStoreModel"], function (declare)
if (treeItem)
return this.store.setValue(treeItem, key, value);
},
getNextUnreadFeed: function (feed, is_cat) {
if (!this.store._itemsByIdentity)
return null;
let treeItem;
if (is_cat) {
treeItem = this.store._itemsByIdentity['CAT:' + feed];
} else {
treeItem = this.store._itemsByIdentity['FEED:' + feed];
}
const items = this.store._arrayOfAllItems;
for (let i = 0; i < items.length; i++) {
if (items[i] == treeItem) {
for (let j = i + 1; j < items.length; j++) {
const unread = this.store.getValue(items[j], 'unread');
const id = this.store.getValue(items[j], 'id');
if (unread > 0 && ((is_cat && id.match("CAT:")) || (!is_cat && id.match("FEED:")))) {
if (!is_cat || !(this.store.hasAttribute(items[j], 'parent_id') && this.store.getValue(items[j], 'parent_id') == feed)) return items[j];
}
}
for (let j = 0; j < i; j++) {
const unread = this.store.getValue(items[j], 'unread');
const id = this.store.getValue(items[j], 'id');
if (unread > 0 && ((is_cat && id.match("CAT:")) || (!is_cat && id.match("FEED:")))) {
if (!is_cat || !(this.store.hasAttribute(items[j], 'parent_id') && this.store.getValue(items[j], 'parent_id') == feed)) return items[j];
}
}
}
}
return null;
},
hasCats: function () {
if (this.store && this.store._itemsByIdentity)
return this.store._itemsByIdentity['CAT:-1'] != undefined;

@ -82,6 +82,9 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
if (id.match("FEED:")) {
tnode.rowNode.setAttribute('data-feed-id', bare_id);
tnode.rowNode.setAttribute('data-is-cat', "false");
const menu = new dijit.Menu();
menu.row_id = bare_id;
@ -98,6 +101,15 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
CommonDialogs.editFeed(this.getParent().row_id, false);
}}));
menu.addChild(new dijit.MenuItem({
label: __("Open site"),
onClick: function() {
App.postOpenWindow("backend.php", {op: "feeds", method: "opensite",
feed_id: this.getParent().row_id, csrf_token: __csrf_token});
}}));
menu.addChild(new dijit.MenuSeparator());
menu.addChild(new dijit.MenuItem({
label: __("Debug feed"),
onClick: function() {
@ -132,10 +144,18 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
if (id.match("CAT:")) {
tnode.loadingNode = dojo.create('img', { className: 'loadingNode', src: 'images/blank_icon.gif'});
tnode.rowNode.setAttribute('data-feed-id', bare_id);
tnode.rowNode.setAttribute('data-is-cat', "true");
tnode.loadingNode = dojo.create('img', { className: 'loadingNode', src: App.getInitParam('icon_blank')});
domConstruct.place(tnode.loadingNode, tnode.labelNode, 'after');
}
if (id.match("FEED:")) {
tnode.loadingNode = dojo.create('img', { className: 'loadingNode', src: App.getInitParam('icon_blank')});
domConstruct.place(tnode.loadingNode, tnode.expandoNode, 'only');
}
if (id.match("CAT:") && bare_id == -1) {
const menu = new dijit.Menu();
menu.row_id = bare_id;
@ -191,10 +211,15 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
return (item.unread <= 0) ? "dijitTreeLabel" : "dijitTreeLabel Unread";
},
getRowClass: function (item/*, opened */) {
let rc = "dijitTreeRow";
let rc = "dijitTreeRow dijitTreeRowFlex";
const is_cat = String(item.id).indexOf('CAT:') != -1;
if (is_cat)
rc += " Is_Cat";
else
rc += " Is_Feed";
if (!is_cat && item.error != '') rc += " Error";
if (item.unread > 0) rc += " Unread";
if (item.auxcounter > 0) rc += " Has_Aux";
@ -303,7 +328,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}, 0);
}
},
setFeedIcon: function(feed, is_cat, src) {
setIcon: function(feed, is_cat, src) {
let treeNode;
if (is_cat)
@ -313,13 +338,19 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (treeNode) {
treeNode = treeNode[0];
const icon = dojo.create('img', { src: src, className: 'icon' });
domConstruct.place(icon, treeNode.iconNode, 'only');
// could be <i material>
const icon = treeNode.iconNode.querySelector('img.icon');
if (icon) {
icon.src = src;
return true;
}
}
return false;
},
setFeedExpandoIcon: function(feed, is_cat, src) {
showLoading: function(feed, is_cat, show) {
let treeNode;
if (is_cat)
@ -329,14 +360,17 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (treeNode) {
treeNode = treeNode[0];
if (treeNode.loadingNode) {
treeNode.loadingNode.src = src;
return true;
if (show) {
treeNode.loadingNode.addClassName("visible");
treeNode.loadingNode.setAttribute("src",
is_cat ? App.getInitParam("icon_three_dots") : App.getInitParam("icon_oval"));
} else {
const icon = dojo.create('img', { src: src, className: 'loadingExpando' });
domConstruct.place(icon, treeNode.expandoNode, 'only');
return true;
treeNode.loadingNode.removeClassName("visible");
treeNode.loadingNode.setAttribute("src", App.getInitParam("icon_blank"))
}
return true
}
return false;
@ -360,47 +394,28 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
},
getNextFeed: function (feed, is_cat) {
let treeItem;
if (is_cat) {
treeItem = this.model.store._itemsByIdentity['CAT:' + feed];
} else {
treeItem = this.model.store._itemsByIdentity['FEED:' + feed];
}
getNextUnread: function(feed, is_cat) {
return this.getNextFeed(feed, is_cat, true);
},
_nextTreeItemFromIndex: function (start, unread_only) {
const items = this.model.store._arrayOfAllItems;
let item = items[0];
for (let i = 0; i < items.length; i++) {
if (items[i] == treeItem) {
for (let j = i+1; j < items.length; j++) {
const id = String(items[j].id);
for (let i = start+1; i < items.length; i++) {
const id = String(items[i].id);
const box = this._itemNodesMap[id];
const unread = parseInt(items[i].unread);
if (box) {
if (box && (!unread_only || unread > 0)) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
if (Element.visible(cat) && Element.visible(row)) {
item = items[j];
break;
}
return items[i];
}
}
break;
}
}
if (item) {
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
} else {
return false;
}
},
getPreviousFeed: function (feed, is_cat) {
getNextFeed: function (feed, is_cat, unread_only = false) {
let treeItem;
if (is_cat) {
@ -410,37 +425,68 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
const items = this.model.store._arrayOfAllItems;
let item = items[0] == treeItem ? items[items.length-1] : items[0];
const start = items.indexOf(treeItem);
for (let i = 0; i < items.length; i++) {
if (items[i] == treeItem) {
if (start != -1) {
let item = this._nextTreeItemFromIndex(start, unread_only);
for (let j = i-1; j > 0; j--) {
const id = String(items[j].id);
// let's try again from the top
// 0 (instead of -1) to skip Special category
if (!item) {
item = this._nextTreeItemFromIndex(0, unread_only);
}
if (item)
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
}
return [false, false];
},
_prevTreeItemFromIndex: function (start, unread_only) {
const items = this.model.store._arrayOfAllItems;
for (let i = start-1; i > 0; i--) {
const id = String(items[i].id);
const box = this._itemNodesMap[id];
const unread = parseInt(items[i].unread);
if (box) {
if (box && (!unread_only || unread > 0)) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
if (Element.visible(cat) && Element.visible(row)) {
item = items[j];
break;
return items[i];
}
}
}
break;
},
getPreviousFeed: function (feed, is_cat, unread_only = false) {
let treeItem;
if (is_cat) {
treeItem = this.model.store._itemsByIdentity['CAT:' + feed];
} else {
treeItem = this.model.store._itemsByIdentity['FEED:' + feed];
}
const items = this.model.store._arrayOfAllItems;
const start = items.indexOf(treeItem);
if (start != -1) {
let item = this._prevTreeItemFromIndex(start, unread_only);
// wrap from the bottom
if (!item) {
item = this._prevTreeItemFromIndex(items.length, unread_only);
}
if (item) {
if (item)
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
} else {
return false;
}
return [false, false];
},
getFeedCategory: function(feed) {
try {

@ -113,23 +113,30 @@ const Feeds = {
this.hideOrShowFeeds(App.getInitParam("hide_read_feeds"));
this._counters_prev = elems;
PluginHost.run(PluginHost.HOOK_COUNTERS_PROCESSED);
PluginHost.run(PluginHost.HOOK_COUNTERS_PROCESSED, elems);
},
reloadCurrent: function(method) {
if (this.getActive() != undefined) {
console.log("reloadCurrent: " + method);
console.log("reloadCurrent", this.getActive(), this.activeIsCat(), method);
this.open({feed: this.getActive(), is_cat: this.activeIsCat(), method: method});
}
return false; // block unneeded form submits
},
openDefaultFeed: function() {
this.open({feed: this._default_feed_id});
},
onViewModeChanged: function() {
// TODO: is this still needed?
App.find("body").setAttribute("view-mode",
dijit.byId("toolbar-main").getValues().view_mode);
return Feeds.reloadCurrent('');
},
openNextUnread: function() {
const is_cat = this.activeIsCat();
const nuf = this.getNextUnread(this.getActive(), is_cat);
if (nuf) this.open({feed: nuf, is_cat: is_cat});
const [feed, is_cat] = this.getNextUnread(this.getActive(), this.activeIsCat());
if (feed !== false)
this.open({feed: feed, is_cat: is_cat});
},
toggle: function() {
Element.toggle("feeds-holder");
@ -236,12 +243,12 @@ const Feeds = {
//document.onkeypress = (event) => { return App.hotkeyHandler(event) };
window.onresize = () => { Headlines.scrollHandler(); }
/* global hash_get */
const hash_feed_id = hash_get('f');
const hash_feed_is_cat = hash_get('c') == "1";
const hash = App.Hash.get();
if (hash_feed_id != undefined) {
this.open({feed: hash_feed_id, is_cat: hash_feed_is_cat});
console.log('got hash', hash);
if (hash.f != undefined) {
this.open({feed: parseInt(hash.f), is_cat: parseInt(hash.c)});
} else {
this.openDefaultFeed();
}
@ -305,15 +312,22 @@ const Feeds = {
setActive: function(id, is_cat) {
console.log('setActive', id, is_cat);
/* global hash_set */
hash_set('f', id);
hash_set('c', is_cat ? 1 : 0);
window.requestIdleCallback(() => {
App.Hash.set({f: id, c: is_cat ? 1 : 0});
});
this._active_feed_id = id;
this._active_feed_is_cat = is_cat;
App.byId("headlines-frame").setAttribute("feed-id", id);
App.byId("headlines-frame").setAttribute("is-cat", is_cat ? 1 : 0);
const container = App.byId("headlines-frame");
// TODO @deprecated: these two should be removed (replaced with data- attributes below)
container.setAttribute("feed-id", id);
container.setAttribute("is-cat", is_cat ? 1 : 0);
// ^
container.setAttribute("data-feed-id", id);
container.setAttribute("data-is-cat", is_cat ? "true" : "false");
this.select(id, is_cat);
@ -366,10 +380,7 @@ const Feeds = {
}, 10 * 1000);
}
//Form.enable("toolbar-main");
let query = Object.assign({op: "feeds", method: "view", feed: feed},
dojo.formToObject("toolbar-main"));
let query = {...{op: "feeds", method: "view", feed: feed}, ...dojo.formToObject("toolbar-main")};
if (method) query.m = method;
@ -389,21 +400,20 @@ const Feeds = {
query.m = "ForceUpdate";
}
if (!delayed)
if (!this.setExpando(feed, is_cat,
(is_cat) ? 'images/indicator_tiny.gif' : 'images/indicator_white.gif'))
Notify.progress("Loading, please wait...", true);
query.cat = is_cat;
this.setActive(feed, is_cat);
window.clearTimeout(this._viewfeed_wait_timeout);
this._viewfeed_wait_timeout = window.setTimeout(() => {
this.showLoading(feed, is_cat, true);
//Notify.progress("Loading, please wait...", true);*/
xhr.json("backend.php", query, (reply) => {
try {
window.clearTimeout(this._infscroll_timeout);
this.setExpando(feed, is_cat, 'images/blank_icon.gif');
this.showLoading(feed, is_cat, false);
Headlines.onLoaded(reply, offset, append);
PluginHost.run(PluginHost.HOOK_FEED_LOADED, [feed, is_cat]);
} catch (e) {
@ -469,10 +479,10 @@ const Feeds = {
// only select next unread feed if catching up entirely (as opposed to last week etc)
if (show_next_feed && !mode) {
const nuf = this.getNextUnread(feed, is_cat);
const [next_feed, next_is_cat] = this.getNextUnread(feed, is_cat);
if (nuf) {
this.open({feed: nuf, is_cat: is_cat});
if (next_feed !== false) {
this.open({feed: next_feed, is_cat: next_is_cat});
}
} else if (feed == this.getActive() && is_cat == this.activeIsCat()) {
this.reloadCurrent();
@ -516,7 +526,7 @@ const Feeds = {
const tree = dijit.byId("feedTree");
if (tree && tree.model)
return tree._cat_of_feed(feed);
return tree.getFeedCategory(feed);
} catch (e) {
//
@ -564,21 +574,35 @@ const Feeds = {
setIcon: function(feed, is_cat, src) {
const tree = dijit.byId("feedTree");
if (tree) return tree.setFeedIcon(feed, is_cat, src);
if (tree) return tree.setIcon(feed, is_cat, src);
},
setExpando: function(feed, is_cat, src) {
showLoading: function(feed, is_cat, show) {
const tree = dijit.byId("feedTree");
if (tree) return tree.setFeedExpandoIcon(feed, is_cat, src);
if (tree) return tree.showLoading(feed, is_cat, show);
return false;
},
getNextFeed: function(feed, is_cat, unread_only = false) {
const tree = dijit.byId("feedTree");
if (tree) return tree.getNextFeed(feed, is_cat, unread_only);
return [false, false];
},
getPreviousFeed: function(feed, is_cat, unread_only = false) {
const tree = dijit.byId("feedTree");
if (tree) return tree.getPreviousFeed(feed, is_cat, unread_only);
return [false, false];
},
getNextUnread: function(feed, is_cat) {
const tree = dijit.byId("feedTree");
const nuf = tree.model.getNextUnreadFeed(feed, is_cat);
if (nuf)
return tree.model.store.getValue(nuf, 'bare_id');
if (tree) return tree.getNextUnread(feed, is_cat);
return [false, false];
},
search: function() {
xhr.json("backend.php",
@ -612,7 +636,7 @@ const Feeds = {
{class: 'alt-info pull-left', onclick: "window.open('https://tt-rss.org/wiki/SearchSyntax')"})}
` : ''}
${App.FormFields.submit_tag(__('Search'), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.submit_tag(App.FormFields.icon("search") + " " + __('Search'), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__('Cancel'))}
</footer>
</form>

@ -17,17 +17,27 @@ const Headlines = {
sticky_header_observer: new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
const header = entry.target.nextElementSibling;
const header = entry.target.closest('.cdm').querySelector(".header");
if (entry.intersectionRatio == 0) {
header.setAttribute("stuck", "1");
} else if (entry.intersectionRatio == 1) {
header.removeAttribute("stuck");
if (entry.isIntersecting) {
header.removeAttribute("data-is-stuck");
} else {
header.setAttribute("data-is-stuck", "true");
}
//console.log(entry.target, header, entry.intersectionRatio);
//console.log(entry.target, entry.intersectionRatio, entry.isIntersecting, entry.boundingClientRect.top);
});
},
{threshold: [0, 1], root: document.querySelector("#headlines-frame")}
),
sticky_content_observer: new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
const header = entry.target.closest('.cdm').querySelector(".header");
header.style.position = entry.isIntersecting ? "sticky" : "unset";
//console.log(entry.target, entry.intersectionRatio, entry.isIntersecting, entry.boundingClientRect.top);
});
},
{threshold: [0, 1], root: document.querySelector("#headlines-frame")}
@ -72,14 +82,13 @@ const Headlines = {
}
});
PluginHost.run(PluginHost.HOOK_HEADLINE_MUTATIONS, mutations);
Headlines.updateSelectedPrompt();
if ('requestIdleCallback' in window)
window.requestIdleCallback(() => {
Headlines.syncModified(modified);
});
else
Headlines.syncModified(modified);
}),
syncModified: function (modified) {
const ops = {
@ -173,14 +182,14 @@ const Headlines = {
});
}
Promise.all(promises).then((results) => {
Promise.allSettled(promises).then((results) => {
let feeds = [];
let labels = [];
results.forEach((res) => {
if (res) {
try {
const obj = JSON.parse(res);
const obj = JSON.parse(res.value);
if (obj.feeds)
feeds = feeds.concat(obj.feeds);
@ -198,6 +207,8 @@ const Headlines = {
console.log('requesting counters for', feeds, labels);
Feeds.requestCounters(feeds, labels);
}
PluginHost.run(PluginHost.HOOK_HEADLINE_MUTATIONS_SYNCED, results);
});
},
click: function (event, id, in_body) {
@ -278,7 +289,7 @@ const Headlines = {
}
},
loadMore: function () {
const view_mode = document.forms["toolbar-main"].view_mode.value;
const view_mode = dijit.byId("toolbar-main").getValues().view_mode;
const unread_in_buffer = App.findAll("#headlines-frame > div[id*=RROW][class*=Unread]").length;
const num_all = App.findAll("#headlines-frame > div[id*=RROW]").length;
const num_unread = Feeds.getUnread(Feeds.getActive(), Feeds.activeIsCat());
@ -340,8 +351,7 @@ const Headlines = {
// invoke lazy load if last article in buffer is nearly visible OR is active
if (Article.getActive() == last_row.getAttribute("data-article-id") || last_row.offsetTop - 250 <= container.scrollTop + container.offsetHeight) {
hsp.innerHTML = "<span class='loading'><img src='images/indicator_tiny.gif'> " +
__("Loading, please wait...") + "</span>";
hsp.innerHTML = `<span class='text-muted text-small text-center'><img class="icon-three-dots" src="${App.getInitParam('icon_three_dots')}"> ${__("Loading, please wait...")}</span>`;
Headlines.loadMore();
return;
@ -371,6 +381,9 @@ const Headlines = {
}
}
}
PluginHost.run(PluginHost.HOOK_HEADLINES_SCROLL_HANDLER);
} catch (e) {
console.warn("scrollHandler", e);
}
@ -378,11 +391,17 @@ const Headlines = {
objectById: function (id) {
return this.headlines[id];
},
setCommonClasses: function () {
App.byId("headlines-frame").removeClassName("cdm");
App.byId("headlines-frame").removeClassName("normal");
setCommonClasses: function (headlines_count) {
const container = App.byId("headlines-frame");
App.byId("headlines-frame").addClassName(App.isCombinedMode() ? "cdm" : "normal");
container.removeClassName("cdm");
container.removeClassName("normal");
container.addClassName(App.isCombinedMode() ? "cdm" : "normal");
container.setAttribute("data-enable-grid", App.getInitParam("cdm_enable_grid") ? "true" : "false");
container.setAttribute("data-headlines-count", parseInt(headlines_count));
container.setAttribute("data-is-cdm", App.isCombinedMode() ? "true" : "false");
container.setAttribute("data-is-cdm-expanded", App.getInitParam("cdm_expanded"));
// for floating title because it's placed outside of headlines-frame
App.byId("main").removeClassName("expandable");
@ -393,7 +412,7 @@ const Headlines = {
},
renderAgain: function () {
// TODO: wrap headline elements into a knockoutjs model to prevent all this stuff
Headlines.setCommonClasses();
Headlines.setCommonClasses(this.headlines.filter((h) => h.id).length);
App.findAll("#headlines-frame > div[id*=RROW]").forEach((row) => {
const id = row.getAttribute("data-article-id");
@ -422,11 +441,18 @@ const Headlines = {
this.sticky_header_observer.observe(e)
});
App.findAll(".cdm .content").forEach((e) => {
this.sticky_content_observer.observe(e)
});
if (App.getInitParam("cdm_expanded"))
App.findAll("#headlines-frame > div[id*=RROW].cdm").forEach((e) => {
this.unpack_observer.observe(e)
});
dijit.byId('main').resize();
PluginHost.run(PluginHost.HOOK_HEADLINES_RENDERED);
},
render: function (headlines, hl) {
let row = null;
@ -440,9 +466,11 @@ const Headlines = {
if (headlines.vfeed_group_enabled && hl.feed_title && this.vgroup_last_feed != hl.feed_id) {
const vgrhdr = `<div data-feed-id='${hl.feed_id}' class='feed-title'>
<div style='float : right'>${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</div>
<a class="title" href="#" onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}
<a class="catchup" title="${__('mark feed as read')}" onclick="Feeds.catchupFeedInGroup(${hl.feed_id})" href="#"><i class="icon-done material-icons">done_all</i></a>
<div class="pull-right">${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</div>
<a class="title" href="#" onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}</a>
<a class="catchup" title="${__('mark feed as read')}" onclick="Feeds.catchupFeedInGroup(${hl.feed_id})" href="#">
<i class="icon-done material-icons">done_all</i>
</a>
</div>`
const tmp = document.createElement("div");
@ -462,7 +490,10 @@ const Headlines = {
id="RROW-${hl.id}"
data-article-id="${hl.id}"
data-orig-feed-id="${hl.feed_id}"
data-orig-feed-title="${App.escapeHtml(hl.feed_title)}"
data-is-packed="1"
data-content="${App.escapeHtml(hl.content)}"
data-rendered-enclosures="${App.escapeHtml(Article.renderEnclosures(hl.enclosures))}"
data-score="${hl.score}"
data-article-title="${App.escapeHtml(hl.title)}"
onmouseover="Article.mouseIn(${hl.id})"
@ -476,6 +507,7 @@ const Headlines = {
</div>
<span onclick="return Headlines.click(event, ${hl.id});" data-article-id="${hl.id}" class="titleWrap hlMenuAttach">
${App.getInitParam("debug_headline_ids") ? `<span class="text-muted small">A: ${hl.id} F: ${hl.feed_id}</span>` : ""}
<a class="title" title="${App.escapeHtml(hl.title)}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.link)}">
${hl.title}</a>
<span class="author">${hl.author}</span>
@ -483,7 +515,7 @@ const Headlines = {
${hl.cdm_excerpt ? hl.cdm_excerpt : ""}
</span>
<div class="feed">
<div class="feed vfeedMenuAttach" data-feed-id="${hl.feed_id}">
<a href="#" style="background-color: ${hl.feed_bg_color}"
onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}</a>
</div>
@ -491,9 +523,10 @@ const Headlines = {
<span class="updated" title="${hl.imported}">${hl.updated}</span>
<div class="right">
<i class="material-icons icon-grid-span" title="${__("Span all columns")}" onclick="Article.cdmToggleGridSpan(${hl.id})">fullscreen</i>
<i class="material-icons icon-score" title="${hl.score}" onclick="Article.setScore(${hl.id}, this)">${Article.getScorePic(hl.score)}</i>
<span style="cursor : pointer" title="${App.escapeHtml(hl.feed_title)}" onclick="Feeds.open({feed:${hl.feed_id}})">
<span class="icon-feed" title="${App.escapeHtml(hl.feed_title)}" onclick="Feeds.open({feed:${hl.feed_id}})">
${Feeds.renderIcon(hl.feed_id, hl.has_icon)}
</span>
</div>
@ -503,11 +536,14 @@ const Headlines = {
<div class="content" onclick="return Headlines.click(event, ${hl.id}, true);">
${Article.renderNote(hl.id, hl.note)}
<div class="content-inner" lang="${hl.lang ? hl.lang : 'en'}">
<img src="${App.getInitParam('icon_indicator_white')}">
<div class="text-center text-muted">
${__("Loading, please wait...")}
</div>
<div class="intermediate">
${Article.renderEnclosures(hl.enclosures)}
</div>
<!-- intermediate: unstyled, kept for compatibility -->
<div class="intermediate"></div>
<div class="footer" onclick="event.stopPropagation()">
<div class="left">
@ -531,6 +567,7 @@ const Headlines = {
row = `<div class="hl ${row_class} ${Article.getScoreClass(hl.score)}"
id="RROW-${hl.id}"
data-orig-feed-id="${hl.feed_id}"
data-orig-feed-title="${App.escapeHtml(hl.feed_title)}"
data-article-id="${hl.id}"
data-score="${hl.score}"
data-article-title="${App.escapeHtml(hl.title)}"
@ -542,13 +579,14 @@ const Headlines = {
<i class="pub-pic pub-${hl.id} material-icons" onclick="Headlines.togglePub(${hl.id})">rss_feed</i>
</div>
<div onclick="return Headlines.click(event, ${hl.id})" class="title">
${App.getInitParam("debug_headline_ids") ? `<span class="text-muted small">A: ${hl.id} F: ${hl.feed_id}</span>` : ""}
<span data-article-id="${hl.id}" class="hl-content hlMenuAttach">
<a class="title" href="${App.escapeHtml(hl.link)}">${hl.title} <span class="preview">${hl.content_preview}</span></a>
<span class="author">${hl.author}</span>
${Article.renderLabels(hl.id, hl.labels)}
</span>
</div>
<span class="feed">
<span class="feed vfeedMenuAttach" data-feed-id="${hl.feed_id}">
<a style="background : ${hl.feed_bg_color}" href="#" onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}</a>
</span>
<div title="${hl.imported}">
@ -556,7 +594,7 @@ const Headlines = {
</div>
<div class="right">
<i class="material-icons icon-score" title="${hl.score}" onclick="Article.setScore(${hl.id}, this)">${Article.getScorePic(hl.score)}</i>
<span onclick="Feeds.open({feed:${hl.feed_id}})" style="cursor : pointer" title="${App.escapeHtml(hl.feed_title)}">${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</span>
<span onclick="Feeds.open({feed:${hl.feed_id}})" class="icon-feed" title="${App.escapeHtml(hl.feed_title)}">${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</span>
</div>
</div>
`;
@ -610,7 +648,7 @@ const Headlines = {
</span>
<span class='right'>
<span id='selected_prompt'></span>
<div dojoType='fox.form.DropDownButton' title='"${__('Select articles')}'>
<div class='select-articles-dropdown' dojoType='fox.form.DropDownButton' title='"${__('Select articles')}'>
<span>${__("Select...")}</span>
<div dojoType='dijit.Menu' style='display: none;'>
<div dojoType='dijit.MenuItem' onclick='Headlines.select("all")'>${__('All')}</div>
@ -667,11 +705,15 @@ const Headlines = {
console.log('infscroll_disabled=', Feeds.infscroll_disabled);
// also called in renderAgain() after view mode switch
Headlines.setCommonClasses();
Headlines.setCommonClasses(headlines_count);
/** TODO: remove @deprecated */
App.byId("headlines-frame").setAttribute("is-vfeed",
reply['headlines']['is_vfeed'] ? 1 : 0);
App.byId("headlines-frame").setAttribute("data-is-vfeed",
reply['headlines']['is_vfeed'] ? "true" : "false");
Article.setActive(0);
try {
@ -712,6 +754,9 @@ const Headlines = {
hsp.id = "headlines-spacer";
}
// clear out hsp contents in case there's a power-hungry svg icon rotating there
hsp.innerHTML = "";
dijit.byId('headlines-frame').domNode.appendChild(hsp);
this.initHeadlinesMenu();
@ -763,6 +808,9 @@ const Headlines = {
hsp.id = "headlines-spacer";
}
// clear out hsp contents in case there's a power-hungry svg icon rotating there
hsp.innerHTML = "";
c.domNode.appendChild(hsp);
this.initHeadlinesMenu();
@ -795,6 +843,10 @@ const Headlines = {
this.sticky_header_observer.observe(e)
});
App.findAll(".cdm .content").forEach((e) => {
this.sticky_content_observer.observe(e)
});
if (App.getInitParam("cdm_expanded"))
App.findAll("#headlines-frame > div[id*=RROW].cdm").forEach((e) => {
this.unpack_observer.observe(e)
@ -812,22 +864,22 @@ const Headlines = {
// unpack visible articles, fill buffer more, etc
this.scrollHandler();
dijit.byId('main').resize();
PluginHost.run(PluginHost.HOOK_HEADLINES_RENDERED);
Notify.close();
},
reverse: function () {
const toolbar = document.forms["toolbar-main"];
const order_by = dijit.getEnclosingWidget(toolbar.order_by);
let value = order_by.attr('value');
const toolbar = dijit.byId("toolbar-main");
let order_by = toolbar.getValues().order_by;
if (value != "date_reverse")
value = "date_reverse";
if (order_by != "date_reverse")
order_by = "date_reverse";
else
value = "default";
order_by = App.getInitParam("default_view_order_by");
order_by.attr('value', value);
Feeds.reloadCurrent();
toolbar.setValues({order_by: order_by});
},
selectionToggleUnread: function (params = {}) {
const cmode = params.cmode != undefined ? params.cmode : 2;
@ -1451,6 +1503,48 @@ const Headlines = {
menu.startup();
}
/* vfeed menu */
if (!dijit.byId("vfeedMenu")) {
const menu = new dijit.Menu({
id: "vfeedMenu",
targetNodeIds: ["headlines-frame"],
selector: ".vfeedMenuAttach"
});
menu.addChild(new dijit.MenuItem({
label: __("Mark as read"),
onClick: function() {
Feeds.catchupFeed(this.getParent().currentTarget.getAttribute("data-feed-id"));
}}));
menu.addChild(new dijit.MenuItem({
label: __("Edit feed"),
onClick: function() {
CommonDialogs.editFeed(this.getParent().currentTarget.getAttribute("data-feed-id"), false);
}}));
menu.addChild(new dijit.MenuItem({
label: __("Open site"),
onClick: function() {
App.postOpenWindow("backend.php", {op: "feeds", method: "opensite",
feed_id: this.getParent().currentTarget.getAttribute("data-feed-id"), csrf_token: __csrf_token});
}}));
menu.addChild(new dijit.MenuSeparator());
menu.addChild(new dijit.MenuItem({
label: __("Debug feed"),
onClick: function() {
/* global __csrf_token */
App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger",
feed_id: this.getParent().currentTarget.getAttribute("data-feed-id"), csrf_token: __csrf_token});
}}));
menu.startup();
}
/* vgroup feed title menu */
if (!dijit.byId("headlinesFeedTitleMenu")) {

@ -17,6 +17,10 @@ const PluginHost = {
HOOK_HEADLINE_RENDERED: 12,
HOOK_COUNTERS_RECEIVED: 13,
HOOK_COUNTERS_PROCESSED: 14,
HOOK_HEADLINE_MUTATIONS: 15,
HOOK_HEADLINE_MUTATIONS_SYNCED: 16,
HOOK_HEADLINES_RENDERED: 17,
HOOK_HEADLINES_SCROLL_HANDLER: 18,
hooks: [],
register: function (name, callback) {
if (typeof(this.hooks[name]) == 'undefined')
@ -25,7 +29,7 @@ const PluginHost = {
this.hooks[name].push(callback);
},
run: function (name, args) {
//console.warn('PluginHost::run ' + name);
//console.warn('PluginHost.run', name);
if (typeof(this.hooks[name]) != 'undefined')
for (let i = 0; i < this.hooks[name].length; i++) {

@ -300,7 +300,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b
try {
const dialog = new fox.SingleUseDialog({
title: __("Edit Multiple Feeds"),
title: __("Edit multiple feeds"),
/*getChildByName: function (name) {
let rv = null;
this.getChildren().forEach(
@ -513,7 +513,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b
<div class='panel panel-scrollable'>
<table width='100%' id='inactive-feeds-list'>
${reply.map((row) => `<tr data-row-id='${row.id}'>
<td width='5%' align='center'>
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this)' dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td>

@ -1,7 +1,7 @@
'use strict';
/* eslint-disable no-new */
/* global __, dijit, dojo, Tables, xhrPost, Notify, xhr, App, fox */
/* global __, dijit, dojo, Tables, Notify, xhr, App, fox */
const Helpers = {
AppPasswords: {
@ -19,7 +19,7 @@ const Helpers = {
alert("No passwords selected.");
} else if (confirm(__("Remove selected app passwords?"))) {
xhr.post("backend.php", {op: "pref-prefs", method: "deleteAppPassword", ids: rows.toString()}, (reply) => {
xhr.post("backend.php", {op: "pref-prefs", method: "deleteAppPasswords", "ids[]": rows}, (reply) => {
this.updateContent(reply);
Notify.close();
});
@ -53,6 +53,33 @@ const Helpers = {
return false;
},
},
Digest: {
preview: function() {
const dialog = new fox.SingleUseDialog({
title: __("Digest preview"),
content: `
<div class='panel panel-scrollable digest-preview'>
<div class='text-center'>${__("Loading, please wait...")}</div>
</div>
<footer class='text-center'>
${App.FormFields.submit_tag(__('Close this window'))}
</footer>
`
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhr.json("backend.php", {op: "pref-prefs", method: "previewDigest"}, (reply) => {
dialog.domNode.querySelector('.digest-preview').innerHTML = reply[0];
});
});
dialog.show();
}
},
System: {
//
},
@ -97,10 +124,28 @@ const Helpers = {
edit: function() {
const dialog = new fox.SingleUseDialog({
id: "profileEditDlg",
title: __("Settings Profiles"),
title: __("Manage profiles"),
getSelectedProfiles: function () {
return Tables.getSelected("pref-profiles-list");
},
cloneSelected: function() {
const sel_rows = this.getSelectedProfiles();
if (sel_rows.length == 1) {
const new_title = prompt(__("Name for cloned profile:"));
if (new_title) {
xhr.post("backend.php", {op: "pref-prefs", method: "cloneprofile", "new_title": new_title, "old_profile": sel_rows[0]}, () => {
Notify.close();
dialog.refresh();
});
}
} else {
alert(__("Please select a single profile to clone."));
}
},
removeSelected: function () {
const sel_rows = this.getSelectedProfiles();
@ -108,12 +153,7 @@ const Helpers = {
if (confirm(__("Remove selected profiles? Active and default profiles will not be removed."))) {
Notify.progress("Removing selected profiles...", true);
const query = {
op: "pref-prefs", method: "remprofiles",
ids: sel_rows.toString()
};
xhr.post("backend.php", query, () => {
xhr.post("backend.php", {op: "pref-prefs", method: "remprofiles", "ids[]": sel_rows}, () => {
Notify.close();
dialog.refresh();
});
@ -152,7 +192,7 @@ const Helpers = {
<div class="pull-right">
<input name='newprofile' dojoType='dijit.form.ValidationTextBox' required='1'>
${App.FormFields.button_tag(__('Create profile'), "", {onclick: 'App.dialogOf(this).addProfile()'})}
${App.FormFields.button_tag(App.FormFields.icon("add_circle") + " " + __('Add'), "", {onclick: 'App.dialogOf(this).addProfile()'})}
</div>
</div>
@ -161,7 +201,7 @@ const Helpers = {
<table width='100%' id='pref-profiles-list'>
${reply.map((profile) => `
<tr data-row-id="${profile.id}">
<td width='5%'>
<td class='checkbox'>
${App.FormFields.checkbox_tag("", false, "", {onclick: 'Tables.onRowChecked(this)'})}
</td>
<td>
@ -176,6 +216,7 @@ const Helpers = {
</script>
</span>` : `${profile.title}`}
${profile.active ? __("(active)") : ""}
${profile.initialized ? "" : __("(empty)")}
</td>
</tr>
`).join("")}
@ -183,9 +224,11 @@ const Helpers = {
</div>
<footer>
${App.FormFields.button_tag(__('Remove selected profiles'), "",
${App.FormFields.button_tag(App.FormFields.icon("delete") + " " +__('Remove selected'), "",
{class: 'pull-left alt-danger', onclick: 'App.dialogOf(this).removeSelected()'})}
${App.FormFields.submit_tag(__('Activate profile'), {onclick: 'App.dialogOf(this).execute()'})}
${App.FormFields.button_tag(App.FormFields.icon("content_copy") + " " + __('Clone'), "",
{class: '', onclick: 'App.dialogOf(this).cloneSelected()'})}
${App.FormFields.submit_tag(App.FormFields.icon("check") + " " + __('Activate'), {onclick: 'App.dialogOf(this).execute()'})}
${App.FormFields.cancel_dialog_tag(__('Cancel'))}
</footer>
</form>
@ -217,8 +260,6 @@ const Helpers = {
},
Prefs: {
customizeCSS: function() {
xhr.json("backend.php", {op: "pref-prefs", method: "customizeCSS"}, (reply) => {
const dialog = new fox.SingleUseDialog({
title: __("Customize stylesheet"),
apply: function() {
@ -249,14 +290,16 @@ const Helpers = {
</div>
</div>
<textarea class='panel user-css-editor' dojoType='dijit.form.SimpleTextarea'
style='font-size : 12px;' name='value'>${reply.value}</textarea>
<textarea class='panel user-css-editor' disabled='true' dojoType='dijit.form.SimpleTextarea'
style='font-size : 12px;' name='value'>${__("Loading, please wait...")}</textarea>
<footer>
<button dojoType='dijit.form.Button' class='alt-success' onclick="App.dialogOf(this).apply()">
${App.FormFields.icon("check")}
${__('Apply')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
${App.FormFields.icon("refresh")}
${__('Save and reload')}
</button>
<button dojoType='dijit.form.Button' onclick="App.dialogOf(this).hide()">
@ -266,9 +309,21 @@ const Helpers = {
`
});
dialog.show();
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhr.json("backend.php", {op: "pref-prefs", method: "customizeCSS"}, (reply) => {
const editor = dijit.getEnclosingWidget(dialog.domNode.querySelector(".user-css-editor"));
editor.attr('value', reply.value);
editor.attr('disabled', false);
});
});
dialog.show();
},
confirmReset: function() {
if (confirm(__("Reset to defaults?"))) {
@ -278,20 +333,447 @@ const Helpers = {
});
}
},
clearPluginData: function(name) {
if (confirm(__("Clear stored data for this plugin?"))) {
refresh: function() {
xhr.post("backend.php", { op: "pref-prefs" }, (reply) => {
dijit.byId('prefsTab').attr('content', reply);
Notify.close();
});
},
},
Plugins: {
_list_of_plugins: [],
_search_query: "",
enableSelected: function() {
const form = dijit.byId("changePluginsForm");
if (form.validate()) {
xhr.post("backend.php", form.getValues(), () => {
Notify.close();
if (confirm(__('Selected plugins have been enabled. Reload?'))) {
window.location.reload();
}
})
}
},
search: function() {
this._search_query = dijit.byId("changePluginsForm").getValues().search;
this.render_contents();
},
reload: function() {
xhr.json("backend.php", {op: "pref-prefs", method: "getPluginsList"}, (reply) => {
this._list_of_plugins = reply;
this.render_contents();
});
},
render_contents: function() {
const container = document.querySelector(".prefs-plugin-list");
container.innerHTML = "";
let results_rendered = 0;
const is_admin = this._list_of_plugins.is_admin;
const search_tokens = this._search_query
.split(/ {1,}/)
.filter((stoken) => (stoken.length > 0 ? stoken : null));
this._list_of_plugins.plugins.forEach((plugin) => {
if (search_tokens.length == 0 ||
Object.values(plugin).filter((pval) =>
search_tokens.filter((stoken) =>
(pval.toString().indexOf(stoken) != -1 ? stoken : null)
).length == search_tokens.length).length > 0) {
++results_rendered;
// only user-enabled actually counts in the checkbox when saving because system plugin checkboxes are disabled (see below)
container.innerHTML += `
<li data-row-value="${App.escapeHtml(plugin.name)}" data-plugin-local="${plugin.is_local}"
data-plugin-name="${App.escapeHtml(plugin.name)}" title="${plugin.is_system ? __("System plugins are enabled using global configuration.") : ""}">
<label class="checkbox ${plugin.is_system ? "system text-info" : ""}">
${App.FormFields.checkbox_tag("plugins[]", plugin.user_enabled || plugin.system_enabled, plugin.name,
{disabled: plugin.is_system})}</div>
<span class='name'>${plugin.name}:</span>
<span class="description ${plugin.is_system ? "text-info" : ""}">
${plugin.description}
</span>
</label>
<div class='actions'>
${plugin.is_system ?
App.FormFields.button_tag(App.FormFields.icon("security"), "",
{disabled: true}) : ''}
${plugin.more_info ?
App.FormFields.button_tag(App.FormFields.icon("help"), "",
{class: 'alt-info', onclick: `window.open("${App.escapeHtml(plugin.more_info)}")`}) : ''}
${is_admin && plugin.is_local ?
App.FormFields.button_tag(App.FormFields.icon("update"), "",
{title: __("Update"), class: 'alt-warning', "data-update-btn-for-plugin": plugin.name, style: 'display : none',
onclick: `Helpers.Plugins.update("${App.escapeHtml(plugin.name)}")`}) : ''}
${is_admin && plugin.has_data ?
App.FormFields.button_tag(App.FormFields.icon("clear"), "",
{title: __("Clear data"), onclick: `Helpers.Plugins.clearData("${App.escapeHtml(plugin.name)}")`}) : ''}
${is_admin && plugin.is_local ?
App.FormFields.button_tag(App.FormFields.icon("delete"), "",
{title: __("Uninstall"), onclick: `Helpers.Plugins.uninstall("${App.escapeHtml(plugin.name)}")`}) : ''}
</div>
<div class='version text-muted'>${plugin.version}</div>
</li>
`;
} else {
// if plugin is outside of search scope, keep current value in case of saving (only user-enabled is needed)
container.innerHTML += App.FormFields.checkbox_tag("plugins[]", plugin.user_enabled, plugin.name, {style: 'display : none'});
}
});
if (results_rendered == 0) {
container.innerHTML += `<li class='text-center text-info'>${__("Could not find any plugins for this search query.")}</li>`;
}
dojo.parser.parse(container);
},
clearData: function(name) {
if (confirm(__("Clear stored data for %s?").replace("%s", name))) {
Notify.progress("Loading, please wait...");
xhr.post("backend.php", {op: "pref-prefs", method: "clearplugindata", name: name}, () => {
xhr.post("backend.php", {op: "pref-prefs", method: "clearPluginData", name: name}, () => {
Helpers.Prefs.refresh();
});
}
},
refresh: function() {
xhr.post("backend.php", { op: "pref-prefs" }, (reply) => {
dijit.byId('prefsTab').attr('content', reply);
Notify.close();
uninstall: function(plugin) {
const msg = __("Uninstall plugin %s?").replace("%s", plugin);
if (confirm(msg)) {
Notify.progress("Loading, please wait...");
xhr.json("backend.php", {op: "pref-prefs", method: "uninstallPlugin", plugin: plugin}, (reply) => {
if (reply && reply.status == 1)
Helpers.Prefs.refresh();
else {
Notify.error("Plugin uninstallation failed.");
}
});
}
},
install: function() {
const dialog = new fox.SingleUseDialog({
PI_RES_ALREADY_INSTALLED: "PI_RES_ALREADY_INSTALLED",
PI_RES_SUCCESS: "PI_RES_SUCCESS",
PI_ERR_NO_CLASS: "PI_ERR_NO_CLASS",
PI_ERR_NO_INIT_PHP: "PI_ERR_NO_INIT_PHP",
PI_ERR_EXEC_FAILED: "PI_ERR_EXEC_FAILED",
PI_ERR_NO_TEMPDIR: "PI_ERR_NO_TEMPDIR",
PI_ERR_PLUGIN_NOT_FOUND: "PI_ERR_PLUGIN_NOT_FOUND",
PI_ERR_NO_WORKDIR: "PI_ERR_NO_WORKDIR",
title: __("Available plugins"),
need_refresh: false,
entries: false,
search_query: "",
installed_plugins: [],
onHide: function() {
if (this.need_refresh) {
Helpers.Prefs.refresh();
}
},
performInstall: function(plugin) {
const install_dialog = new fox.SingleUseDialog({
title: __("Plugin installer"),
content: `
<ul class="panel panel-scrollable contents">
<li class='text-center'>${__("Installing %s, please wait...").replace("%s", plugin)}</li>
</ul>
<footer class='text-center'>
${App.FormFields.submit_tag(__("Close this window"))}
</footer>`
});
const tmph = dojo.connect(install_dialog, 'onShow', function () {
dojo.disconnect(tmph);
const container = install_dialog.domNode.querySelector(".contents");
xhr.json("backend.php", {op: "pref-prefs", method: "installPlugin", plugin: plugin}, (reply) => {
if (!reply) {
container.innerHTML = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
} else {
switch (reply.result) {
case dialog.PI_RES_SUCCESS:
container.innerHTML = `<li class='text-success text-center'>${__("Plugin has been installed.")}</li>`
dialog.need_refresh = true;
break;
case dialog.PI_RES_ALREADY_INSTALLED:
container.innerHTML = `<li class='text-success text-center'>${__("Plugin is already installed.")}</li>`
break;
default:
container.innerHTML = `
<li>
<h3 style="margin-top: 0">${plugin}</h3>
<div class='text-error'>${reply.result}</div>
${reply.stderr ? `<pre class="small text-error pre-wrap">${reply.stderr}</pre>` : ''}
${reply.stdour ? `<pre class="small text-success pre-wrap">${reply.stdout}</pre>` : ''}
<p class="small">
${App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", reply.git_status)}
</p>
</li>
`;
}
}
});
});
install_dialog.show();
},
search: function() {
this.search_query = this.attr('value').search.toLowerCase();
window.requestIdleCallback(() => {
this.render_contents();
});
},
render_contents: function() {
const container = dialog.domNode.querySelector(".contents");
if (!dialog.entries) {
container.innerHTML = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
} else {
container.innerHTML = "";
let results_rendered = 0;
const search_tokens = dialog.search_query
.split(/ {1,}/)
.filter((stoken) => (stoken.length > 0 ? stoken : null));
dialog.entries.forEach((plugin) => {
const is_installed = (dialog.installed_plugins
.filter((p) => plugin.topics.map((t) => t.replace(/-/g, "_")).includes(p))).length > 0;
if (search_tokens.length == 0 ||
Object.values(plugin).filter((pval) =>
search_tokens.filter((stoken) =>
(pval.indexOf(stoken) != -1 ? stoken : null)
).length == search_tokens.length).length > 0) {
++results_rendered;
container.innerHTML += `
<li data-row-value="${App.escapeHtml(plugin.name)}" class="${is_installed ? "plugin-installed" : ""}">
${App.FormFields.button_tag((is_installed ?
App.FormFields.icon("check") + " " +__("Already installed") :
App.FormFields.icon("file_download") + " " +__('Install')), "", {class: 'alt-primary pull-right',
disabled: is_installed,
onclick: `App.dialogOf(this).performInstall("${App.escapeHtml(plugin.name)}")`})}
<h3>${plugin.name}
<a target="_blank" href="${App.escapeHtml(plugin.html_url)}">
${App.FormFields.icon("open_in_new_window")}
</a>
</h3>
<div class='small text-muted'>${__("Updated: %s").replace("%s", plugin.last_update)}</div>
<div class='description'>${plugin.description}</div>
</li>
`
}
});
if (results_rendered == 0) {
container.innerHTML = `<li class='text-center text-info'>${__("Could not find any plugins for this search query.")}</li>`;
}
dojo.parser.parse(container);
}
},
reload: function() {
const container = dialog.domNode.querySelector(".contents");
container.innerHTML = `<li class='text-center'>${__("Looking for plugins...")}</li>`;
xhr.json("backend.php", {op: "pref-prefs", method: "getAvailablePlugins"}, (reply) => {
dialog.entries = reply;
dialog.render_contents();
});
},
content: `
<div dojoType='fox.Toolbar'>
<div class='pull-right'>
<input name="search" placeholder="${__("Search...")}" type="search" dojoType="dijit.form.TextBox" onkeyup="App.dialogOf(this).search()">
</div>
<div style='height : 16px'>&nbsp;</div> <!-- disgusting -->
</div>
<ul style='clear : both' class="panel panel-scrollable-400px contents plugin-installer-list"> </ul>
<footer>
${App.FormFields.button_tag(App.FormFields.icon("refresh") + " " +__("Refresh"), "", {class: 'alt-primary', onclick: 'App.dialogOf(this).reload()'})}
${App.FormFields.cancel_dialog_tag(__("Close"))}
</footer>
`,
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
dialog.installed_plugins = [...document.querySelectorAll('*[data-plugin-name]')].map((p) => p.getAttribute('data-plugin-name'));
dialog.reload();
});
dialog.show();
},
update: function(name = null) {
const dialog = new fox.SingleUseDialog({
title: __("Update plugins"),
need_refresh: false,
plugins_to_update: [],
plugins_to_check: [],
onHide: function() {
if (this.need_refresh) {
Helpers.Prefs.refresh();
}
},
performUpdate: function() {
const container = dialog.domNode.querySelector(".update-results");
console.log('updating', dialog.plugins_to_update);
dialog.attr('title', __('Updating...'));
container.innerHTML = `<li class='text-center'>${__("Updating, please wait...")}</li>`;
let enable_update_btn = false;
xhr.json("backend.php", {op: "pref-prefs", method: "updateLocalPlugins", plugins: dialog.plugins_to_update.join(",")}, (reply) => {
if (!reply) {
container.innerHTML = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
} else {
container.innerHTML = "";
reply.forEach((p) => {
if (p.rv.git_status == 0)
dialog.need_refresh = true;
else
enable_update_btn = true;
container.innerHTML +=
`
<li>
<h3>${p.plugin}</h3>
${p.rv.stderr ? `<pre class="small text-error pre-wrap">${p.rv.stderr}</pre>` : ''}
${p.rv.stdout ? `<pre class="small text-success pre-wrap">${p.rv.stdout}</pre>` : ''}
<div class="small">
${p.rv.git_status ? App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", p.rv.git_status) :
App.FormFields.icon("check") + " " + __("Update done.")}
</div>
</li>
`
});
}
dialog.attr('title', __('Updates complete'));
dijit.getEnclosingWidget(dialog.domNode.querySelector(".update-btn")).attr('disabled', !enable_update_btn);
});
},
checkNextPlugin: function() {
const name = dialog.plugins_to_check.shift();
if (name) {
this.checkUpdates(name);
} else {
const num_updated = dialog.plugins_to_update.length;
if (num_updated > 0)
dialog.attr('title',
App.l10n.ngettext('Updates pending for %d plugin', 'Updates pending for %d plugins', num_updated)
.replace("%d", num_updated));
else
dialog.attr('title', __("No updates available"));
dijit.getEnclosingWidget(dialog.domNode.querySelector(".update-btn"))
.attr('disabled', num_updated == 0);
}
},
checkUpdates: function(name) {
console.log('checkUpdates', name);
const container = dialog.domNode.querySelector(".update-results");
dialog.attr('title', __("Checking: %s").replace("%s", name));
//container.innerHTML = `<li class='text-center'>${__("Checking: %s...").replace("%s", name)}</li>`;
xhr.json("backend.php", {op: "pref-prefs", method: "checkForPluginUpdates", name: name}, (reply) => {
if (!reply) {
container.innerHTML += `<li class='text-error'>${__("%s: Operation failed: check event log.").replace("%s", name)}</li>`;
} else {
reply.forEach((p) => {
if (p.rv) {
if (p.rv.need_update) {
dialog.plugins_to_update.push(p.plugin);
const update_button = dijit.getEnclosingWidget(
App.find(`*[data-update-btn-for-plugin="${p.plugin}"]`));
if (update_button)
update_button.domNode.show();
}
if (p.rv.need_update || p.rv.git_status != 0) {
container.innerHTML +=
`
<li><h3>${p.plugin}</h3>
${p.rv.stderr ? `<pre class="small text-error pre-wrap">${p.rv.stderr}</pre>` : ''}
${p.rv.stdout ? `<pre class="small text-success pre-wrap">${p.rv.stdout}</pre>` : ''}
<div class="small">
${p.rv.git_status ? App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", p.rv.git_status) :
App.FormFields.icon("check") + " " + __("Ready to update")}
</div>
</li>
`
}
}
dialog.checkNextPlugin();
});
}
});
},
content: `
<ul class="panel panel-scrollable plugin-updater-list update-results">
</ul>
<footer>
${App.FormFields.button_tag(App.FormFields.icon("update") + " " + __("Update"), "", {disabled: true, class: "update-btn alt-primary", onclick: "App.dialogOf(this).performUpdate()"})}
${App.FormFields.cancel_dialog_tag(__("Close"))}
</footer>
`,
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
dialog.plugins_to_update = [];
if (name) {
dialog.checkUpdates(name);
} else {
dialog.plugins_to_check = [...document.querySelectorAll('*[data-plugin-name][data-plugin-local=true]')].map((p) => p.getAttribute('data-plugin-name'));
dialog.checkNextPlugin();
}
});
dialog.show();
},
},
OPML: {
@ -347,62 +829,5 @@ const Helpers = {
console.log("export");
window.open("backend.php?op=opml&method=export&" + dojo.formToQuery("opmlExportForm"));
},
publish: function() {
Notify.progress("Loading, please wait...", true);
xhr.json("backend.php", {op: "pref-feeds", method: "getOPMLKey"}, (reply) => {
try {
const dialog = new fox.SingleUseDialog({
title: __("Public OPML URL"),
regenOPMLKey: function() {
if (confirm(__("Replace current OPML publishing address with a new one?"))) {
Notify.progress("Trying to change address...", true);
xhr.json("backend.php", {op: "pref-feeds", method: "regenOPMLKey"}, (reply) => {
if (reply) {
const new_link = reply.link;
const target = this.domNode.querySelector('.generated_url');
if (new_link && target) {
target.href = new_link;
target.innerHTML = new_link;
Notify.close();
} else {
Notify.error("Could not change feed URL.");
}
}
});
}
return false;
},
content: `
<header>${__("Your Public OPML URL is:")}</header>
<section>
<div class='panel text-center'>
<a class='generated_url' href="${App.escapeHtml(reply.link)}" target='_blank'>${App.escapeHtml(reply.link)}</a>
</div>
</section>
<footer class='text-center'>
<button dojoType='dijit.form.Button' onclick="return App.dialogOf(this).regenOPMLKey()">
${__('Generate new URL')}
</button>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
${__('Close this window')}
</button>
</footer>
`
});
dialog.show();
Notify.close();
} catch (e) {
App.Error.report(e);
}
});
},
}
};

@ -68,8 +68,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
const dialog = new fox.SingleUseDialog({
id: "labelEditDlg",
title: __("Label Editor"),
style: "width: 650px",
title: __("Edit label"),
setLabelColor: function (id, fg, bg) {
let kind = '';
@ -121,10 +120,10 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
content: `
<form onsubmit='return false'>
<header>${__("Caption")}</header>
<section>
<input style='font-size : 16px; color : ${fg_color}; background : ${bg_color}; transition : background 0.1s linear'
<input style='font-size : 16px; width : 550px; color : ${fg_color}; background : ${bg_color}; transition : background 0.1s linear'
id='labelEdit_caption'
placeholder="${__("Caption")}"
name='caption'
dojoType='dijit.form.ValidationTextBox'
required='true'
@ -138,7 +137,6 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
${App.FormFields.hidden_tag('fg_color', fg_color, {}, 'labelEdit_fgColor')}
${App.FormFields.hidden_tag('bg_color', bg_color, {}, 'labelEdit_bgColor')}
<header>${__("Colors")}</header>
<section>
<table width='100%'>
<tr>
@ -168,6 +166,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
<footer>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary' onclick='App.dialogOf(this).execute()'>
${App.FormFields.icon("save")}
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>

@ -1,16 +1,18 @@
'use strict'
/* global __ */
/* global xhrPost, xhr, dijit, Notify, Tables, App, fox */
/* global __, xhr, dijit, Notify, Tables, App, fox */
const Users = {
reload: function(sort) {
return new Promise((resolve, reject) => {
const user_search = App.byId("user_search");
const search = user_search ? user_search.value : "";
xhr.post("backend.php", { op: "pref-users", sort: sort, search: search }, (reply) => {
dijit.byId('usersTab').attr('content', reply);
Notify.close();
resolve();
}, (e) => { reject(e) });
});
},
add: function() {
@ -20,8 +22,9 @@ const Users = {
Notify.progress("Adding user...");
xhr.post("backend.php", {op: "pref-users", method: "add", login: login}, (reply) => {
alert(reply);
Users.reload();
Users.reload().then(() => {
Notify.info(reply);
})
});
}
@ -33,14 +36,16 @@ const Users = {
const dialog = new fox.SingleUseDialog({
id: "userEditDlg",
title: __("User Editor"),
title: __("Edit user"),
execute: function () {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhr.post("backend.php", this.attr('value'), () => {
xhr.post("backend.php", this.attr('value'), (reply) => {
dialog.hide();
Users.reload();
Users.reload().then(() => {
Notify.info(reply);
});
});
}
},
@ -54,8 +59,6 @@ const Users = {
<div dojoType="dijit.layout.TabContainer" style="height : 400px">
<div dojoType="dijit.layout.ContentPane" title="${__('Edit user')}">
<header>${__("User")}</header>
<section>
<fieldset>
<label>${__("Login:")}</label>
@ -66,11 +69,9 @@ const Users = {
${admin_disabled ? App.FormFields.hidden_tag("login", user.login) : ''}
</fieldset>
</section>
<header>${__("Authentication")}</header>
<hr/>
<section>
<fieldset>
<label>${__('Access level: ')}</label>
${App.FormFields.select_hash("access_level",
@ -84,11 +85,15 @@ const Users = {
<input dojoType='dijit.form.TextBox' type='password' size='20'
placeholder='${__("Change password")}' name='password'>
</fieldset>
</section>
<fieldset>
<label></label>
<label class="checkbox">
${App.FormFields.checkbox_tag("otp_enabled", user.otp_enabled)}
${__('OTP enabled')}
</fieldset>
<header>${__("Options")}</header>
<hr/>
<section>
<fieldset>
<label>${__("E-mail:")}</label>
<input dojoType='dijit.form.TextBox' size='30' name='email'
@ -110,6 +115,7 @@ const Users = {
<footer>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick='App.dialogOf(this).execute()'>
${App.FormFields.icon("save")}
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>

@ -1,8 +1,22 @@
'use strict';
/* global dijit, __, App, dojo, __csrf_token */
/* global dijit, App, dojo, __csrf_token */
/* eslint-disable no-new */
/* exported __ */
function __(msg) {
if (typeof App != "undefined") {
return App.l10n.__(msg);
} else {
return msg;
}
}
/* exported ngettext */
function ngettext(msg1, msg2, n) {
return __((parseInt(n) > 1) ? msg2 : msg1);
}
/* exported $ */
function $(id) {
console.warn("FIXME: please use App.byId() or document.getElementById() instead of $():", id);
@ -86,7 +100,7 @@ Element.prototype.fadeIn = function(display = undefined){
};
Element.prototype.visible = function() {
return this.style.display != "none" && this.offsetHeight != 0 && this.offsetWidth != 0;
return window.getComputedStyle(this).display != "none"; //&& this.offsetHeight != 0 && this.offsetWidth != 0;
}
Element.visible = function(elem) {
@ -140,7 +154,10 @@ String.prototype.stripTags = function() {
/* exported xhr */
const xhr = {
post: function(url, params = {}, complete = undefined) {
_ts: 0,
post: function(url, params = {}, complete = undefined, failed = undefined) {
this._ts = new Date().getTime();
console.log('xhr.post', '>>>', params);
return new Promise((resolve, reject) => {
@ -151,10 +168,13 @@ const xhr = {
postData: dojo.objectToQuery(params),
handleAs: "text",
error: function(error) {
if (failed != undefined)
failed(error);
reject(error);
},
load: function(data, ioargs) {
console.log('xhr.post', '<<<', ioargs.xhr);
console.log('xhr.post', '<<<', ioargs.xhr, (new Date().getTime() - xhr._ts) + " ms");
if (complete != undefined)
complete(data, ioargs.xhr);
@ -164,7 +184,7 @@ const xhr = {
);
});
},
json: function(url, params = {}, complete = undefined) {
json: function(url, params = {}, complete = undefined, failed = undefined) {
return new Promise((resolve, reject) =>
this.post(url, params).then((data) => {
let obj = null;
@ -173,13 +193,21 @@ const xhr = {
obj = JSON.parse(data);
} catch (e) {
console.error("xhr.json", e, xhr);
if (failed != undefined)
failed(e);
reject(e);
}
console.log('xhr.json', '<<<', obj);
console.log('xhr.json', '<<<', obj, (new Date().getTime() - xhr._ts) + " ms");
if (obj && typeof App != "undefined")
if (!App.handleRpcJson(obj)) {
if (failed != undefined)
failed(obj);
reject(obj);
return;
}
@ -234,8 +262,11 @@ const Lists = {
if (row)
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).querySelectorAll("li").forEach((row) => {
select: function(elem, selected) {
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("li").forEach((row) => {
const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]");
if (checkNode) {
const widget = dijit.getEnclosingWidget(checkNode);
@ -250,6 +281,30 @@ const Lists = {
}
});
},
getSelected: function(elem) {
const rv = [];
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("li").forEach((row) => {
if (row.hasClassName("Selected")) {
const rowVal = row.getAttribute("data-row-value");
if (rowVal) {
rv.push(rowVal);
} else {
// either older prefix-XXX notation or separate attribute
const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, "");
if (!isNaN(rowId))
rv.push(parseInt(rowId));
}
}
});
return rv;
}
};
/* exported Tables */
@ -265,8 +320,11 @@ const Tables = {
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).querySelectorAll("tr").forEach((row) => {
select: function(elem, selected) {
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("tr").forEach((row) => {
const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]");
if (checkNode) {
const widget = dijit.getEnclosingWidget(checkNode);
@ -281,17 +339,26 @@ const Tables = {
}
});
},
getSelected: function(elemId) {
getSelected: function(elem) {
const rv = [];
$(elemId).querySelectorAll("tr").forEach((row) => {
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("tr").forEach((row) => {
if (row.hasClassName("Selected")) {
const rowVal = row.getAttribute("data-row-value");
if (rowVal) {
rv.push(rowVal);
} else {
// either older prefix-XXX notation or separate attribute
const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, "");
if (!isNaN(rowId))
rv.push(parseInt(rowId));
}
}
});
return rv;
@ -365,7 +432,7 @@ const Notify = {
break;
case this.KIND_PROGRESS:
notify.addClassName("notify_progress");
icon = App.getInitParam("icon_indicator_white")
icon = App.getInitParam("icon_oval")
break;
default:
icon = "notifications";

@ -69,15 +69,3 @@ require(["dojo/_base/kernel",
});
});
/* exported hash_get */
function hash_get(key) {
const obj = dojo.queryToObject(window.location.hash.substring(1));
return obj[key];
}
/* exported hash_set */
function hash_set(key, value) {
const obj = dojo.queryToObject(window.location.hash.substring(1));
obj[key] = value;
window.location.hash = dojo.objectToQuery(obj);
}

File diff suppressed because one or more lines are too long

@ -1,9 +0,0 @@
The recommended way to use the Material Icons font is by linking to the web font hosted on Google Fonts:
```html
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
```
Read more in our full usage guide:
http://google.github.io/material-design-icons/#icon-font-for-the-web

@ -1,36 +0,0 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(MaterialIcons-Regular.eot); /* For IE6-8 */
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(MaterialIcons-Regular.woff2) format('woff2'),
url(MaterialIcons-Regular.woff) format('woff'),
url(MaterialIcons-Regular.ttf) format('truetype');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px; /* Preferred icon size */
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Support for IE. */
font-feature-settings: 'liga';
}

@ -1,38 +0,0 @@
* 1.0.0 build 2010031920
- first public release
- help in readme, install
- cleanup ans separation of QRtools and QRspec
- now TCPDF binding requires minimal changes in TCPDF, having most of job
done in QRtools tcpdfBarcodeArray
- nicer QRtools::timeBenchmark output
- license and copyright notices in files
- indent cleanup - from tab to 4spc, keep it that way please :)
- sf project, repository, wiki
- simple code generator in index.php
* 1.1.0 build 2010032113
- added merge tool wich generate merged version of code
located in phpqrcode.php
- splited qrconst.php from qrlib.php
* 1.1.1 build 2010032405
- patch by Rick Seymour allowing saving PNG and displaying it at the same time
- added version info in VERSION file
- modified merge tool to include version info into generated file
- fixed e-mail in almost all head comments
* 1.1.2 build 2010032722
- full integration with TCPDF thanks to Nicola Asuni, it's author
- fixed bug with alphanumeric encoding detection
* 1.1.3 build 2010081807
- short opening tags replaced with standard ones
* 1.1.4 build 2010100721
- added missing static keyword QRinput::check (found by Luke Brookhart, Onjax LLC)

@ -1,67 +0,0 @@
== REQUIREMENTS ==
* PHP5
* PHP GD2 extension with JPEG and PNG support
== INSTALLATION ==
If you want to recreate cache by yourself make sure cache directory is
writable and you have permisions to write into it. Also make sure you are
able to read files in it if you have cache option enabled
== CONFIGURATION ==
Feel free to modify config constants in qrconfig.php file. Read about it in
provided comments and project wiki page (links in README file)
== QUICK START ==
Notice: probably you should'nt use all of this in same script :)
<?phpb
//include only that one, rest required files will be included from it
include "qrlib.php"
//write code into file, Error corection lecer is lowest, L (one form: L,M,Q,H)
//each code square will be 4x4 pixels (4x zoom)
//code will have 2 code squares white boundary around
QRcode::png('PHP QR Code :)', 'test.png', 'L', 4, 2);
//same as above but outputs file directly into browser (with appr. header etc.)
//all other settings are default
//WARNING! it should be FIRST and ONLY output generated by script, otherwise
//rest of output will land inside PNG binary, breaking it for sure
QRcode::png('PHP QR Code :)');
//show benchmark
QRtools::timeBenchmark();
//rebuild cache
QRtools::buildCache();
//code generated in text mode - as a binary table
//then displayed out as HTML using Unicode block building chars :)
$tab = $qr->encode('PHP QR Code :)');
QRspec::debug($tab, true);
== TCPDF INTEGRATION ==
Inside bindings/tcpdf you will find slightly modified 2dbarcodes.php.
Instal phpqrcode liblaty inside tcpdf folder, then overwrite (or merge)
2dbarcodes.php
Then use similar as example #50 from TCPDF examples:
<?php
$style = array(
'border' => true,
'padding' => 4,
'fgcolor' => array(0,0,0),
'bgcolor' => false, //array(255,255,255)
);
//code name: QR, specify error correction level after semicolon (L,M,Q,H)
$pdf->write2DBarcode('PHP QR Code :)', 'QR,L', '', '', 30, 30, $style, 'N');

@ -1,165 +0,0 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

@ -1,45 +0,0 @@
This is PHP implementation of QR Code 2-D barcode generator. It is pure-php
LGPL-licensed implementation based on C libqrencode by Kentaro Fukuchi.
== LICENSING ==
Copyright (C) 2010 by Dominik Dzienia
This library is free software; you can redistribute it and/or modify it under
the terms of the GNU Lesser General Public License as published by the Free
Software Foundation; either version 3 of the License, or any later version.
This library is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU Lesser General Public License (LICENSE file)
for more details.
You should have received a copy of the GNU Lesser General Public License along
with this library; if not, write to the Free Software Foundation, Inc., 51
Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
== INSTALATION AND USAGE ==
* INSTALL file
* http://sourceforge.net/apps/mediawiki/phpqrcode/index.php?title=Main_Page
== CONTACT ==
Fell free to contact me via e-mail (deltalab at poczta dot fm) or using
folowing project pages:
* http://sourceforge.net/projects/phpqrcode/
* http://phpqrcode.sourceforge.net/
== ACKNOWLEDGMENTS ==
Based on C libqrencode library (ver. 3.1.1)
Copyright (C) 2006-2010 by Kentaro Fukuchi
http://megaui.net/fukuchi/works/qrencode/index.en.html
QR Code is registered trademarks of DENSO WAVE INCORPORATED in JAPAN and other
countries.
Reed-Solomon code encoder is written by Phil Karn, KA9Q.
Copyright (C) 2002, 2003, 2004, 2006 Phil Karn, KA9Q

@ -1,2 +0,0 @@
1.1.4
2010100721

File diff suppressed because it is too large Load Diff

@ -1,2 +0,0 @@
<EFBFBD><EFBFBD>Á À E9³u<06><>`³"PÅ„CÛ牗T!0$
E•É²Q™<EFBFBD>Ém½úhÛ¾9{kI" 9Ln)Ap¤åÖ¾Ë>ß^‡Õz³mënÅ´mßn†ú¦Ë

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

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

Loading…
Cancel
Save