Compare commits

...

871 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 3 years ago
Andrew Dolgov 4795c4a2a9 Merge branch 'weblate-integration' 3 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/
3 years ago
Andrew Dolgov 295fc1f88a API: bump api level to 17 3 years ago
Andrew Dolgov 2adf364c2c provide base configuration object in login response to skip on initial getConfig 3 years ago
Andrew Dolgov 9f6237a1b8 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 57cd8acfc9 API: return custom sort types in getConfig 3 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
3 years ago
linkai 983655165e Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived 3 years ago
kdan 6c06a26649 Merge branch 'master' into master 3 years ago
Andrew Dolgov f423874e05 checking for PDO there is rather useless 3 years ago
Andrew Dolgov b5a559a1a7 sanity check: in single user mode, only test for admin user if migrations have been completed 3 years ago
Andrew Dolgov e3c4724dc1 use database-backed sessions in single user mode 3 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
3 years ago
Jacek Tomasiak 0c38dc8456 Improve missing token check
Avoid "E_NOTICE (8) (classes/userhelper.php:78) Undefined index:
csrf_token" in logs.
3 years ago
kdan 2ccf0e50a2 Merge branch 'master' into master 3 years ago
linkai acf0e0d266 Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived 3 years ago
Andrew Dolgov b2f888e386 include archived articles (which lack associated feed id) when browsing by tag 3 years ago
Andrew Dolgov fea59de26b af_redditimgur: use core youtube vid helper 3 years ago
Andrew Dolgov 86300a0ca8 add urlhelper to extract youtube video id from url 3 years ago
Andrew Dolgov d11718c89c fix combined/three panel transition to expandable mode 3 years ago
linkai 0574675ed6 Fix:Plugins-share:init.php - site_url is NULL when share atircle by URL form archived 3 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
3 years ago
Andrew Dolgov 88a7130d79 fix for previous changeset that broke expanded mode 3 years ago
Andrew Dolgov e8e4fc641e Article.pack: add no-op for three panel mode 3 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
3 years ago
Andrew Dolgov c6befcddb7 Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov 5a71426ea5 youtube_embed: use embed-responsive 3 years ago
Andrew Dolgov b3d45a4a5d Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov cc634ba91d Merge branch 'weblate-integration' 3 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/
3 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
3 years ago
Oliver Haucke cfd9e6b53b FIX: public.php - Undefined index: feed_title 3 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
3 years ago
Rodney Stromlund c18383d1ea Fix `getCategory` method. 3 years ago
Andrew Dolgov 3e22368962 getPreviousFeed/getNextFeed: implement wrap around 3 years ago
Andrew Dolgov eadaaebd58 functions_enabled: trim spaces from disable_functions php ini setting 3 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
3 years ago
Cyb10101 c15c1dfb0b if backend request 'op' is empty fixed 3 years ago
Andrew Dolgov a61348e2b7 pluginhost: add profile_get/profile_set helpers 3 years ago
Andrew Dolgov a5af15cfe9 fix noscript notifications 3 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
3 years ago
Andrew Dolgov c0fba62fa0 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 0acd33abe3 OTP: generate longer secrets, also make them easier to read/copy 3 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/
3 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/
3 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/
3 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
3 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.
3 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
3 years ago
wn_ 2ed5a79e64 Fix automatically showing next feed on catchup 3 years ago
Andrew Dolgov 8c32ed76df revert back to lower contrast light theme by default, add separate light-high-contrast.less 3 years ago
Andrew Dolgov ceb8179ccc don't use css-defined .svg files because firefox 3 years ago
Andrew Dolgov 19c277391e fonts-ui: add Cantarell 3 years ago
Andrew Dolgov 58ab641fea light theme: increase contrast 3 years ago
Andrew Dolgov be2d1602bd fix previous issue properly 3 years ago
Andrew Dolgov e3c51b0e6c Revert "clip max displayed counter value to 9999 because of container node width"
This reverts commit c34a4c85bd.
3 years ago
Andrew Dolgov c34a4c85bd clip max displayed counter value to 9999 because of container node width 3 years ago
Andrew Dolgov 0f6644880a yet another flex feedtree attempt 3 years ago
Andrew Dolgov 98251022d4 Revert "Revert "another attempt at flex-based feed tree""
This reverts commit 43744412f4.
3 years ago
Andrew Dolgov 334a361e79 don't try to j/k move to nonexistant feed 3 years ago
Andrew Dolgov d275134f26 unify return values for getPreviousFeed and usages of both prev/next 3 years ago
Andrew Dolgov 2e6d48ead7 * Feeds.openNextUnread: fix
* model.getNextFeed: make sure return values are consistent, stop
wrapping back to starred
3 years ago
Andrew Dolgov 43744412f4 Revert "another attempt at flex-based feed tree"
This reverts commit e12a6ca540.
3 years ago
Andrew Dolgov ef5d6b9b78 Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov e12a6ca540 another attempt at flex-based feed tree 3 years ago
Andrew Dolgov 1f5adf1600 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 68299c914b share: move og:image back to head 3 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
3 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).
3 years ago
Andrew Dolgov 718c9f07fa remove model.getNextUnreadFeed; unify code with feedTree.getNextFeed 3 years ago
Andrew Dolgov 43ea36d030 prefs: allow setting email if it was previously blank 3 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
3 years ago
wn_ cd52ca80ab Minor cleanup in 'Handler_Public->getProfiles' 3 years ago
wn_ baf3ecd4cf Fix a couple of array index warnings in 'Handler_Public->forgotpass' 3 years ago
Andrew Dolgov 968270ed48 fix excessive CPU usage on linux chromium caused by animated SVG icons 3 years ago
wn_ 541a07250c Switch 'Handler_Public->forgotpass' to ORM 3 years ago
wn_ f057c124d1 Switch 'Handler_Public->login' to ORM, fix 'Handler_Public->getProfiles' 3 years ago
wn_ 7ea48f7a4b Switch 'Handler_Public->rss' to ORM 3 years ago
wn_ b6ae280446 Switch 'Handler_Public->getProfiles' to ORM 3 years ago
Andrew Dolgov db0315e596 feed tree: set cursor pointer on tree label 3 years ago
Andrew Dolgov 88534a8ae4 fix loadingNode offset for feeds 3 years ago
Andrew Dolgov 82bed1e651 filter test dialog: remove .gif; cleanup markup 3 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
3 years ago
wn_ 401b22666d Switch 'RSSUtils::update_basic_info' to ORM 3 years ago
Andrew Dolgov 0f5fd9ea13 use svg icon for headlines loadmore prompt 3 years ago
Andrew Dolgov 32c080bec0 use svg icon for the subscribe dialog (night mode) 3 years ago
Andrew Dolgov 166517240e use svg icon for the subscribe dialog 3 years ago
Andrew Dolgov 7a1e1630d8 use svg icon for packed article placeholders 3 years ago
Andrew Dolgov 92f859add2 update night theme re: previous 3 years ago
Andrew Dolgov a0e41f41a4 add svg loading indicators 3 years ago
Andrew Dolgov 7ec8a6cad0 simplify feed tree expando/loading/feed icon handling 3 years ago
Andrew Dolgov d9ba403927 remove some hardcoded color values 3 years ago
Andrew Dolgov 44b274b6d4 remove published opml (use CLI instead) 3 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
3 years ago
Andrew Dolgov f81a579386 fix selected feedtree item being invisible in dark theme 3 years ago
JustAMacUser 39bbbef030 Fix E_NOTICE in `add_handler()`. 3 years ago
Andrew Dolgov 1870fe172b feed tree: css cleanup; set cursor 3 years ago
Andrew Dolgov b23ba3e236 error log: fix column widths 3 years ago
Andrew Dolgov a0ce7f556b nsfw: set cursor pointer 3 years ago
Andrew Dolgov 1664b87821 Merge branch 'weblate-integration' 3 years ago
Andrew Dolgov 13210747d8 mailer: stop warning if to_name is unset (it's optional anyway) 3 years ago
Andrew Dolgov 15b39a534d Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov f7ee812db2 update editorconfig 3 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
3 years ago
Jordan Galby 3d801b1ac5 set orm and pdo mysql charset on connection 3 years ago
Andrew Dolgov 2f402d598d only show right-side feed icon for vfeeds 3 years ago
Andrew Dolgov 38ab3ef11c Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 4ddcd54e8d * limit progressfunction debugging to size quota exceeded notifications
* af_redditimgur: reparent generated iframes outside of post table
3 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
3 years ago
Philip Klempin fa22e1bc35 Add coalescing operator to otp_enabled when changing user password 3 years ago
Andrew Dolgov 4e81233ac9 make description clickable in plugin list row 3 years ago
Andrew Dolgov fcce1c443e api: don't try to pass null site_url to Article::_get_image() 3 years ago
Andrew Dolgov bc73bf0f67 cdmToggleGridSpan: toggle classname instead of a style property 3 years ago
Andrew Dolgov efde6d36c7 add HOOK_HEADLINES_SCROLL_HANDLER 3 years ago
Andrew Dolgov e85cba5958 sticky header: better positioning strategy 3 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/
3 years ago
Andrew Dolgov 52d1a5c96d gettextify previous 3 years ago
Andrew Dolgov 580eccd3da throttle login attempts, controlled by Config::AUTH_MIN_INTERVAL 3 years ago
Andrew Dolgov b9268fcc88 schema: add ttrss_users.last_auth_attempt 3 years ago
Andrew Dolgov 96d89fe912 shorten_expanded: reduce log spam 3 years ago
Andrew Dolgov bd1630d278 grid mode: limit word breaking to link elements 3 years ago
Andrew Dolgov 76a6060ca3 get_override_links: actually return overrides 3 years ago
Andrew Dolgov 4949e1a590 valid OTP code should not be enough to login, oops 3 years ago
Andrew Dolgov 146b1e0feb * shorten_expanded: use ResizeObserver (DUH)
* add HOOK_HEADLINES_RENDERED
3 years ago
Andrew Dolgov 6e0474a7c8 update zoom layout a bit 3 years ago
Andrew Dolgov f67d2623b7 add some media queries to improve main UI on small-width devices 3 years ago
Andrew Dolgov a4da2f1e62 continuation of the css cleanup 3 years ago
Andrew Dolgov 755072de91 css cleanup, combined mode, fonts 3 years ago
Andrew Dolgov de47082ca6 Article.cdmToggleGridSpan: also set as active 3 years ago
Andrew Dolgov f9a381ecca grid: add a header icon (and a hotkey) to toggle article span entire row 3 years ago
Andrew Dolgov 27ab16b6dc add Config::LOCAL_OVERRIDE_JS 3 years ago
Andrew Dolgov 324aef9f6f route Logger:log() to user_error() if there's no adapter 3 years ago
Andrew Dolgov 03361dda34 remove previous spacer-specific hack, not needed anymore 3 years ago
Andrew Dolgov 24e64b8c78 exp: set last odd grid child to span all columns 3 years ago
Andrew Dolgov 21e0b28cf1 nsfw plugin: we don't actually need any JS 3 years ago
Andrew Dolgov f9a9fcbb56 fix related to Promise.allSettled() returning a bit different result object 3 years ago
Andrew Dolgov 3e1b3e8ea8 grid: add workaround for a single loaded headline not spanning all columns 3 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
3 years ago
Andrew Dolgov 84fe383ed4 adjust grid view footer (3) 3 years ago
Andrew Dolgov 16726ec07f adjust grid view footer (2) 3 years ago
Andrew Dolgov a0dd5baa51 adjust grid view footer 3 years ago
Andrew Dolgov 5e738ec278 shorten stuff in af_zz_vidmute 3 years ago
Andrew Dolgov 71b12857e0 in grid mode, also force word-break .intermediate (enclosures) 3 years ago
Andrew Dolgov 353ee40378 shorten_expanded: remove loading=lazy on the js side instead 3 years ago
Andrew Dolgov 668b0ac7a6 shorten_expanded: no need to hook on HOOK_SANITIZE anymore 3 years ago
Andrew Dolgov fb89c3bad0 instead of a fixed column layout, fit based on minimum column size 3 years ago
Andrew Dolgov 5bc47451e1 shorten_expanded: increase timeout 3 years ago
Andrew Dolgov 36ad46e60d * shorten_expanded: use promises instead of a timeout hack
* normalize some icon colors
3 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/
3 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/
3 years ago
Andrew Dolgov 96031c80bf stop setting specific background color on .cdm.expanded 3 years ago
Andrew Dolgov e826c9e055 fix crash in preferences due to headlines-frame missing 3 years ago
Andrew Dolgov f58879c1dc small stuck header fixes in grid mode 3 years ago
Andrew Dolgov bdc72e5b63 fix headlines-spacer height in grid mode 3 years ago
Andrew Dolgov df9c389cbf in grid mode, hide feed title from header 3 years ago
Andrew Dolgov b6033d0bbd grid view tweaks 3 years ago
Andrew Dolgov 0b93d8d013 add hotkey to toggle grid view 3 years ago
Andrew Dolgov 089fa5ec26 use proper syntax for equal-width columns 3 years ago
Andrew Dolgov 87d13e826f fix vfeed group subtitle in grid mode 3 years ago
Andrew Dolgov eba8c97f36 some minor grid stuff 3 years ago
Andrew Dolgov a3ab4020bf set #headlines-spacer, etc, to span grid columns 3 years ago
Andrew Dolgov ddfa39015e experimental: add preference to show combined mode headlines as a 2 column grid 3 years ago
Andrew Dolgov 6ec66d0ce5 set border color on that too 3 years ago
Andrew Dolgov f804caec90 support coloring counters by feed-id/is-cat; set fresh counter to green 3 years ago
Andrew Dolgov ae7b87bca9 add HOOK_HEADLINE_MUTATIONS, HOOK_HEADLINE_MUTATIONS_SYNCED 3 years ago
Andrew Dolgov 2160a86092 show E_COMPILE_ERROR in event log at higher severity levels 3 years ago
Andrew Dolgov 4e1c78374f error log: allow wrapping long filenames 3 years ago
Andrew Dolgov 74391ec30a reorganize update.php a bit, remove unneeded options 3 years ago
Andrew Dolgov dd9d017f7d add another coalesce for rule inverse 3 years ago
Andrew Dolgov 9b321be270 get_article_filters: set coalesce values for inverse and match_any_rule 3 years ago
Andrew Dolgov 4fe2e6bbf1 app password list: fix th/td alignment 3 years ago
Andrew Dolgov b1961163b8 af_redditimgur: import link flair as tags 3 years ago
Andrew Dolgov bc7cb76379 describe global settings in classes/config.php 3 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/
3 years ago
Andrew Dolgov ea25c49eb9 update messages.pot 3 years ago
Andrew Dolgov fe4c284858 Merge branch 'weblate-integration' 3 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
3 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.
3 years ago
Andrew Dolgov cfb4882591 cleanup javascript_tag and stylesheet_tag 3 years ago
Andrew Dolgov 28dd255c30 show user css editor before xhr is completed 3 years ago
Andrew Dolgov bfeaf4d6a4 search dialog: add button icon 3 years ago
Andrew Dolgov ef03f8188c api: add support for setting score (bump api level to 16) 3 years ago
Andrew Dolgov c26f58d8a5 fix some php8 warnings 3 years ago
Andrew Dolgov a125e8540d Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov 1fb7125f90 minor cleanup related to toolbar-main (use dijit methods, etc) 3 years ago
Andrew Dolgov 46b77fc6b7 fix digest preview not working on mysql because of a quoted LIMIT argument 3 years ago
Andrew Dolgov 5db6939dc9 add to previous a bit 3 years ago
Andrew Dolgov 603cc89638 check updates one plugin at a time 3 years ago
Andrew Dolgov f4d0e7bb6d * af_redditimgur: optionally import score
* add pluginhost->set_array() to set many plugin settings at once
3 years ago
Andrew Dolgov 72c04123d4 HOOK_ARTICLE_IMAGE: stop after first provided match 3 years ago
Andrew Dolgov 518e677a6b nsfw: fix wrong return parameter count in hook article image 3 years ago
Andrew Dolgov 266c8a6eae add nsfw.png placeholder 3 years ago
Andrew Dolgov ac6a59914b nsfw: support API clients 3 years ago
Andrew Dolgov ffb93d72ac fix previous to actually save enabled plugins 3 years ago
Andrew Dolgov 773bad1490 prevent list of enabled plugins resetting if saved while in search results 3 years ago
Andrew Dolgov 1dcc36deca make rendered labels clickable 3 years ago
Andrew Dolgov c036c27ec7 logger: use constants instead of hardcoded string literals 3 years ago
Andrew Dolgov 17650775d2 hide event log accordion pane if LOG_DESTINATION is not sql 3 years ago
Andrew Dolgov 5bb8714839 allow blank override values 3 years ago
Andrew Dolgov 77b5201b7d set plugin list name width same as preferences table 3 years ago
Andrew Dolgov d6fd0d5462 add some icons, remove some words 3 years ago
Andrew Dolgov 39c570a9ff Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov b27218a1e3 add some more dialog icons 3 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
3 years ago
Andrew Dolgov 1d9fa2a42e reduce overhead in hash set/get 3 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)
3 years ago
Andrew Dolgov 7b0b5b55c7 fix plugins-list line height 3 years ago
Andrew Dolgov 68ecf52594 some small layout fixes, remove a few inline styles 3 years ago
Andrew Dolgov 473ea6255c render list of plugins on the client 3 years ago
Andrew Dolgov 217922899d set some more type hints 3 years ago
Andrew Dolgov 270f0c3132 general cleanup, set some type hints 3 years ago
Andrew Dolgov 63651bd91d fix some leftover variables 3 years ago
Andrew Dolgov e5469479c1 * don't try to update custom set feed favicons
* cleanup update_rss_feed() a bit, use ORM
3 years ago
Andrew Dolgov 42e057c808 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 53dcd4b229 fix plugins not shown as already installed if they have more than 1 dash 3 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
3 years ago
wn_ 2e8b064236 The type hint for 'DAEMON_MAX_CHILD_RUNTIME' should be T_INT 3 years ago
Andrew Dolgov 2cd159e2ce use separate database column for OTP secrets (migrate previous format if needed) 3 years ago
Andrew Dolgov 2aed79d729 schema: add separate otp_secret column 3 years ago
Andrew Dolgov ecb94ec23d login page: fix a warning if return is unset 3 years ago
Andrew Dolgov 5c1f9f31bd add a bunch of button icons 3 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.
3 years ago
Andrew Dolgov 98c75a9e43 don't check for plugin updates automatically on pane open 3 years ago
Andrew Dolgov b649d2240f split af_zz_noautoplay into a separate repo 3 years ago
Andrew Dolgov c8883d3440 af_comics filters: don't try to load empty html 3 years ago
Andrew Dolgov bc2953b5e7 split no_url_hashes into a separate repo 3 years ago
Andrew Dolgov 198c9b4069 split scored_oldest_first into a separate repo 3 years ago
Andrew Dolgov e8e6329040 rename unfairly prefixed get_enclosures() in feeditem 3 years ago
Andrew Dolgov c744cfe2dc plugin installer: show last commit timestamp 3 years ago
Andrew Dolgov d016f7a499 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 476965b161 show installed plugins in the installer list 3 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
3 years ago
Threk 9442ceb7bd Fix Undefined index when using Single User Mode 3 years ago
Andrew Dolgov f398fea414 shorten plugin list action buttons 3 years ago
Andrew Dolgov cb4b730e42 split af_unburn 3 years ago
Andrew Dolgov 386dc415d9 a bit better search behavior for plugin installer 3 years ago
Andrew Dolgov 9b8b07376f shorten install button text 3 years ago
Andrew Dolgov f90531ae40 reduce plugin installer entry height 3 years ago
Andrew Dolgov 6cf771f2bc _get_available_plugins: decode as array 3 years ago
Andrew Dolgov c50a4296a5 split vf_shared 3 years ago
Andrew Dolgov 04128c7870 add search to plugin installer 3 years ago
Andrew Dolgov 2f6ea8b387 split a bunch of plugins into separate repos 3 years ago
Andrew Dolgov b74e313844 use computed style for element.prototype.visible 3 years ago
Andrew Dolgov 4fda5ccd0e fix a bunch of bookmarklets login forms not leading back 3 years ago
Andrew Dolgov 30765805fd use orm for settings profiles stuff 3 years ago
Andrew Dolgov 31b29e0a56 log applied migrations 3 years ago
Andrew Dolgov 8f8ca49e4b migrations: refuse to apply empty schema files 3 years ago
Andrew Dolgov 4ede76280b migrations: don't try to use transactions on mysql 3 years ago
Andrew Dolgov bd4ade6329 remove ttrss_version from base schema 3 years ago
Andrew Dolgov 5eb0f3d640 bring back web dbupdate using new migrations system 3 years ago
Andrew Dolgov e19570f422 sessions: don't check schema version 3 years ago
Andrew Dolgov c0fb0a5ec0 wip for db_migrations for core schema 3 years ago
Andrew Dolgov 921569e5da support loading base schema as latest version 3 years ago
Andrew Dolgov 8256ab5dd9 wip: initial for db_migrations 3 years ago
Andrew Dolgov 0cb719a404 add basic local plugin uninstaller 3 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/
3 years ago
Andrew Dolgov dfdb746a76 add word wrap for git stdout/stderr pre elements 3 years ago
Andrew Dolgov cb7f322f09 add basic plugin installer (uses tt-rss.org) 3 years ago
Andrew Dolgov 06cb181f73 add update button for system plugins 3 years ago
Andrew Dolgov 75e659ba65 reduce Amount of Caps Used in Multiple Dialogs 3 years ago
Andrew Dolgov 0730128a97 add a send test email button to prefs/system 3 years ago
Andrew Dolgov dbda996a7a previous one was not good enough i guess 3 years ago
Andrew Dolgov 1aedd22306 config::make_self_url() strip index.php etc 3 years ago
Andrew Dolgov 50087df162 * remove _SKIP_SELF_URL_PATH_CHECKS
* simplify SELF_URL_PATH checks wrt trailing slash
3 years ago
Andrew Dolgov adf7189e94 show timing information in xhr.post/json 3 years ago
Andrew Dolgov 3b67abb0ea reddit: import comment counts 3 years ago
Andrew Dolgov 6f93c45c28 use orm in some more places; prevent _get_cat_title from hitting the db for uncategorized 3 years ago
Andrew Dolgov 9ec0732942 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov ba86c64d38 add digest preview button, also fix a bunch of bugs 3 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
3 years ago
sam302psu 57fdf032e9 changed skip and limit to coalesce to 0 instead of "" 3 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.
3 years ago
Andrew Dolgov 386316aba1 update previous (comment) 3 years ago
Andrew Dolgov 1ab6ca57af initialize Db object early because otherwise ORM might be used unconfigured 3 years ago
Andrew Dolgov d6629ed188 move dbupdater to db/updater; move base SCHEMA_VERSION constant inside db/updater class 3 years ago
Andrew Dolgov 86b12fc06c pluginhost: remove namespace classloader, plugins should use composer instead 3 years ago
Andrew Dolgov 08ff629af5 limit user data sent to frontend 3 years ago
Andrew Dolgov d4ad483add user editor: allow toggling otp 3 years ago
Andrew Dolgov 982bd838bf use orm when setting personal data; fix some warnings in mailer class 3 years ago
Andrew Dolgov 30b94fb194 store widescreen mode setting in preferences instead of a cookie 3 years ago
Andrew Dolgov 1a7f724bfa move around some methods in base plugins class 3 years ago
Andrew Dolgov 20d0cbff77 use ORM for article _labels_of/_feeds_of 3 years ago
Andrew Dolgov f9888fc67f use separate connection for logging 3 years ago
Andrew Dolgov c4eaab8a31 feeds/_add_cat: use ORM 3 years ago
Andrew Dolgov 7cf12233d7 use ORM when subscribing feeds 3 years ago
Andrew Dolgov dae0476159 sql logger: use orm 3 years ago
Andrew Dolgov 2005a7bf4f revise behavior of Feeds::_cat_of 3 years ago
Andrew Dolgov f097ae608d article/redirect: use orm (cast id to int) 3 years ago
Andrew Dolgov 3bab5ca6b1 article/redirect: use orm 3 years ago
Andrew Dolgov f195e86be3 don't rely on exit code when checking version (again) 3 years ago
Andrew Dolgov 84d8b08d1f use orm for feed access keys 3 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
3 years ago
Andrew Dolgov 6f835ded78 remove (unused) prefs/toggleAdvanced 3 years ago
Andrew Dolgov f56a4eab17 use orm for app password stuff 3 years ago
Andrew Dolgov 372e8e062c Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 51ed72efab use dash instead of space when invoking git to get version 3 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
3 years ago
wn_ 03400bd8d4 Get the version as an array in RPC->checkforupdates. 3 years ago
Andrew Dolgov 031ee47a3e don't try to pass string literal NOW() to ORM as a timestamp 3 years ago
Andrew Dolgov b150e46a52 revert back load_filters-related changes 3 years ago
Andrew Dolgov cd962dfa00 delete Article getScore (seems to be unused) 3 years ago
Andrew Dolgov 56f658711f use orm for a bunch of short feed/cat queries 3 years ago
Andrew Dolgov 8b1a2406e6 userhelper: use orm for a few more user-related things 3 years ago
Andrew Dolgov 127a868e40 userhelper: use orm for some things 3 years ago
Andrew Dolgov f38be747d1 initial for idiorm 3 years ago
Andrew Dolgov f96abd2b52 generate_syndicated_feed: timestamp is a strtotime() expression, not an integer 3 years ago
Andrew Dolgov 2d1391a02b come to think of it, we don't need it at all 3 years ago
Andrew Dolgov dbad39d7a2 auth_internal: don't try to get otp_enabled on old schema 3 years ago
Andrew Dolgov 6359259dbb simplify internal authentication code and bump default algo to SSHA-512 3 years ago
Andrew Dolgov 320503dd39 move version-related stuff to Config; fix conditional feed requests 3 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) 3 years ago
Andrew Dolgov 1e6973307c we don't need to initialize urlhelper properties 3 years ago
Andrew Dolgov 7ef72fe0dc move startup checks to Config, set a bunch of @deprecated annotations 3 years ago
Andrew Dolgov b05d4e3d9f speed up plugin updating a bit, fix some phpstan warnings 3 years ago
Andrew Dolgov bf02afed45 check schema version on backend calls because session stuff does it anyway and it's already cached 3 years ago
Andrew Dolgov 1bb0d9b603 sanity_check: config.php is now optional, also cleanup some error messages 3 years ago
Andrew Dolgov a22ddb2fe0 move material-icons to composer 3 years ago
Andrew Dolgov bada1601fc OTP form: simplify layout, use dojo controls 3 years ago
Andrew Dolgov f4fdc9c2a3 some plugin updater UI improvements 3 years ago
Andrew Dolgov afc7142250 move all $fetch globals to UrlHelper 3 years ago
Andrew Dolgov e2cbb54b2c plugin updater: show changes before updating 3 years ago
Andrew Dolgov 7f2fe465b0 add plugin updates checker into normal updates checker 3 years ago
Andrew Dolgov d821e4b090 disable plugin update checking if CHECK_FOR_UPDATES is disabled 3 years ago
Andrew Dolgov 85f411d688 don't try to update all plugins 3 years ago
Andrew Dolgov 15f9cb708e reload prefs when plugin updater is closed 3 years ago
Andrew Dolgov de63e3799a only show plugin update buttons when needed 3 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/
3 years ago
Andrew Dolgov cf5c7c4f29 feeds/add: hide php8 warning 3 years ago
Andrew Dolgov 78a7b3642f af_redditimgur: allow adding custom tags for NSFW posts 3 years ago
Andrew Dolgov dfff2cef7b add basic updater for stuff in plugins.local 3 years ago
Andrew Dolgov 5edcbf2e9b add an option to disable conditional counters 3 years ago
Andrew Dolgov c1cd3324e3 bump schema for ttrss_user_labels2 indexes 3 years ago
Andrew Dolgov 6d06450649 don't rely only on label_cache contents when displaying headline labels 3 years ago
Andrew Dolgov 126b1fd2de don't try to compare null value against anything 3 years ago
Andrew Dolgov c521e26a19 use absolute namespace for readability 3 years ago
Andrew Dolgov d6bb77f452 exclude a bunch of phpunit test files 3 years ago
Andrew Dolgov ebf16a36a1 remove a bunch of return type hints that didn't quite fit 3 years ago
Andrew Dolgov ef8c3abd7e Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 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
3 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
3 years ago
Andrew Dolgov bc4475b669 add missing composer files 3 years ago
Andrew Dolgov cf1ede0ba8 pull latest readability-php via composer 3 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
3 years ago
Andrew Dolgov d577eb898c when browsing by tags, return same set of columns as normally 3 years ago
Andrew Dolgov c01b6e43fd add pluginhost->get_array() shorthand 3 years ago
wn_ 86513d70dd Fix the type hint for '_DEFAULT_VIEW_MODE'. 3 years ago
Andrew Dolgov bf9033beb6 rebase-translations: disable everything except for messages.pot 3 years ago
Andrew Dolgov 167c9fc34e silence php8 warnings in otp secondary login form 3 years ago
Andrew Dolgov e6a875b7e4 check if client-presented URL scheme is different from one configured in SELF_URL_PATH 3 years ago
Andrew Dolgov 4896874bda _get_headlines: don't try to use _SESSION uid 3 years ago
Andrew Dolgov fa7c6a6129 we need to compile .mo files after all 3 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/
3 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/
3 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/
3 years ago
Andrew Dolgov cb512d653c match a few more translated strings 3 years ago
Andrew Dolgov 2a0b3a161c rebase-translations: try only dealing with messages.pot, let weblate rebuild .po files 3 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/
3 years ago
Andrew Dolgov c21fbb2d13 rebase translations, fixing a few JS strings not mached; remove obsolete scripts (2) 3 years ago
Andrew Dolgov 15cad4a9c0 rebase translations, fixing a few JS strings not mached; remove obsolete scripts 3 years ago
Andrew Dolgov 634f1210a6 Merge branch 'weblate-integration' 3 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/
3 years ago
Andrew Dolgov 8d49b6396e Merge branch 'weblate-integration' 3 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/
3 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/
3 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/
3 years ago
Andrew Dolgov 26a6177bc9 upd previous 3 years ago
Andrew Dolgov 9689f884ab add Prefs::DEBUG_HEADLINE_IDS 3 years ago
Andrew Dolgov 05f690c86b add a separator before HEADLINES_NO_DISTINCT 3 years ago
Andrew Dolgov 3ab664f846 feeds/view: silence view_mode warning 3 years ago
Andrew Dolgov f3d4bae32e add an option to disable DISTINCT on headlines query (unless it's Labels category) 3 years ago
Andrew Dolgov 51142e1bf8 silence phpstan warning 3 years ago
Andrew Dolgov 7815a881e8 cleanup previous 3 years ago
Andrew Dolgov 56b10fea18 pass translations to frontend as a json object 3 years ago
Andrew Dolgov fd9cd52929 prefs: migrate after cache has been filled to skip 1 pref request 3 years ago
Andrew Dolgov a1ca62af50 cache schema version better 3 years ago
Andrew Dolgov 22ae284db4 reduce overall amount of unnecessary database queries 3 years ago
Andrew Dolgov 281f2efeb8 wrap prefs->migrate() into a transaction block 3 years ago
Andrew Dolgov 89ad25405e userhelper: only notify failed login for actual logins 3 years ago
Andrew Dolgov 8915bd1b21 fix crash caused by non-numeric non-null _SESSION[uid] passed to sql logger 3 years ago
Andrew Dolgov 34c74400a4 enforce some stricter type checking for loggers 3 years ago
Andrew Dolgov dcf0135285 logger: shorter syntax 3 years ago
Andrew Dolgov 59c14e9c00 api: remove base64 encoded passwords (wtf), log all authentication failures in userhelper 3 years ago
Andrew Dolgov efd196839a stop caching schema version entirely, fix some session_start() related warnings 3 years ago
Andrew Dolgov 1464abbbfc prefs cleanup 3 years ago
Andrew Dolgov f137e64a13 get_version: pass int to strftime() 3 years ago
Andrew Dolgov c96172fa04 use constants in get_pref()/set_pref() 3 years ago
Andrew Dolgov 5aa05c90e1 pref-prefs: use constants instead of hardcoded strings 3 years ago
Andrew Dolgov 011e318947 prefs: don't try to do anything on schema < 141 3 years ago
Andrew Dolgov 6f02b1afd0 cleanup a bunch of old prefs code 3 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.
3 years ago
Andrew Dolgov 7f18e8c33b updater: show owner login instead of just uid 3 years ago
Andrew Dolgov 7869378436 deal with feed update scheduling w/ new prefs 3 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.
3 years ago
Andrew Dolgov 00d0cb8c81 remove unused data from schema files 3 years ago
Andrew Dolgov 2621fe7955 fix get_pref always using default profile; remove unneeded code from db_prefs 3 years ago
Andrew Dolgov bd2314170d implement prefs UI based on new prefs class and a few more things 3 years ago
Andrew Dolgov e858e979e9 Merge branch 'master' into wip-new-prefs 3 years ago
Andrew Dolgov 49a9afadce add prefs caching 3 years ago
Andrew Dolgov 1112922029 bump schema for upcoming prefs overhaul 3 years ago
Andrew Dolgov 8026f3c3bd initial (wip) for new prefs: add missing 3 years ago
Andrew Dolgov 988eb3ac91 initial (wip) for new prefs 3 years ago
Andrew Dolgov 922a699215 reorder debug targets 3 years ago
Weblate c70fc68012 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 93940d2a9f Merge branch 'master' of git.fakecake.org:fox/tt-rss into weblate-integration 3 years ago
Andrew Dolgov 1adacd0572 rebase translations 3 years ago
Andrew Dolgov db583287b2 add hide/show events for feeds sidebar 3 years ago
Andrew Dolgov 2f14fa1bc3 add a hack to position labels on a dijit toolbar better 3 years ago
Andrew Dolgov 7f41228a71 decouple runtime-info object from counters 3 years ago
Andrew Dolgov 553548b689 request label counters conditionally 3 years ago
Andrew Dolgov 9313ebf2e7 fix warning in counters::get_feeds() 3 years ago
Andrew Dolgov 8b09e653e0 pass array to setScore 3 years ago
Andrew Dolgov 155e4f6125 pass a bunch of related arrays properly to backend 3 years ago
Andrew Dolgov 96182597c4 fix typo 3 years ago
Andrew Dolgov 9ad5f04e51 only request counters once for headline mutations 3 years ago
Andrew Dolgov e468e5a589 cats_of: enforce owner_uid 3 years ago
Andrew Dolgov 6ea1430a04 no special counter handling for catchupAll 3 years ago
Andrew Dolgov e6505b7d83 _cats_of: only request parents if needed 3 years ago
Andrew Dolgov d6203bf350 try to calculate counters conditionally based on feed ids 3 years ago
Andrew Dolgov a42e8aad97 add Errors.php 3 years ago
Andrew Dolgov 8d2e3c2528 drop errors.php and simplify error handling 3 years ago
Andrew Dolgov 37d46411c7 App.requestCounters() is not a thing 3 years ago
Andrew Dolgov 85095f8a53 rename TTRSS_SESSION_NAME to SESSION_NAME 3 years ago
Andrew Dolgov ab4dafa4be config: add a type hint system 3 years ago
Andrew Dolgov 9e2e12dff8 add some ;s 3 years ago
Andrew Dolgov 46e650622c floIcon: declare images property 3 years ago
Andrew Dolgov 2ae0b7059f cleanup some defined-stuff 3 years ago
Andrew Dolgov 5229cc58b2 Merge branch 'wip-config-object' 3 years ago
Andrew Dolgov 4ed91619dd af_redditimgur: fix an oopsie 3 years ago
Andrew Dolgov cae54dad56 af_redditimgur: fix an oopsie 3 years ago
Andrew Dolgov 6e4fbbfa4d cleanup config.php-dist 3 years ago
Andrew Dolgov 29ada58b4a move db-prefs shortcut functions to functions.php 3 years ago
Andrew Dolgov 77e6d589ff allow adding custom config options 3 years ago
Andrew Dolgov fd5dd27f16 Merge branch 'master' of git.tt-rss.org:fox/tt-rss into wip-config-object 3 years ago
fox ac6cea859a Merge pull request 'Check whether data is parsable by 'imagecreatefromstring' in jimIcon.' (#7) from wn/tt-rss:jimIcon-imagecreatefromstring into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/7
3 years ago
Andrew Dolgov caf3040313 update config.php-dist 3 years ago
Andrew Dolgov 445ac1213c finalize config:: migration; make config.php optional 3 years ago
Andrew Dolgov 6b7af973b2 update gitignore 3 years ago
Andrew Dolgov 12bcf826e4 don't include config.php everywhere 3 years ago
Andrew Dolgov 211f699aa0 migrate the rest into Config:: 3 years ago
Andrew Dolgov 383f4ca04a add config.php 3 years ago
Andrew Dolgov e4107ac952 wip: initial for config object 3 years ago
wn_ 7c966b69d5 Check whether data is parsable by 'imagecreatefromstring' in jimIcon. 3 years ago
Andrew Dolgov 42173386b3 dirname(__FILE__) -> __DIR__ 3 years ago
Andrew Dolgov add6242e51 do not use define_default() because it screws with static analyzers 3 years ago
fox 3f00502305 Merge pull request 'Let 'RSSUtils::check_feed_favicon' update existing favicons.' (#6) from wn/tt-rss:check-feed-favicon into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/6
3 years ago
wn_ 6fbf7ef368 Remove check against the old file in 'RSSUtils::check_feed_favicon'. 3 years ago
Andrew Dolgov be4e7b1340 fix several issues reported by phpstan 3 years ago
Andrew Dolgov 043ef3dad6 add chrome configuration for debugging 3 years ago
Andrew Dolgov 167ed87684 add launch.json for xdebug 3 years ago
Andrew Dolgov 33fff26869 reinstate HOOK_RENDER_ENCLOSURE 3 years ago
wn_ 02a9485966 Try to limit max favicon size, don't store current/old in a var. 3 years ago
Andrew Dolgov 6f29ecbbb9 add phpstan config 3 years ago
Andrew Dolgov f6bfb89b29 pref-prefs: switch to new control shorthand in a few places 3 years ago
wn_ cb401af6f6 Let 'RSSUtils::check_feed_favicon' update existing favicons. 3 years ago
Andrew Dolgov 861a632ac7 move published opml JS code to pref helpers 3 years ago
Andrew Dolgov c6b7a7f8d0 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 2ab215daca batch editor: comment out getChildByName 3 years ago
fox d0efa35d22 Merge pull request 'Open the default feed after unsubscribing' (#5) from wn/tt-rss:bugfix/post-unsubscribe-feed-selection into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/5
3 years ago
Andrew Dolgov 521d0b65c7 batch feed editor: use tab layout, cleanup 3 years ago
wn_ 1bd5152c80 Open the default feed after unsubscribing.
Previously the UI appeared to hang, even though the backend request had already completed successfully.
3 years ago
Andrew Dolgov d1328321be move published OPML endpoint to public.php 3 years ago
Andrew Dolgov 2843b99171 minor filter UI layout fix 3 years ago
Andrew Dolgov 810afdaf5a prevent creation of filter rules matching no feeds 3 years ago
Andrew Dolgov fb471652c0 Merge branch 'wip-filter-stuff' 3 years ago
Andrew Dolgov 9e56896bd4 Element visible: check for offsetHeight/offsetWidth 3 years ago
Andrew Dolgov 3b8d69206c deal with filter actions UI 3 years ago
Andrew Dolgov 94560132dd for the most part, deal with filter rules UI 3 years ago
Andrew Dolgov b4e96374bc more filter stuff 3 years ago
Andrew Dolgov da97b29dbe prevent filter selected text dialog from opening in wrong order 3 years ago
Andrew Dolgov 590b1fc39e a few more methods shuffled around 3 years ago
Andrew Dolgov be91355c20 first for filter frontend overhaul 3 years ago
Andrew Dolgov d6de021ae6 haven't i fixed this already 3 years ago
Andrew Dolgov 39be169f0b also disable Article.completeTags 3 years ago
Andrew Dolgov 5c7416458f rpc: disable completeLabels for now 3 years ago
Andrew Dolgov 22fe9b54d2 feed editor: use client dialog 3 years ago
Andrew Dolgov 9586c72a17 wip: feed editor client-side 3 years ago
Andrew Dolgov 545bcc3e4b bookmarklets: cleanup some more markup 3 years ago
fox b8786215dc Merge pull request 'Fix an undefined array key warning in 'catchupFeed'.' (#4) from wn/tt-rss:rpc-catchupfeed-warning into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/4
3 years ago
wn_ ce3e1756b3 Fix an undefined array key warning in 'catchupFeed'. 3 years ago
Andrew Dolgov 053b262aa7 rename public.php/cached_url to cached 3 years ago
Andrew Dolgov fc0ebf0891 move bookmarklet-related methods out of public.php into the plugin 3 years ago
Andrew Dolgov c9ccb0791d fix for startup crash because of classes containing spaces 3 years ago
Andrew Dolgov cf249d7e8c modify classname helpers to use element.classList; fix feed debugger & share--get 3 years ago
Andrew Dolgov d5f4979831 headlines.select: maybe fix another one 3 years ago
Andrew Dolgov 5cec4eb015 af_readability: fix selector 3 years ago
Andrew Dolgov 760a26e484 fix height of dijit input boxes embedded into toolbars 3 years ago
Andrew Dolgov 737cffc241 render feed icon markup on the client 3 years ago
Andrew Dolgov d445530fa0 format note on the client 3 years ago
Andrew Dolgov 4fa8450d38 setArticleTags: always return tags from the db 3 years ago
Andrew Dolgov 921b5ca2ce add onTagsUpdated similar to onLabelsUpdated 3 years ago
Andrew Dolgov e73779fec1 render tags on the client 3 years ago
Andrew Dolgov d9fe14a012 use template strings in a bunch of places instead of id concatenation 3 years ago
Andrew Dolgov 131f34648d render headline labels on the client 3 years ago
Andrew Dolgov 660a1bbe01 * switch to xhr.post() almost everywhere
* call App.handlerpcjson() automatically on json request (if possible)
 * show net/log indicators in prefs
3 years ago
Andrew Dolgov bb4e4282f4 migrate a bunch of xhrPost invocations 3 years ago
Andrew Dolgov 6b43b788d9 migrate xhrJson invocations to the new helper 3 years ago
Andrew Dolgov dba6dce3b3 add element fadeout/fadein and a shorter xhr helper 3 years ago
Andrew Dolgov f645120641 table helpers: don't try to iterate over a single element 3 years ago
Andrew Dolgov d26269865f use .closest() instead of .up() to lookup parent by selector 3 years ago
Andrew Dolgov bec35200e9 fix some eslint-related stuff 3 years ago
Andrew Dolgov 0832dd9d40 fix eslint configuration 3 years ago
Andrew Dolgov 00310d2d23 cleanup some unused code, fix App.byId() invoked by wrong name 3 years ago
Andrew Dolgov dcfea9baac properly validate feed editor dialog 3 years ago
Andrew Dolgov d57e7eaa98 move stuff in common.js around a bit 3 years ago
Andrew Dolgov 5475eed452 bring back hash functions 3 years ago
Andrew Dolgov b6c3dde1cc add $/423 shims 3 years ago
Andrew Dolgov c088e9d9d8 get rid of a few more prototype-isms 3 years ago
Andrew Dolgov 89fd9ec8c3 compat shim fixes 3 years ago
Andrew Dolgov e61e7c8356 compat shim fixes 3 years ago
Andrew Dolgov f77c17c6f0 add Element toggleClassName 3 years ago
Andrew Dolgov 70fa423026 initial for RIP prototype/scriptaculous 3 years ago
Andrew Dolgov 0b6a71f8ea Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 049c423454 fix hotkey help toolbar action 3 years ago
Andrew Dolgov 839cb2cd21 rebase translations 3 years ago
Andrew Dolgov 61fdce4f44 rework previous to be even less jumpy 3 years ago
Andrew Dolgov 2c5927d8cd rework previous to be less jumpy 3 years ago
Andrew Dolgov 2e4b403787 * use es5 (?) default parameter values for some functions
* when moving to next article, try to show hsp if its next
3 years ago
Andrew Dolgov bed36cbf9f af_psql_trgm: cleanup 3 years ago
Andrew Dolgov a2c75257f1 bookmarklets: cleanup 3 years ago
Andrew Dolgov 75435aa960 user details: cleanup 3 years ago
Andrew Dolgov d8a99ce06a remove unneeded headings 3 years ago
Andrew Dolgov 39c0fe3697 shorten many invocations of Ajax.Request in inline form methods 3 years ago
Andrew Dolgov ee0b66b6bd af_proxy_http: markup cleanup 3 years ago
Andrew Dolgov e03d6379a6 small markup adjustment 3 years ago
Andrew Dolgov 466cba39d8 Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov 1adb9bb6b6 profiles: use client dialog; move related methods to pref-prefs 3 years ago
Andrew Dolgov b888bc2091 cache_starred_images: don't try to use undefined array index 3 years ago
Andrew Dolgov e4609c18ef * add (disabled) shortcut syntax for plugin methods
* add controls shortcut for pluginhandler tags
 * add similar shortcut for frontend
 * allow plugins to selectively exclude their methods from CSRF checking
3 years ago
Andrew Dolgov b16abc157e * App: rename hidden to hidden_tag
* search: use client dialog
 * add some form field helpers
3 years ago
Andrew Dolgov 92cb91e2e2 search dialog: bring back id of language dropdown 3 years ago
Andrew Dolgov 35b6d63289 af_proxy_http: don't try to proxy back to ourselves 3 years ago
Andrew Dolgov 6ecee2abbd cache_starred_images: minor fixes 3 years ago
Andrew Dolgov ea37d05a83 delete unused mail .pngs 3 years ago
Andrew Dolgov 2ac6508fe6 mail, mailto: cleanup markup 3 years ago
Andrew Dolgov 7be1e3ed38 pluginhandler: reject method requests without CSRF 3 years ago
Andrew Dolgov 2b2833bb4f plugins: load dialogs via xhr instead of http 3 years ago
Andrew Dolgov 4632d6cf55 fix some php8 warnings 3 years ago
Andrew Dolgov e9c3118ddd don't show E_USER_DEPRECATED on the frontpage 3 years ago
Andrew Dolgov 538f87e415 af_psql_trgm: don't load dialog via http 3 years ago
Andrew Dolgov d439685895 pluginhandlers: post notice if pluginmethod is requested without CSRF token 3 years ago
Andrew Dolgov 00b31c3f53 af_readability: cleanup markup 3 years ago
Andrew Dolgov 3c14eed1c2 close_button: fix color not applying 3 years ago
Andrew Dolgov 35b6a88146 RIP af_tumblr_1280 3 years ago
Andrew Dolgov 7587f2cdc6 af_redditimgur: cleanup markup 3 years ago
Andrew Dolgov 91049335eb af_readability: cleanup markup 3 years ago
Andrew Dolgov 9ac6741d24 af_comics: markup cleanup 3 years ago
Andrew Dolgov 4325c30a3f share: markup cleanup 3 years ago
Andrew Dolgov 273ada7353 * implement shortcut syntax for exposed plugin methods
* move shared article rendering code to share plugin
3 years ago
Andrew Dolgov 7adcada324 share plugin: cleanup, fix icon not highlighting properly 3 years ago
Andrew Dolgov 0fc783e2b3 cleanup markup in some plugins, make nsfw generate dijit widgets 3 years ago
Andrew Dolgov 89e8176c69 Article.render: parse dojo widgets 3 years ago
Andrew Dolgov 91e7969383 replace a few more controls to new style 3 years ago
Andrew Dolgov 24c79d91c2 controls_compat: comment out most of them 3 years ago
Andrew Dolgov f58c49beaa replace a few more controls to new style 3 years ago
Andrew Dolgov bf88c64d1e fix floicon not imported from global namespace 3 years ago
Andrew Dolgov 9d7ba773ec move session-related functions to their own namespace 3 years ago
Andrew Dolgov 7fad6ce651 move rgb/hsl functions to their own namespace 3 years ago
Andrew Dolgov bdbbdbb0ed rework controls to accept parameters as array 3 years ago
Andrew Dolgov 627af2c236 amend previous to fix actual underlying problem (double escaping) 3 years ago
Andrew Dolgov 4f4e57bb26 hidden_tag: temporarily prevent htmlspecialchars() to stop embedded JSON from breaking 3 years ago
Andrew Dolgov 1f5d81b77c use a few more control helpers for checkboxes 3 years ago
Andrew Dolgov af4b3e7df0 login form: use control helpers 3 years ago
Andrew Dolgov 22fc6871e8 remove backend helper and move its only function to rpc for the time being 3 years ago
Andrew Dolgov d7127cead3 feed debugger: use hidden helpers; add button helpers 3 years ago
Andrew Dolgov 1f43d7916c replace print_hidden with hidden_tag 3 years ago
Andrew Dolgov 26d6b84a57 add namespaced controls with unified naming; deprecated old-style control shortcuts 3 years ago
Andrew Dolgov cb6b3584ce pref-labels: remove unused code 3 years ago
Andrew Dolgov 3887665bcb CommonDialogs.addLabel: remove long unused parameters 3 years ago
Andrew Dolgov cca84aedfd _format_enclosures: always return entries array 3 years ago
Andrew Dolgov 88f7c4f1a5 feeds/view: fix php8 warning 3 years ago
Andrew Dolgov 6e06fe2885 shorten_expanded: fix for posts without attachments 3 years ago
Andrew Dolgov 5c4223992f db-prefs: minor cleanup, add warnings if unknown prefs are requested 3 years ago
Andrew Dolgov 70e293bccb pref-filters: fix some warnings 3 years ago
Andrew Dolgov d4157b9e4e counters: just merge everything at once 3 years ago
Andrew Dolgov 39604bedef move reset_password to UserHelper 3 years ago
Andrew Dolgov 5d42ce553f drop legacy DB interface and related sanity checks 3 years ago
Andrew Dolgov 9f55454f63 remove the rest of db.php; rename some leftover methods in feeds 3 years ago
Andrew Dolgov bd3c38de84 move bookmarklet-related subscribe_to_feed_url to bookmarklet plugin 3 years ago
Andrew Dolgov cfad740c99 drop legacy db_ functions wrapper 3 years ago
Andrew Dolgov 91285e3868 router: add additional logging for refused requests; reject requests for methods starting with _ 3 years ago
Andrew Dolgov d1c83fad14 api: unify naming 3 years ago
Andrew Dolgov 71f2f4288f counters: one more 3 years ago
Andrew Dolgov 6426ae559a dbupdater: unify naming 3 years ago
Andrew Dolgov 166f2d4666 diskcache: unify naming 3 years ago
Andrew Dolgov 8e79f1717d prefs: unify naming 3 years ago
Andrew Dolgov 5704deb460 counters: unify naming 3 years ago
Andrew Dolgov 257efb43c6 article: unify naming 3 years ago
Andrew Dolgov 020f062a76 feeds: unify naming 3 years ago
Andrew Dolgov 6b006a18e7 subscribe to feed: use client dialog 3 years ago
Andrew Dolgov ecb36b6354 edit tags: use client dialog 3 years ago
Dario Di Ludovico 8b022c2bfb Translated using Weblate (Italian)
Currently translated at 100.0% (705 of 705 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/it/
3 years ago
Andrew Dolgov 82adb01307 render enclosures on the client 3 years ago
fox 916c21fe60 Merge pull request 'Lazy load image attachments' (#2) from verifiedjoseph/tt-rss:lazy-load-image-attachments into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/2
3 years ago
Andrew Dolgov 868b9b476e api: rewrite article urls at the very end to prevent plugins which expect source URLs from breaking 3 years ago
Andrew Dolgov 52a86c5e38 Revert "api: get flavor image from plugin-processed content"
This reverts commit a4604e892c.
3 years ago
Andrew Dolgov a4604e892c api: get flavor image from plugin-processed content 3 years ago
Andrew Dolgov 3c584376ca shared opml and feed dialogs: remove unique target element id, move associated methods into dialog 3 years ago
Andrew Dolgov 9f31381bb6 renderToolbar: support empty data i.e. dashboard feed 3 years ago
Andrew Dolgov a2e688fcb2 render headline-specific toolbar on the client 3 years ago
Joseph 68e2ccb354 Lazy load image attachments 3 years ago
Andrew Dolgov 37a81ba594 SingleUseDialog: destroy existing widget with same id on create 3 years ago
Andrew Dolgov ff6031d3c9 remove old-style markup from exception dialog 3 years ago
Andrew Dolgov 4996d8ccfe pref-users edit: use client dialog 3 years ago
Andrew Dolgov 0b7377238a add Handler_Administrative 3 years ago
Andrew Dolgov 33ea46c2bc pref-users/add: remove unused variable 3 years ago
Andrew Dolgov 0fbf109912 * remove users/filters toolbar edit button (just click on it)
* fix title of edit filter dialog always showing create filter
3 years ago
Andrew Dolgov a8cc43a0ff move logout_user() to UserHelper 3 years ago
Andrew Dolgov 2547ece0ca pref-users: cleanup index 3 years ago
Andrew Dolgov 1c7e4782aa prefs system: load phpinfo using inline method 3 years ago
Andrew Dolgov 6b5c9c781b pref prefs: load secondary tabs when needed 3 years ago
Andrew Dolgov e5cedc7d5f appPasswordList: markup cleanup 3 years ago
Andrew Dolgov 8e75551f95 pref prefs: split index into manageable chunks 3 years ago
Andrew Dolgov 15fd23c374 use shortcut echo syntax for php templates 3 years ago
Ptsa Daniel ce1831e2be Translated using Weblate (Chinese (Simplified))
Currently translated at 99.4% (701 of 705 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
3 years ago
Andrew Dolgov d4c925819b pref-feeds: load error button via xhr 3 years ago
Andrew Dolgov 43d8a1f2ff remove getinactivefeeds (duplicate functionality) 3 years ago
Andrew Dolgov 103d30ad3f batch subscribe: use client dialog 3 years ago
Andrew Dolgov c36b2adf84 feeds with errors: use client dialog 3 years ago
Andrew Dolgov 8464c619e4 inactive feeds: use client dialog 3 years ago
Andrew Dolgov 17413078a7 pref feeds: index cleanup, split into several methods, use tabs to maximize space for feed tree, persist feed tree state 3 years ago
Andrew Dolgov 9684ce5c4b minor fixes re: previous 3 years ago
Andrew Dolgov b112198991 pref filters index: markup cleanup 3 years ago
Andrew Dolgov 5127c29297 prefs system: markup cleanup 3 years ago
Andrew Dolgov aa63014073 pref-labels index: use cleaner markup 3 years ago
Andrew Dolgov 46f6d7c11a pref-labels/index: cleanup 3 years ago
Andrew Dolgov e7924c6dac label editor: use client dialog 3 years ago
Andrew Dolgov 0b71729bd3 Merge branch 'weblate-integration' 3 years ago
Andrew Dolgov eec5871f5f fail better if requested article URL is blank 3 years ago
Glandos e2c7166719 Translated using Weblate (French)
Currently translated at 98.7% (696 of 705 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/fr/
3 years ago
Andrew Dolgov d3940b6259 fix a bunch of warnings related to generated feeds 3 years ago
Andrew Dolgov 481bd76100 pref helpers: move some methods to their own sections 3 years ago
Andrew Dolgov 6af83e3881 drop ENABLE_GZIP_OUTPUT; system prefs: load php info only if needed 3 years ago
Andrew Dolgov e6624cf631 fix a few more session-related warnings 3 years ago
Andrew Dolgov 119a4226d8 validate_csrf: remove warning 3 years ago
Andrew Dolgov f2d3cba231 add HTTP_ACCEPT_LANGUAGE handling for php8 3 years ago
Weblate d02872983d 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 6365bf39d9 rebase translations 3 years ago
Andrew Dolgov 6d7fea537e silence some more eslint warnings 3 years ago
Andrew Dolgov 157675d9fd prefs: fix published shared URL dialog 3 years ago
Andrew Dolgov 7f0800537e silence (or fix) a bunch of eslint warnings 3 years ago
Andrew Dolgov ad7842c98a RIP tag cloud: last of the vanilla popup dialog system 3 years ago
Andrew Dolgov 9330bde991 batchsubscribe: xhr 3 years ago
Andrew Dolgov 03b85248e6 move some dialogs to xhr loading 3 years ago
Andrew Dolgov 71dfc83466 force _ENABLED_PLUGINS to string when passed to pluginhost 3 years ago
Andrew Dolgov 1f2ba932b8 RIP easy-installer 3 years ago
Andrew Dolgov d23a261b92 RIP self-registration 3 years ago
Andrew Dolgov 3268364693 more dialog-related cleanup 3 years ago
Andrew Dolgov 3d11c61f32 * OPML import: don't reload everything, just feed tree
* dialogs: use auto-destroying dialog for almost all dialogs instead of destroying them manually
* some general dialog-related cleanup
3 years ago
Andrew Dolgov 219cc9a0ab fix previous: secondary dialog not opening because of onLoad 3 years ago
Andrew Dolgov 8f8675a26a * filters: remove duplicate code, overall cleanup
* check if some tres exist before trying to reload them
3 years ago
Andrew Dolgov 699186f430 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
fox a718b692a0 Merge pull request 'Add defaults to api.php variables' (#1) from klempin/tt-rss:fix/undefined-content into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/1
3 years ago
Philip Klempin ace19c0790 Add defaults to api.php variables 3 years ago
Andrew Dolgov 0f7af07c6e edit filter dialog: cleanup 3 years ago
Andrew Dolgov 9804a17b79 fix typo 3 years ago
Andrew Dolgov a72171f8ef dialogOf: deal with both raw DOM elements and widgets 3 years ago
Andrew Dolgov 20fb056323 remove customizecss from csrf-ignored methods 3 years ago
Andrew Dolgov bf6d0f2817 various dialog-related fixes; stop referring to many dialogs by name; move filter test initial dialog to client side 3 years ago
Andrew Dolgov 72e38bfe1f rework a few more dialogs to use App.dialogOf() 3 years ago
Andrew Dolgov d466284fab * customizeCSS: client dialog
* remove hardcoded width from most dialogs (move to css)
* add helper to easily get dialog from its widget
* rework some dialog buttons to use current object instead of calling dialog by name
3 years ago
Andrew Dolgov cb7c075cd2 remove OPML.onImportComplete 3 years ago
Andrew Dolgov 83b0738b04 opml import: no more iframe, use client dialog 3 years ago
Andrew Dolgov 3134d71b8f fix typo introduced by 4182018cb7 3 years ago
Andrew Dolgov eac7ad5d34 remove explainError server-side dlg 3 years ago
Andrew Dolgov 4182018cb7 generated feed: use client dialog 3 years ago
Andrew Dolgov 1a680d4eae publishedOPML: use client dialog 3 years ago
Andrew Dolgov 848bc57f29 disable themes in safe mode; rework safe mode warning/login prompt 3 years ago
Andrew Dolgov 74986d1ac6 shorten pref tab names; make log-alert clickable 3 years ago
Andrew Dolgov cc646790fd format_backtrace: don't try to use resources as strings 3 years ago
Andrew Dolgov 09e9f34bb4 add UserHelper::find_user_by_login() and rewrite some user checks to invoke it instead of going through PDO 3 years ago
Andrew Dolgov 7af8744c85 authentication: make logins case-insensitive (force lowercase) 3 years ago
Andrew Dolgov e7e73193fe fix warning in profile edit dialog (2) 3 years ago
Andrew Dolgov 2505ae43a9 fix warning in profile edit dialog 3 years ago
Andrew Dolgov 9e1459d5db pref/prefs: fix warning when in non-default profile 3 years ago
Andrew Dolgov 72edab5f1c close_button: fix warning 3 years ago
Andrew Dolgov 7833760fa0 make feed/cat nested dropdowns a bit more readable 3 years ago
Andrew Dolgov d630a92c40 fix 2 warnings in feed editor 3 years ago
Andrew Dolgov 2f8efab275 api: one more php8 warning 3 years ago
Andrew Dolgov a5819569f2 pluginhost: a few more warnings and type hints 3 years ago
Andrew Dolgov 6a25bc53ef api: pass hook object payload by reference 3 years ago
Andrew Dolgov 3655e7aaf1 api: fix some php8 warnings (4) 3 years ago
Andrew Dolgov aba028a375 api: fix some php8 warnings (3) 3 years ago
Andrew Dolgov f6f0f21664 make ARTICLE_KIND_ constants class members 3 years ago
Andrew Dolgov 0871a51cb4 api: fix some php8 warnings (2) 3 years ago
Andrew Dolgov 63a90d26f3 api: fix some php8 warnings 3 years ago
Andrew Dolgov 7ae0e8d9c5 rewrite some more hooks in classes/feeds 3 years ago
Andrew Dolgov 345dbb3521 rewrite some more hooks 3 years ago
Andrew Dolgov 6c8ccd2acc front page log checker: filter out idiotic GD warning 3 years ago
Andrew Dolgov 9f3de2d24c login: fix profile warning 3 years ago
Andrew Dolgov 07408ac222 opml: normalize class name 3 years ago
Andrew Dolgov d91eae9c7e pluginhost: add some type hints 3 years ago
Andrew Dolgov 7eb860af61 even more hooks 3 years ago
Andrew Dolgov 6e57fd77af db: add type hints 3 years ago
Andrew Dolgov a14873d5b4 more hooks, also add type hint for PluginHost::getInstance() 3 years ago
Andrew Dolgov 54bbd08f38 some more hooks 3 years ago
Andrew Dolgov ca4c93c6b9 pluginhost: note hook function prototypes 3 years ago
Andrew Dolgov 7874f6ac58 remove PHPMD.UnusedFormalParameter 3 years ago
Andrew Dolgov a341a838b1 pluginhost: deny hook registration to plugins which lack relevant implementation methods 3 years ago
Andrew Dolgov 51d2deeea9 fix hierarchy of authentication modules, make everything extend Auth_Base and implement hook_auth_user() for pluginhost 3 years ago
Andrew Dolgov fc2e0bf67b log viewer: disable previous page on page 1 3 years ago
Andrew Dolgov fa2ebcd0a2 api: rewrite a few more hooks 3 years ago
Andrew Dolgov 363b3629a4 rewrite a few more hooks 3 years ago
Andrew Dolgov 3b52cea811 move some old-style handlers to new callback ones 3 years ago
Andrew Dolgov 1d5c8ee500 prefs: fix user plugins shown by incorrect criteria 3 years ago
Andrew Dolgov 1eb1629d9e pluginhost: rework run_hooks() to be shorter, add callback variant; implement exception handling for both 3 years ago
Andrew Dolgov 20b56b5b23 pluginhost: catch errors while loading plugin source code 3 years ago
Andrew Dolgov 4165834f80 pluginhost: catch fatal errors in plugin init 3 years ago
Andrew Dolgov 9de26d44da af_psql_trgm: fix warning 3 years ago
Andrew Dolgov d293cbd5a9 fix several warnings related to feed editor 3 years ago
Andrew Dolgov 43abc183ab add phpstan dummy file 3 years ago
Andrew Dolgov 0a788da2d2 dlg: fix unset param warning 3 years ago
Andrew Dolgov 3aada04c7f Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov 942afb43a1 sanity checks: use better CLI detection, shorten most of the text 3 years ago
Andrew Dolgov 5d0f65358f revert jimIcon stuff 3 years ago
Andrew Dolgov 3ad820e083 oops, remove unneeded warnings 3 years ago
Andrew Dolgov 479da5aa86 jimIcon: hide GD warning 3 years ago
Andrew Dolgov 3f972f8fed public/subscribe: fix warnings 3 years ago
Andrew Dolgov 983a874ddd bookmarklet: encode URL properly so special characters won't get lost 3 years ago
Andrew Dolgov c1ad7acfb9 bookmarklet: encode URL properly so special characters won't get lost 3 years ago
Andrew Dolgov 41fc03287e fix even more warnings reported by phpstan 3 years ago
Andrew Dolgov c94f1b6ff8 fix some more warnings reported by phpstan 3 years ago
Andrew Dolgov b6e1a5c91a fix several warnings reported by phpstan 3 years ago
Andrew Dolgov ce2335deaf pref-users: css fixes 3 years ago
Andrew Dolgov d8de10d78a error log: fix severity dropdown 3 years ago
Andrew Dolgov 73e697a0df fix some warnings in prefs (filters, users) 3 years ago
Andrew Dolgov 73070544ca error log: make it more readable 3 years ago
Andrew Dolgov 5cfc5914f2 log viewer: show total pages 3 years ago
Andrew Dolgov 5849a39820 af_redditimgur: don't try to load empty html; fix a warning in update debugger 3 years ago
Andrew Dolgov ce489a724b fix a few more warnings 3 years ago
Andrew Dolgov 10392ecc28 event log: add pagination 3 years ago
Andrew Dolgov 9fdeb58fd3 check a few more php8 warnings 3 years ago
Andrew Dolgov 8b39e6bca7 _color_pack: define variable before using 3 years ago
Andrew Dolgov a544123b59 fix clean() for arrays and user plugin list 3 years ago
Andrew Dolgov 6e774a58fe more php8 fixes mostly related to login 3 years ago
Andrew Dolgov 403dca154c initial WIP for php8; bump php version requirement to 7.0 3 years ago
Andrew Dolgov b4cbc792cc add workaround for gulp4 not updating file timestamps on build 3 years ago
Andrew Dolgov 6d8f2221b8 Merge branch 'weblate-integration' 3 years ago
Eike 505cbdd82e Translated using Weblate (German)
Currently translated at 100.0% (729 of 729 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/de/
3 years ago
Andrew Dolgov eb896f824d night theme: disable on-hover undimming 3 years ago
Andrew Dolgov 927df33d49 night theme: dim images unless hovered over 3 years ago
Andrew Dolgov 64f7ac0e74 dark theme: fix color of .dijitSplitterHover 3 years ago
fox 607ecab31e Update 'CONTRIBUTING.md' 3 years ago
fox 1507b051fd update contributing.md due to gitea changes 3 years ago
Andrew Dolgov 6c546f37ba af_redditimgur: handle youtube /embed/ URLs 3 years ago
Andrew Dolgov 43e9dd5ea9 feed debugger: wrap long lines 3 years ago
Andrew Dolgov b30b354b53 af_redditimgur: add some last minute handling for generic preview media URLs provided in JSON 3 years ago
Andrew Dolgov 0d1336bd29 af_redditimgur:
* draw a basic form for testurl() if no url is given
 * only process specific JSON media files/child elements until something is found
 * handle generic preview images for self posts (not link posts because
link is handled afterwards)
3 years ago
Andrew Dolgov 1ded706f8f af_redditimgur: cleanup, rework to embed stuff from reddit-provided JSON first 3 years ago
Andrew Dolgov 2933483393 add a hack (Headlines.unpackVisible) to workaround against unpack observer sometimes missing articles 3 years ago
Andrew Dolgov 41bde84a92 af_redditimgur: add basic support for reddit galleries 3 years ago
Andrew Dolgov 4e95591087 af_redditimgur: shorten href stuff 3 years ago
Andrew Dolgov da0ad82c24 Archive cleanup:
- remove code to manually archive/unarchive articles
- remove ttrss_archived_feeds/orig_feed_id handling - the whole thing was implemented for
this data to be kept indefinitely; it doesn't make a lot of sense to deal with this stuff
now that it is expired after one month anyway (same reasons as feed browser being removed - privacy)
- remove "originally from"-related stuff because of the above
- also remove unused remaining frontend/backend code related to feed browser (rip)
3 years ago
Andrew Dolgov 6c13449088 remove CommonDialogs.feedBrowser() 3 years ago
Andrew Dolgov 25520e9784 Select... dropdown: replace dijit Select with DropDownButton, simplify layout
PluginHost: add HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM
Headlines.onActionChanged: removed
3 years ago
Andrew Dolgov 7a2ad08a7d scored_oldest_first: update sort caption 3 years ago
Andrew Dolgov c82457e534 add plugins/scored_oldest_first 3 years ago
Andrew Dolgov bc0d50e892 remove show as feed from Select dropdown in main toolbar 3 years ago
Andrew Dolgov b2993bcd30 remove menu options to manually un/archive articles 3 years ago
Andrew Dolgov 78ed64932f Merge branch 'weblate-integration' 3 years ago
Andrew Dolgov ee4b7bebe8 pluginhost: load_data: check schema last 3 years ago
Andrew Dolgov 3d32a5f755 Merge branch 'master' of git.fakecake.org:tt-rss 3 years ago
Andrew Dolgov 40f38fc87f pluginhost: load plugin data automatically (also marks load_data method as private) 3 years ago
Andrew Dolgov 6311fb607d pre: set white-space: pre-wrap to remove horizontal scrolling 3 years ago
Andrew Dolgov f67f0f864b HOOK_ARTICLE_EXPORT_FEED: also pass owner_uid 3 years ago
Dario Di Ludovico ff194fadea Translated using Weblate (Italian)
Currently translated at 100.0% (729 of 729 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/it/
3 years ago
Andrew Dolgov d1e8042cf3 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 6d4005f984 af_psql_trgm:
1. better debugging output
2. fix incorrect default values being used sometimes
3. remove special workaround for equal titles because trgm extension
seems to be working properly for those now (tested on postgres 11)
4. code cleanup
3 years ago
fox 8cf8db8456 Merge branch 'inc-tags' of JustAMacUser/tt-rss into master 3 years ago
JustAMacUser fadf4dec96 Include tags for HOOK_ARTICLE_EXPORT_FEED. 3 years ago
Ptsa Daniel 17bc1de49e Translated using Weblate (Chinese (Simplified))
Currently translated at 99.8% (728 of 729 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
3 years ago
Weblate 219b493550 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 8e1e9ec2e3 rebase translations 3 years ago
Andrew Dolgov 6ed6b9e120 Merge branch 'weblate-integration' 3 years ago
Andrew Dolgov dcad60284c translations: rebase, add T_nsprintf 3 years ago
Andrew Dolgov 33a5ecd2ce feed editor: show purge interval correctly if FORCE_ARTICLE_PURGE is set 3 years ago
Andrew Dolgov 0868ff9d64 auth_remote: use empty() instead of isset() while checking headers 3 years ago
Andrew Dolgov dc40f69511 fix auth_remote broken by previous commit 3 years ago
Andrew Dolgov 8a34084df1 auth_remote: rewrite header checking to be more readable 3 years ago
Andrew Dolgov 4e3ef7a4dd get_user_ip: remove REMOTEADDR for the time being 3 years ago
Andrew Dolgov a8302fb253 use X-Real-IP headers if possible while authenticating 3 years ago
Andrew Dolgov 8764662138 af_redditimgur: also blacklist in-content links 3 years ago
Andrew Dolgov 2abc434e26 daemon: clarify some task-related messages 3 years ago
Andrew Dolgov 8cc07bc8bd event log: add severity filtering 3 years ago
Andrew Dolgov e86b2e60d3 edit tags dialog: initialize autocomplete in onShow (instead of onLoad) because of xhr 3 years ago
Andrew Dolgov 8de2100cf7 Merge branch 'master' of git.fakecake.org:tt-rss 3 years ago
Andrew Dolgov 57f36f3f97 search dialog: populate current search values onShow instead of onLoad because the dialog is preloaded via xhr 3 years ago
Ptsa Daniel 7282f0bf38 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.8% (716 of 717 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
3 years ago
fox d4666d30d2 Merge branch 'master' of Tony/tt-rss into master 3 years ago
Tony 564a24fd78 Add support for HTTP_REMOTE_USER variable for user authentication 3 years ago
Andrew Dolgov 6da576dbe4 BLACKLISTED_TAGS: use textarea for editing; normalize value when saving 3 years ago
Andrew Dolgov f59c567831 update_rss_feed: fix BLACKLISTED_TAGS not working properly, simplify tag-related code 3 years ago
Andrew Dolgov 5f733604f0 purge_feed: limit debugging to LOG_VERBOSE 3 years ago
Andrew Dolgov 9e62513095 af_redditimgur: also rewrite in the API handler 3 years ago
Andrew Dolgov b65e07a12b Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov f25ea5355c af_redditimgur: add option to rewrite reddit URLs to teddit.net 3 years ago
fox 82d3c653a7 Merge branch 'bugfix/return-errors' of wn/tt-rss into master 3 years ago
wn_ 12435b223e Actually return the array of errors... 3 years ago
Andrew Dolgov 50d089ae59 redditimgur: blacklist github because it usually resolves to a huge profile photo of someone 3 years ago
fox e48beee7fc Merge branch 'bugfix/php8-vsprintf' of wn/tt-rss into master 3 years ago
wn_ d2db58de4f Switch from 'vsprintf' to 'sprintf' in another place. 3 years ago
fox ef7e679363 Merge branch 'feature/php8' of wn/tt-rss into master 3 years ago
Andrew Dolgov b4b2ba99ef purge_feed: shorten one log message 3 years ago
Andrew Dolgov f05f9b4252 purge_feed: add more debugging output 3 years ago
Andrew Dolgov 9b7338e807 feed editor: properly show global purging interval as disabled 3 years ago
Andrew Dolgov 8aa1b0fed6 purge_intervals global: set '1 week old' to mean 7 days instead of 5 (???) 3 years ago
wn 62da307ef1 Use correct 'sprintf' function and other minor fixes in Pref_Feeds. 3 years ago
wn a1f8d6941b Remove duplicate block in 'classes/pref/filters.php'.
Also a minor tweak to getting the search filter.
3 years ago
wn 8c4ca7c8ef Fix some 'isset' checks in 'classes/pref/prefs.php'. 3 years ago
wn 95d0cb4953 Handle potential absence of a URL path in UrlHelper. 3 years ago
wn c68f2aabc9 Make 'ttrss_error_handler' compatible w/ 8.
2d467abc46/UPGRADING (L43)
2d467abc46/UPGRADING (L63)
3 years ago
wn 936b91a7e6 Don't do deprecated 'libxml_disable_entity_loader(true)' under PHP 8.
2d467abc46/UPGRADING (L886)
3 years ago
wn 6bdf4a1a25 Switch to 'get_error_types()' to ensure availability in 'include/functions.php'.
The global in 'sanity_check()' was null... possibly due to circular requires?
3 years ago
wn 08a6f6bde2 Only do sanity checks for self URL if we can create a valid URL.
'sanity_check.php' gets included in 'update.php' and 'update_daemon2.php', where a Host request header is likely not provided.
3 years ago
wn 75536b4790 Switch to recommended 'default_charset' to fix 'gettext' error.
'mbstring.internal-encoding' was deprecated in 5.6.

https://www.php.net/manual/en/mbstring.configuration.php#ini.mbstring.internal-encoding
https://www.php.net/manual/en/ini.core.php#ini.default-charset
3 years ago
wn 6f31372b37 Address param order deprecation warning for 'af_redditimgur'. 3 years ago
wn 358bcdd881 Fix passing options to plugins in 'update.php'. 3 years ago

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

@ -2,12 +2,12 @@ module.exports = {
"env": {
"browser": true,
"es6": true,
"jquery": true,
"webextensions": true
"jquery": false,
"webextensions": false
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2017
"ecmaVersion": 2018
},
"rules": {
"accessor-pairs": "error",
@ -106,7 +106,7 @@ module.exports = {
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-continue": "off",
"no-console": "off",
"no-console": "off",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "off",
@ -187,7 +187,7 @@ module.exports = {
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-undefined": "off",
"no-undef": "warn",
"no-undef": "warn",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": [
@ -197,7 +197,7 @@ module.exports = {
}
],
"no-unused-expressions": "off",
"no-unused-vars": "warn",
"no-unused-vars": "warn",
"no-use-before-define": "off",
"no-useless-call": "error",
"no-useless-computed-key": "error",

28
.gitignore vendored

@ -1,24 +1,14 @@
Thumbs.db
/deploy.exclude
/deploy.sh
/.app_is_ready
/messages.mo
/node_modules
/locale/**/*.po~
/package-lock.json
*~
*.DS_Store
#*
.idea/*
plugins.local/*
themes.local/*
config.php
feed-icons/*
cache/*/*
lock/*
tags
cache/htmlpurifier/*/*ser
lib/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer/*/*ser
web.config
/.save.cson
/.tags*
/.gutentags
/plugins.local/*
/themes.local/*
/config.php
/feed-icons/*
/cache/*/*
/lock/*
/.vscode/settings.json
/vendor/**/.git

@ -0,0 +1,24 @@
{
"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",
"type": "chrome",
"pathMapping": {
"/tt-rss/": "${workspaceFolder}"
},
"urlFilter": "*/tt-rss/*",
"runtimeExecutable": "chrome.exe",
}
]
}

@ -1,33 +1,20 @@
## Contributing code the right way
*(or: how I learned to post merge requests without crying myself to sleep)*
TLDR: it works like Github.
New user accounts on Gogs are not allowed to fork repositories because people
use development accounts to spam. To get initial fork access, do the following:
1. Register on the [development website](https://git.tt-rss.org);
2. Fork the repository you're interested in;
3. Do the needful;
4. File a PR against master branch;
1. Register on the forums and on [Gogs](https://git.tt-rss.org);
2. Create a thread describing your proposed changes in [Development subforum](https://community.tt-rss.org/c/tiny-tiny-rss/development)
while including your Gogs username;
3. You'll be given proper access and will be able to fork repositories and file PRs, etc;
If you already have a fully functional Gogs account it works pretty much like Github:
1. Fork the repository you're interested in;
2. Do the needful;
3. File a pull request with your changes against master branch;
That's it. If you have any other questions, see this [forum thread](https://discourse.tt-rss.org/t/how-to-contribute-code-via-pull-requests-on-git-tt-rss-org/1850).
If you don't want to deal with the above, you can also clone one of the repositories
locally, do the needful, and post resulting patches on the [forums](https://community.tt-rss.org/c/tiny-tiny-rss/development).
If you have any other questions, see this [forum thread](https://discourse.tt-rss.org/t/how-to-contribute-code-via-pull-requests-on-git-tt-rss-org/1850).
Please don't inline patches in forum posts, attach files instead (``.patch`` or ``.diff`` file
extensions should work).
## Contributing translations
Believe it or not, people also spam using Weblate. Therefore, there's some minor
jumping through hoops involved here:
Believe it or not, people also spam using Weblate. Therefore, some minor jumping through hoops is involved here:
1. Register on [Weblate](https://weblate.tt-rss.org/) / forums;
2. Post in the [Weblate discussion thread](https://community.tt-rss.org/t/easier-translations-with-weblate/1680) on the forum, ask to be added to a project

@ -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/

@ -1,83 +1,59 @@
<?php
error_reporting(E_ERROR | E_PARSE);
require_once "../config.php";
set_include_path(dirname(__FILE__) . PATH_SEPARATOR .
dirname(dirname(__FILE__)) . PATH_SEPARATOR .
dirname(dirname(__FILE__)) . "/include" . PATH_SEPARATOR .
set_include_path(__DIR__ . PATH_SEPARATOR .
dirname(__DIR__) . PATH_SEPARATOR .
dirname(__DIR__) . "/include" . PATH_SEPARATOR .
get_include_path());
chdir("..");
define('TTRSS_SESSION_NAME', 'ttrss_api_sid');
define('NO_SESSION_AUTOSTART', true);
require_once "autoload.php";
require_once "db.php";
require_once "db-prefs.php";
require_once "functions.php";
require_once "sessions.php";
ini_set('session.use_cookies', 0);
ini_set("session.gc_maxlifetime", 86400);
if (defined('ENABLE_GZIP_OUTPUT') && ENABLE_GZIP_OUTPUT &&
function_exists("ob_gzhandler")) {
ob_start("ob_gzhandler");
} else {
ob_start();
}
ini_set('session.use_cookies', "0");
ini_set("session.gc_maxlifetime", "86400");
$input = file_get_contents("php://input");
ob_start();
if (defined('_API_DEBUG_HTTP_ENABLED') && _API_DEBUG_HTTP_ENABLED) {
// Override $_REQUEST with JSON-encoded data if available
// fallback on HTTP parameters
if ($input) {
$input = json_decode($input, true);
if ($input) $_REQUEST = $input;
}
} else {
// Accept JSON only
$input = json_decode($input, true);
$_REQUEST = $input;
}
$_REQUEST = json_decode((string)file_get_contents("php://input"), true);
if ($_REQUEST["sid"]) {
if (!empty($_REQUEST["sid"])) {
session_id($_REQUEST["sid"]);
@session_start();
} else if (defined('_API_DEBUG_HTTP_ENABLED')) {
@session_start();
session_start();
}
startup_gettext();
if (!init_plugins()) return;
if ($_SESSION["uid"]) {
if (!validate_session()) {
if (!empty($_SESSION["uid"])) {
if (!\Sessions\validate_session()) {
header("Content-Type: text/json");
print json_encode(array("seq" => -1,
"status" => 1,
"content" => array("error" => "NOT_LOGGED_IN")));
print json_encode([
"seq" => -1,
"status" => API::STATUS_ERR,
"content" => [ "error" => API::E_NOT_LOGGED_IN ]
]);
return;
}
UserHelper::load_user_plugins( $_SESSION["uid"]);
UserHelper::load_user_plugins($_SESSION["uid"]);
}
$method = strtolower($_REQUEST["op"]);
$method = strtolower($_REQUEST["op"] ?? "");
$handler = new API($_REQUEST);
if ($handler->before($method)) {
if ($method && method_exists($handler, $method)) {
$handler->$method();
} else if (method_exists($handler, 'index')) {
} else /* if (method_exists($handler, 'index')) */ {
$handler->index($method);
}
$handler->after();

@ -1,9 +1,11 @@
<?php
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
get_include_path());
$op = $_REQUEST["op"];
@$method = $_REQUEST['subop'] ? $_REQUEST['subop'] : $_REQUEST["method"];
$op = $_REQUEST['op'] ?? '';
$method = !empty($_REQUEST['subop']) ?
$_REQUEST['subop'] :
$_REQUEST["method"] ?? false;
if (!$method)
$method = 'index';
@ -19,14 +21,14 @@
return;
}
@$csrf_token = $_POST['csrf_token'];
$csrf_token = $_POST['csrf_token'] ?? "";
require_once "autoload.php";
require_once "sessions.php";
require_once "functions.php";
require_once "config.php";
require_once "db.php";
require_once "db-prefs.php";
$op = (string)clean($op);
$method = (string)clean($method);
startup_gettext();
@ -36,27 +38,28 @@
header("Content-Type: text/json; charset=utf-8");
if (ENABLE_GZIP_OUTPUT && function_exists("ob_gzhandler")) {
ob_start("ob_gzhandler");
}
if (SINGLE_USER_MODE) {
if (Config::get(Config::SINGLE_USER_MODE)) {
UserHelper::authenticate( "admin", null);
}
if ($_SESSION["uid"]) {
if (!validate_session()) {
if (!empty($_SESSION["uid"])) {
if (!\Sessions\validate_session()) {
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
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"),
5 => __("1 week old"),
7 => __("1 week old"),
14 => __("2 weeks old"),
31 => __("1 month old"),
60 => __("2 months old"),
@ -88,12 +91,29 @@
5 => __("Power User"),
10 => __("Administrator"));
// shortcut syntax for plugin methods (?op=plugin--pmethod&...params)
/* if (strpos($op, PluginHost::PUBLIC_METHOD_DELIMITER) !== false) {
list ($plugin, $pmethod) = explode(PluginHost::PUBLIC_METHOD_DELIMITER, $op, 2);
// TODO: better implementation that won't modify $_REQUEST
$_REQUEST["plugin"] = $plugin;
$method = $pmethod;
$op = "pluginhandler";
} */
$op = str_replace("-", "_", $op);
$override = PluginHost::getInstance()->lookup_handler($op, $method);
if (class_exists($op) || $override) {
if (strpos($method, "_") === 0) {
user_error("Refusing to invoke method $method of handler $op which starts with underscore.", E_USER_WARNING);
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
if ($override) {
$handler = $override;
} else {
@ -112,30 +132,35 @@
if ($reflection->getNumberOfRequiredParameters() == 0) {
$handler->$method();
} else {
user_error("Refusing to invoke method $method of handler $op which has required parameters.", E_USER_WARNING);
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} 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();
return;
} else {
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
} else {
user_error("Refusing to invoke method $method of handler $op with invalid CSRF token.", E_USER_WARNING);
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
}
}
header("Content-Type: text/json");
print error_json(13);
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
?>

File diff suppressed because it is too large Load Diff

@ -1,27 +1,30 @@
<?php
class Article extends Handler_Protected {
const ARTICLE_KIND_ALBUM = 1;
const ARTICLE_KIND_VIDEO = 2;
const ARTICLE_KIND_YOUTUBE = 3;
function redirect() {
$id = clean($_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 = $row['link'];
$article_url = str_replace("\n", "", $article_url);
header("Location: $article_url");
return;
} else {
print_error(__("Article not found."));
$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']);
if ($article) {
$article_url = UrlHelper::validate($article->link);
if ($article_url) {
header("Location: $article_url");
return;
}
}
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,
static function _create_published_article($title, $url, $content, $labels_str,
$owner_uid) {
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
@ -29,16 +32,16 @@ class Article extends Handler_Protected {
if (!$content) {
$pluginhost = new PluginHost();
$pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid);
$pluginhost->load_data();
//$pluginhost->load_data();
foreach ($pluginhost->get_hooks(PluginHost::HOOK_GET_FULL_TEXT) as $p) {
$extracted_content = $p->hook_get_full_text($url);
if ($extracted_content) {
$content = $extracted_content;
break;
}
}
$pluginhost->run_hooks_callback(PluginHost::HOOK_GET_FULL_TEXT,
function ($result) use (&$content) {
if ($result) {
$content = $result;
return true;
}
},
$url);
}
$content_hash = sha1($content);
@ -79,7 +82,7 @@ class Article extends Handler_Protected {
content = ?, content_hash = ? WHERE id = ?");
$sth->execute([$content, $content_hash, $ref_id]);
if (DB_TYPE == "pgsql"){
if (Config::get(Config::DB_TYPE) == "pgsql") {
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
@ -124,7 +127,7 @@ class Article extends Handler_Protected {
if ($row = $sth->fetch()) {
$ref_id = $row["id"];
if (DB_TYPE == "pgsql"){
if (Config::get(Config::DB_TYPE) == "pgsql"){
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
@ -155,39 +158,15 @@ class Article extends Handler_Protected {
return $rc;
}
function editArticleTags() {
$param = clean($_REQUEST['param']);
$tags = self::get_article_tags($param);
$tags_str = join(", ", $tags);
print_hidden("id", "$param");
print_hidden("op", "article");
print_hidden("method", "setArticleTags");
print "<header class='horizontal'>" . __("Tags for this article (separated by commas):")."</header>";
print "<section>";
print "<textarea dojoType='dijit.form.SimpleTextarea' rows='4'
style='height : 100px; font-size : 12px; width : 98%' id='tags_str'
name='tags_str'>$tags_str</textarea>
<div class='autocomplete' id='tags_choices'
style='display:none'></div>";
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button'
type='submit' class='alt-primary'>".__('Save')."</button> ";
print "<button dojoType='dijit.form.Button'
onclick=\"dijit.byId('editTagsDlg').hide()\">".__('Cancel')."</button>";
print "</footer>";
function printArticleTags() {
$id = (int) clean($_REQUEST['id'] ?? 0);
print json_encode(["id" => $id,
"tags" => self::_get_tags($id)]);
}
function setScore() {
$ids = explode(",", clean($_REQUEST['id']));
$ids = array_map("intval", clean($_REQUEST['ids'] ?? []));
$score = (int)clean($_REQUEST['score']);
$ids_qmarks = arr_qmarks($ids);
@ -197,28 +176,17 @@ class Article extends Handler_Protected {
$sth->execute(array_merge([$score], $ids, [$_SESSION['uid']]));
print json_encode(["id" => $ids, "score" => (int)$score]);
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"]);
$tags_str = clean($_REQUEST["tags_str"]);
$tags = array_unique(trim_array(explode(",", $tags_str)));
//$tags_str = clean($_REQUEST["tags_str"]);
//$tags = array_unique(array_map('trim', explode(",", $tags_str)));
$tags = FeedItem_Common::normalize_categories(explode(",", clean($_REQUEST["tags_str"])));
$this->pdo->beginTransaction();
@ -243,8 +211,6 @@ class Article extends Handler_Protected {
(post_int_id, owner_uid, tag_name)
VALUES (?, ?, ?)");
$tags = FeedItem_Common::normalize_categories($tags);
foreach ($tags as $tag) {
$csth->execute([$int_id, $_SESSION['uid'], $tag]);
@ -266,18 +232,12 @@ class Article extends Handler_Protected {
$this->pdo->commit();
$tags = self::get_article_tags($id);
$tags_str = $this->format_tags_string($tags, $id);
$tags_str_full = join(", ", $tags);
if (!$tags_str_full) $tags_str_full = __("no tags");
print json_encode(array("id" => (int)$id,
"content" => $tags_str, "content_full" => $tags_str_full));
// get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ???
print json_encode(["id" => (int)$id, "tags" => $this->_get_tags($id)]);
}
function completeTags() {
/*function completeTags() {
$search = clean($_REQUEST["search"]);
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
@ -292,17 +252,17 @@ class Article extends Handler_Protected {
print "<li>" . $line["tag_name"] . "</li>";
}
print "</ul>";
}
}*/
function assigntolabel() {
return $this->labelops(true);
return $this->_label_ops(true);
}
function removefromlabel() {
return $this->labelops(false);
return $this->_label_ops(false);
}
private function labelops($assign) {
private function _label_ops($assign) {
$reply = array();
$ids = explode(",", clean($_REQUEST["ids"]));
@ -310,22 +270,17 @@ class Article extends Handler_Protected {
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
$reply["info-for-headlines"] = array();
$reply["labels-for"] = [];
if ($label) {
foreach ($ids as $id) {
if ($assign)
Labels::add_article($id, $label, $_SESSION["uid"]);
else
Labels::remove_article($id, $label, $_SESSION["uid"]);
$labels = $this->get_article_labels($id, $_SESSION["uid"]);
array_push($reply["info-for-headlines"],
array("id" => $id, "labels" => $this->format_article_labels($labels)));
array_push($reply["labels-for"],
["id" => (int)$id, "labels" => $this->_get_labels($id)]);
}
}
@ -334,157 +289,84 @@ class Article extends Handler_Protected {
print json_encode($reply);
}
function getArticleFeed($id) {
$sth = $this->pdo->prepare("SELECT feed_id FROM ttrss_user_entries
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
return $row["feed_id"];
} else {
return 0;
}
}
static function format_article_enclosures($id, $always_display_enclosures,
$article_content, $hide_images = false) {
$result = self::get_article_enclosures($id);
$rv = '';
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FORMAT_ENCLOSURES) as $plugin) {
$retval = $plugin->hook_format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images);
if (is_array($retval)) {
$rv = $retval[0];
$result = $retval[1];
} else {
$rv = $retval;
}
}
unset($retval); // Unset to prevent breaking render if there are no HOOK_RENDER_ENCLOSURE hooks below.
if ($rv === '' && !empty($result)) {
$entries_html = array();
$entries = array();
$entries_inline = array();
foreach ($result as $line) {
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_ENCLOSURE_ENTRY) as $plugin) {
$line = $plugin->hook_enclosure_entry($line, $id);
static function _format_enclosures($id,
$always_display_enclosures,
$article_content,
$hide_images = false) {
$enclosures = self::_get_enclosures($id);
$enclosures_formatted = "";
/*foreach ($enclosures as &$enc) {
array_push($enclosures, [
"type" => $enc["content_type"],
"filename" => basename($enc["content_url"]),
"url" => $enc["content_url"],
"title" => $enc["title"],
"width" => (int) $enc["width"],
"height" => (int) $enc["height"]
]);
}*/
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ENCLOSURES,
function ($result) use (&$enclosures_formatted, &$enclosures) {
if (is_array($result)) {
$enclosures_formatted = $result[0];
$enclosures = $result[1];
} else {
$enclosures_formatted = $result;
}
},
$enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images);
if (!empty($enclosures_formatted)) {
return [
'formatted' => $enclosures_formatted,
'entries' => []
];
}
$url = $line["content_url"];
$ctype = $line["content_type"];
$title = $line["title"];
$width = $line["width"];
$height = $line["height"];
if (!$ctype) $ctype = __("unknown type");
//$filename = substr($url, strrpos($url, "/")+1);
$filename = basename($url);
$player = format_inline_player($url, $ctype);
if ($player) array_push($entries_inline, $player);
# $entry .= " <a target=\"_blank\" href=\"" . htmlspecialchars($url) . "\" rel=\"noopener noreferrer\">" .
# $filename . " (" . $ctype . ")" . "</a>";
$rv = [
'formatted' => '',
'entries' => []
];
$entry = "<div onclick=\"Article.popupOpenUrl('".htmlspecialchars($url)."')\"
dojoType=\"dijit.MenuItem\">$filename ($ctype)</div>";
$rv['can_inline'] = isset($_SESSION["uid"]) &&
empty($_SESSION["bw_limit"]) &&
!get_pref(Prefs::STRIP_IMAGES) &&
($always_display_enclosures || !preg_match("/<img/i", $article_content));
array_push($entries_html, $entry);
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
$entry = array();
foreach ($enclosures as $enc) {
$entry["type"] = $ctype;
$entry["filename"] = $filename;
$entry["url"] = $url;
$entry["title"] = $title;
$entry["width"] = $width;
$entry["height"] = $height;
// this is highly approximate
$enc["filename"] = basename($enc["content_url"]);
array_push($entries, $entry);
}
$rendered_enc = "";
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE,
function ($result) use (&$rendered_enc) {
$rendered_enc = $result;
},
$enc, $id, $rv);
if ($_SESSION['uid'] && !get_pref("STRIP_IMAGES") && !$_SESSION["bw_limit"]) {
if ($always_display_enclosures ||
!preg_match("/<img/i", $article_content)) {
foreach ($entries as $entry) {
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ENCLOSURE) as $plugin)
$retval = $plugin->hook_render_enclosure($entry, $hide_images);
if ($retval) {
$rv .= $retval;
} else {
if (preg_match("/image/", $entry["type"])) {
if (!$hide_images) {
$encsize = '';
if ($entry['height'] > 0)
$encsize .= ' height="' . intval($entry['height']) . '"';
if ($entry['width'] > 0)
$encsize .= ' width="' . intval($entry['width']) . '"';
$rv .= "<p><img
alt=\"".htmlspecialchars($entry["filename"])."\"
src=\"" .htmlspecialchars($entry["url"]) . "\"
" . $encsize . " /></p>";
} else {
$rv .= "<p><a target=\"_blank\" rel=\"noopener noreferrer\"
href=\"".htmlspecialchars($entry["url"])."\"
>" .htmlspecialchars($entry["url"]) . "</a></p>";
}
if ($entry['title']) {
$rv.= "<div class=\"enclosure_title\">${entry['title']}</div>";
}
}
}
}
}
}
if ($rendered_enc) {
$rv['formatted'] .= $rendered_enc;
} else {
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY,
function ($result) use (&$enc) {
$enc = $result;
},
$enc, $id, $rv);
if (count($entries_inline) > 0) {
//$rv .= "<hr clear='both'/>";
foreach ($entries_inline as $entry) { $rv .= $entry; };
$rv .= "<br clear='both'/>";
array_push($rv['entries'], $enc);
}
$rv .= "<div class=\"attachments\" dojoType=\"fox.form.DropDownButton\">".
"<span>" . __('Attachments')."</span>";
$rv .= "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
foreach ($entries as $entry) {
if ($entry["title"])
$title = " &mdash; " . truncate_string($entry["title"], 30);
else
$title = "";
if ($entry["filename"])
$filename = truncate_middle(htmlspecialchars($entry["filename"]), 60);
else
$filename = "";
$rv .= "<div onclick='Article.popupOpenUrl(\"".htmlspecialchars($entry["url"])."\")'
dojoType=\"dijit.MenuItem\">".$filename . $title."</div>";
};
$rv .= "</div>";
$rv .= "</div>";
}
return $rv;
}
static function get_article_tags($id, $owner_uid = 0, $tag_cache = false) {
static function _get_tags($id, $owner_uid = 0, $tag_cache = false) {
$a_id = $id;
@ -534,89 +416,47 @@ class Article extends Handler_Protected {
return $tags;
}
static function format_tags_string($tags) {
if (!is_array($tags) || count($tags) == 0) {
return __("no tags");
} else {
$maxtags = min(5, count($tags));
$tags_str = "";
for ($i = 0; $i < $maxtags; $i++) {
$tags_str .= "<a class=\"tag\" href=\"#\" onclick=\"Feeds.open({feed:'".$tags[$i]."'})\">" . $tags[$i] . "</a>, ";
}
$tags_str = mb_substr($tags_str, 0, mb_strlen($tags_str)-2);
function getmetadatabyid() {
$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']);
if (count($tags) > $maxtags)
$tags_str .= ", &hellip;";
return $tags_str;
}
}
static function format_article_labels($labels) {
if (!is_array($labels)) return '';
$labels_str = "";
foreach ($labels as $l) {
$labels_str .= sprintf("<div class='label'
style='color : %s; background-color : %s'>%s</div>",
$l[2], $l[3], $l[1]);
}
return $labels_str;
}
static function format_article_note($id, $note, $allow_edit = true) {
if ($allow_edit) {
$onclick = "onclick='Plugins.Note.edit($id)'";
$note_class = 'editable';
if ($article) {
echo json_encode(["link" => $article->link, "title" => $article->title]);
} else {
$onclick = '';
$note_class = '';
echo json_encode([]);
}
return "<div class='article-note $note_class'>
<i class='material-icons'>note</i>
<div $onclick class='body'>$note</div>
</div>";
return $str;
}
static function get_article_enclosures($id) {
$pdo = Db::pdo();
static function _get_enclosures($id) {
$encs = ORM::for_table('ttrss_enclosures')
->where('post_id', $id)
->find_many();
$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()) {
foreach ($encs as $enc) {
$cache_key = sha1($enc->content_url);
if ($cache->exists(sha1($line["content_url"]))) {
$line["content_url"] = $cache->getUrl(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() {
static function _purge_orphans() {
// purge orphaned posts in main content table
if (DB_TYPE == "mysql")
if (Config::get(Config::DB_TYPE) == "mysql")
$limit_qpart = "LIMIT 5000";
else
$limit_qpart = "";
@ -631,7 +471,7 @@ class Article extends Handler_Protected {
}
}
static function catchupArticlesById($ids, $cmode, $owner_uid = false) {
static function _catchup_by_id($ids, $cmode, $owner_uid = false) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -656,21 +496,7 @@ class Article extends Handler_Protected {
$sth->execute(array_merge($ids, [$owner_uid]));
}
static function getLastArticleId() {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT ref_id AS id FROM ttrss_user_entries
WHERE owner_uid = ? ORDER BY ref_id DESC LIMIT 1");
$sth->execute([$_SESSION['uid']]);
if ($row = $sth->fetch()) {
return $row['id'];
} else {
return -1;
}
}
static function get_article_labels($id, $owner_uid = false) {
static function _get_labels($id, $owner_uid = false) {
$rv = array();
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -687,7 +513,7 @@ class Article extends Handler_Protected {
if ($label_cache) {
$tmp = json_decode($label_cache, true);
if (!$tmp || $tmp["no-labels"] == 1)
if (empty($tmp) || ($tmp["no-labels"] ?? 0) == 1)
return $rv;
else
return $tmp;
@ -717,19 +543,20 @@ class Article extends Handler_Protected {
return $rv;
}
static function get_article_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;
define('ARTICLE_KIND_ALBUM', 1); /* TODO */
define('ARTICLE_KIND_VIDEO', 2);
define('ARTICLE_KIND_YOUTUBE', 3);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE,
function ($result, $plugin) use (&$article_image, &$article_stream, &$content) {
list ($article_image, $article_stream, $content) = $result;
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_ARTICLE_IMAGE) as $p) {
list ($article_image, $article_stream, $content) = $p->hook_article_image($enclosures, $content, $site_url);
}
// run until first hard match
return !empty($article_image);
},
$enclosures, $content, $site_url, $headline);
if (!$article_image && !$article_stream) {
$tmpdoc = new DOMDocument();
@ -744,7 +571,7 @@ class Article extends Handler_Protected {
if ($rrr = preg_match("/\/embed\/([\w-]+)/", $e->getAttribute("src"), $matches)) {
$article_image = "https://img.youtube.com/vi/" . $matches[1] . "/hqdefault.jpg";
$article_stream = "https://youtu.be/" . $matches[1];
$article_kind = ARTICLE_KIND_YOUTUBE;
$article_kind = Article::ARTICLE_KIND_YOUTUBE;
break;
}
} else if ($e->nodeName == "video") {
@ -754,7 +581,7 @@ class Article extends Handler_Protected {
if ($src) {
$article_stream = $src->getAttribute("src");
$article_kind = ARTICLE_KIND_VIDEO;
$article_kind = Article::ARTICLE_KIND_VIDEO;
}
break;
@ -778,8 +605,8 @@ class Article extends Handler_Protected {
if ($article_image) {
$article_image = rewrite_relative_url($site_url, $article_image);
if (!$article_kind && (count($enclosures) > 1 || $elems->length > 1))
$article_kind = ARTICLE_KIND_ALBUM;
if (!$article_kind && (count($enclosures) > 1 || (isset($elems) && $elems->length > 1)))
$article_kind = Article::ARTICLE_KIND_ALBUM;
}
if ($article_stream)
@ -789,12 +616,57 @@ class Article extends Handler_Protected {
$cache = new DiskCache("images");
if ($article_image && $cache->exists(sha1($article_image)))
$article_image = $cache->getUrl(sha1($article_image));
$article_image = $cache->get_url(sha1($article_image));
if ($article_stream && $cache->exists(sha1($article_stream)))
$article_stream = $cache->getUrl(sha1($article_stream));
$article_stream = $cache->get_url(sha1($article_stream));
return [$article_image, $article_stream, $article_kind];
}
// only cached, returns label ids (not label feed ids)
static function _labels_of(array $article_ids) {
if (count($article_ids) == 0)
return [];
$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 = [];
foreach ($entries as $entry) {
$labels = json_decode($entry->label_cache);
if (isset($labels) && is_array($labels)) {
foreach ($labels as $label) {
if (empty($label["no-labels"]))
array_push($rv, Labels::feed_to_label_id($label[0]));
}
}
}
return array_unique($rv);
}
static function _feeds_of(array $article_ids) {
if (count($article_ids) == 0)
return [];
$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 = [];
foreach ($entries as $entry) {
array_push($rv, $entry->feed_id);
}
return array_unique($rv);
}
}

@ -1,6 +1,6 @@
<?php
class Auth_Base {
private $pdo;
abstract class Auth_Base extends Plugin implements IAuthModule {
protected $pdo;
const AUTH_SERVICE_API = '_api';
@ -8,57 +8,42 @@ class Auth_Base {
$this->pdo = Db::pdo();
}
/**
* @SuppressWarnings(unused)
*/
function check_password($owner_uid, $password, $service = '') {
return false;
}
/**
* @SuppressWarnings(unused)
*/
function authenticate($login, $password, $service = '') {
return false;
// compatibility wrapper, because of how pluginhost works (hook name == method name)
function hook_auth_user(...$args) {
return $this->authenticate(...$args);
}
// Auto-creates specified user if allowed by system configuration
// Can be used instead of find_user_by_login() by external auth modules
function auto_create_user($login, $password = false) {
if ($login && defined('AUTH_AUTO_CREATE') && AUTH_AUTO_CREATE) {
$user_id = $this->find_user_by_login($login);
if (!$password) $password = make_password();
function auto_create_user(string $login, $password = false) {
if ($login && Config::get(Config::AUTH_AUTO_CREATE)) {
$user_id = UserHelper::find_user_by_login($login);
if (!$user_id) {
$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 (?, 0, null, NOW(), ?,?)");
$sth->execute([$login, $pwd_hash, $salt]);
if (!$password) $password = make_password();
$user = ORM::for_table('ttrss_users')->create();
return $this->find_user_by_login($login);
$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);
} else {
return $user_id;
}
}
return $this->find_user_by_login($login);
return UserHelper::find_user_by_login($login);
}
function find_user_by_login($login) {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE
login = ?");
$sth->execute([$login]);
if ($row = $sth->fetch()) {
return $row["id"];
} else {
return false;
}
// @deprecated
function find_user_by_login(string $login) {
return UserHelper::find_user_by_login($login);
}
}

@ -1,91 +0,0 @@
<?php
class Backend extends Handler_Protected {
/* function digestTest() {
if (isset($_SESSION['uid'])) {
header("Content-type: text/html");
$rv = Digest::prepare_headlines_digest($_SESSION['uid'], 1, 1000);
print "<h1>HTML</h1>";
print $rv[0];
print "<h1>Plain text</h1>";
print "<pre>".$rv[3]."</pre>";
} else {
print error_json(6);
}
} */
function help() {
$topic = basename(clean($_REQUEST["topic"])); // only one for now
if ($topic == "main") {
$info = RPC::get_hotkeys_info();
$imap = RPC::get_hotkeys_map();
$omap = array();
foreach ($imap[1] as $sequence => $action) {
if (!isset($omap[$action])) $omap[$action] = array();
array_push($omap[$action], $sequence);
}
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
$cur_section = "";
foreach ($info as $section => $hotkeys) {
if ($cur_section) print "<li>&nbsp;</li>";
print "<li><h3>" . $section . "</h3></li>";
$cur_section = $section;
foreach ($hotkeys as $action => $description) {
if (is_array($omap[$action])) {
foreach ($omap[$action] as $sequence) {
if (strpos($sequence, "|") !== false) {
$sequence = substr($sequence,
strpos($sequence, "|")+1,
strlen($sequence));
} else {
$keys = explode(" ", $sequence);
for ($i = 0; $i < count($keys); $i++) {
if (strlen($keys[$i]) > 1) {
$tmp = '';
foreach (str_split($keys[$i]) as $c) {
switch ($c) {
case '*':
$tmp .= __('Shift') . '+';
break;
case '^':
$tmp .= __('Ctrl') . '+';
break;
default:
$tmp .= $c;
}
}
$keys[$i] = $tmp;
}
}
$sequence = join(" ", $keys);
}
print "<li>";
print "<div class='hk'><code>$sequence</code></div>";
print "<div class='desc'>$description</div>";
print "</li>";
}
}
}
}
print "</ul>";
}
print "<footer class='text-center'>";
print "<button dojoType='dijit.form.Button'
onclick=\"return dijit.byId('helpDlg').hide()\">".__('Close this window')."</button>";
print "</footer>";
}
}

@ -0,0 +1,644 @@
<?php
class Config {
private const _ENVVAR_PREFIX = "TTRSS_";
const T_BOOL = 1;
const T_STRING = 2;
const T_INT = 3;
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";
const DB_USER = "DB_USER";
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 ],
Config::DB_USER => [ "", Config::T_STRING ],
Config::DB_NAME => [ "", Config::T_STRING ],
Config::DB_PASS => [ "", Config::T_STRING ],
Config::DB_PORT => [ "5432", Config::T_STRING ],
Config::MYSQL_CHARSET => [ "UTF8", Config::T_STRING ],
Config::SELF_URL_PATH => [ "", Config::T_STRING ],
Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ],
Config::SIMPLE_UPDATE_MODE => [ "", Config::T_BOOL ],
Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ],
Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ],
Config::CACHE_DIR => [ "cache", Config::T_STRING ],
Config::ICONS_DIR => [ "feed-icons", Config::T_STRING ],
Config::ICONS_URL => [ "feed-icons", Config::T_STRING ],
Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ],
Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ],
Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ],
Config::SESSION_COOKIE_LIFETIME => [ 86400, Config::T_INT ],
Config::SMTP_FROM_NAME => [ "Tiny Tiny RSS", Config::T_STRING ],
Config::SMTP_FROM_ADDRESS => [ "noreply@localhost", Config::T_STRING ],
Config::DIGEST_SUBJECT => [ "[tt-rss] New headlines for last 24 hours",
Config::T_STRING ],
Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ],
Config::PLUGINS => [ "auth_internal", Config::T_STRING ],
Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ],
Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css",
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 ],
Config::FILE_FETCH_TIMEOUT => [ 45, Config::T_INT ],
Config::FILE_FETCH_CONNECT_TIMEOUT => [ 15, Config::T_INT ],
Config::DAEMON_UPDATE_LOGIN_LIMIT => [ 30, Config::T_INT ],
Config::DAEMON_FEED_LIMIT => [ 500, Config::T_INT ],
Config::DAEMON_SLEEP_INTERVAL => [ 120, Config::T_INT ],
Config::MAX_CACHE_FILE_SIZE => [ 64*1024*1024, Config::T_INT ],
Config::MAX_DOWNLOAD_FILE_SIZE => [ 16*1024*1024, Config::T_INT ],
Config::MAX_FAVICON_FILE_SIZE => [ 1*1024*1024, Config::T_INT ],
Config::CACHE_MAX_DAYS => [ 7, Config::T_INT ],
Config::MAX_CONDITIONAL_INTERVAL => [ 3600*12, Config::T_INT ],
Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT => [ 30, Config::T_INT ],
Config::LOG_SENT_MAIL => [ "", Config::T_BOOL ],
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 = [];
/** @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));
foreach ($ref->getConstants() as $const => $cvalue) {
if (isset($this::_DEFAULTS[$const])) {
$override = getenv($this::_ENVVAR_PREFIX . $const);
list ($defval, $deftype) = $this::_DEFAULTS[$const];
$this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ];
}
}
}
/* 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);
case self::T_INT:
return (int) $value;
default:
return $value;
}
}
private function _get(string $param) {
list ($value, $type_hint) = $this->params[$param];
return $this->cast_to($value, $type_hint);
}
private function _add(string $param, string $default, int $type_hint) {
$override = getenv($this::_ENVVAR_PREFIX . $param);
$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) {
$instance = self::get_instance();
return $instance->_add($param, $default, $type_hint);
}
static function get(string $param) {
$instance = self::get_instance();
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());
}
}

@ -1,74 +1,116 @@
<?php
class Counters {
static function getAllCounters() {
$data = self::getGlobalCounters();
$data = array_merge($data, self::getVirtCounters());
$data = array_merge($data, self::getLabelCounters());
$data = array_merge($data, self::getFeedCounters());
$data = array_merge($data, self::getCategoryCounters());
return $data;
static function get_all() {
return array_merge(
self::get_global(),
self::get_virt(),
self::get_labels(),
self::get_feeds(),
self::get_cats()
);
}
static private function getCategoryChildrenCounters($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 function get_conditional(array $feed_ids = null, array $label_ids = null) {
return array_merge(
self::get_global(),
self::get_virt(),
self::get_labels($label_ids),
self::get_feeds($feed_ids),
self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null)
);
}
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::getCategoryChildrenCounters($line["id"], $owner_uid);
$cats = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid)
->where('parent_cat', $cat_id)
->find_many();
$unread += $tmp_unread + Feeds::getCategoryUnread($line["id"], $owner_uid);
$marked += $tmp_marked + Feeds::getCategoryMarked($line["id"], $owner_uid);
foreach ($cats as $cat) {
list ($tmp_unread, $tmp_marked) = self::get_cat_children($cat->id, $owner_uid);
$unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid);
$marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid);
}
return [$unread, $marked];
}
static function getCategoryCounters() {
private static function get_cats(array $cat_ids = null) {
$ret = [];
/* Labels category */
$cv = array("id" => -2, "kind" => "cat",
"counter" => Feeds::getCategoryUnread(-2));
"counter" => Feeds::_get_cat_unread(-2));
array_push($ret, $cv);
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = :uid
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = :uid");
$sth->execute(["uid" => $_SESSION['uid']]);
if (is_array($cat_ids)) {
if (count($cat_ids) == 0)
return [];
$cat_ids_qmarks = arr_qmarks($cat_ids);
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = ? AND fc.id IN ($cat_ids_qmarks)
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = ?");
$sth->execute(array_merge(
[$_SESSION['uid']],
$cat_ids,
[$_SESSION['uid']]
));
} else {
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = :uid
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = :uid");
$sth->execute(["uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
if ($line["num_children"] > 0) {
list ($child_counter, $child_marked_counter) = self::getCategoryChildrenCounters($line["id"], $_SESSION["uid"]);
list ($child_counter, $child_marked_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]);
} else {
$child_counter = 0;
$child_marked_counter = 0;
@ -84,43 +126,59 @@ class Counters {
array_push($ret, $cv);
}
array_push($ret, $cv);
return $ret;
}
static function getFeedCounters($active_feed = false) {
private static function get_feeds(array $feed_ids = null) {
$ret = [];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = :uid
GROUP BY f.id");
$sth->execute(["uid" => $_SESSION['uid']]);
if (is_array($feed_ids)) {
if (count($feed_ids) == 0)
return [];
$feed_ids_qmarks = arr_qmarks($feed_ids);
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks)
GROUP BY f.id");
$sth->execute(array_merge([$_SESSION['uid']], $feed_ids));
} else {
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = :uid
GROUP BY f.id");
$sth->execute(["uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
$id = $line["id"];
$last_error = htmlspecialchars($line["last_error"]);
$last_updated = TimeHelper::make_local_datetime($line['last_updated'], false);
if (Feeds::feedHasIcon($id)) {
$has_img = filemtime(Feeds::getIconFile($id));
if (Feeds::_has_icon($id)) {
$has_img = filemtime(Feeds::_get_icon_file($id));
} else {
$has_img = false;
}
if (date('Y') - date('Y', strtotime($line['last_updated'])) > 2)
// hide default un-updated timestamp i.e. 1970-01-01 (?) -fox
if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'])) > 2)
$last_updated = '';
$cv = [
@ -131,11 +189,8 @@ class Counters {
"has_img" => (int) $has_img
];
if ($last_error)
$cv["error"] = $last_error;
if ($active_feed && $id == $active_feed)
$cv["title"] = truncate_string($line["title"], 30);
$cv["error"] = $line["last_error"];
$cv["title"] = truncate_string($line["title"], 30);
array_push($ret, $cv);
@ -144,40 +199,27 @@ class Counters {
return $ret;
}
static function getGlobalCounters($global_unread = -1) {
$ret = [];
if ($global_unread == -1) {
$global_unread = Feeds::getGlobalUnread();
}
$cv = [
"id" => "global-unread",
"counter" => (int) $global_unread
private static function get_global() {
$ret = [
[
"id" => "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();
$subcribed_feeds = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count();
$subscribed_feeds = $row["fn"];
$cv = [
array_push($ret, [
"id" => "subscribed-feeds",
"counter" => (int) $subscribed_feeds
];
array_push($ret, $cv);
"counter" => $subcribed_feeds
]);
return $ret;
}
static function getVirtCounters() {
private static function get_virt() {
$ret = [];
@ -186,7 +228,7 @@ class Counters {
$count = getFeedUnread($i);
if ($i == 0 || $i == -1 || $i == -2)
$auxctr = Feeds::getFeedArticles($i, false);
$auxctr = Feeds::_get_counters($i, false);
else
$auxctr = 0;
@ -221,23 +263,42 @@ class Counters {
return $ret;
}
static function getLabelCounters($descriptions = false) {
static function get_labels(array $label_ids = null) {
$ret = [];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid
WHERE ttrss_labels2.owner_uid = :uid
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute([":uid" => $_SESSION['uid']]);
if (is_array($label_ids)) {
if (count($label_ids) == 0)
return [];
$label_ids_qmarks = arr_qmarks($label_ids);
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ?
WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks)
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute(array_merge([$_SESSION["uid"], $_SESSION["uid"]], $label_ids));
} else {
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid
WHERE ttrss_labels2.owner_uid = :uid
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute([":uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
@ -247,12 +308,10 @@ class Counters {
"id" => $id,
"counter" => (int) $line["count_unread"],
"auxcounter" => (int) $line["total"],
"markedcounter" => (int) $line["count_marked"]
"markedcounter" => (int) $line["count_marked"],
"description" => $line["caption"]
];
if ($descriptions)
$cv["description"] = $line["caption"];
array_push($ret, $cv);
}

@ -1,65 +1,52 @@
<?php
class Db
{
/* @var Db $instance */
/** @var Db $instance */
private static $instance;
/* @var IDb $adapter */
private $adapter;
private $link;
/* @var PDO $pdo */
/** @var PDO $pdo */
private $pdo;
private function __clone() {
//
}
private function legacy_connect() {
user_error("Legacy connect requested to " . DB_TYPE, E_USER_NOTICE);
$er = error_reporting(E_ALL);
switch (DB_TYPE) {
case "mysql":
$this->adapter = new Db_Mysqli();
break;
case "pgsql":
$this->adapter = new Db_Pgsql();
break;
default:
die("Unknown DB_TYPE: " . DB_TYPE);
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)));
}
}
if (!$this->adapter) {
print("Error initializing database adapter for " . DB_TYPE);
exit(100);
}
static function NOW() {
return date("Y-m-d H:i:s", time());
}
$this->link = $this->adapter->connect(DB_HOST, DB_USER, DB_PASS, DB_NAME, defined('DB_PORT') ? DB_PORT : "");
private function __clone() {
//
}
if (!$this->link) {
print("Error connecting through adapter: " . $this->adapter->last_error());
exit(101);
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 = '';
}
error_reporting($er);
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() {
$db_port = defined('DB_PORT') && DB_PORT ? ';port=' . DB_PORT : '';
$db_host = defined('DB_HOST') && DB_HOST ? ';host=' . DB_HOST : '';
public function pdo_connect() : PDO {
try {
$pdo = new PDO(DB_TYPE . ':dbname=' . DB_NAME . $db_host . $db_port,
DB_USER,
DB_PASS);
$pdo = new PDO(self::get_dsn(),
Config::get(Config::DB_USER),
Config::get(Config::DB_PASS));
} catch (Exception $e) {
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
exit(101);
@ -67,47 +54,36 @@ class Db
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$pdo->query("set client_encoding = 'UTF-8'");
$pdo->query("set datestyle = 'ISO, european'");
$pdo->query("set TIME ZONE 0");
$pdo->query("set cpu_tuple_cost = 0.5");
} else if (DB_TYPE == "mysql") {
} else if (Config::get(Config::DB_TYPE) == "mysql") {
$pdo->query("SET time_zone = '+0:0'");
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
$pdo->query("SET NAMES " . MYSQL_CHARSET);
if (Config::get(Config::MYSQL_CHARSET)) {
$pdo->query("SET NAMES " . Config::get(Config::MYSQL_CHARSET));
}
}
return $pdo;
}
public static function instance() {
public static function instance() : Db {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
public static function get() {
if (self::$instance == null)
self::$instance = new self();
if (!self::$instance->adapter) {
self::$instance->legacy_connect();
}
return self::$instance->adapter;
}
public static function pdo() {
public static function pdo() : PDO {
if (self::$instance == null)
self::$instance = new self();
if (!self::$instance->pdo) {
if (empty(self::$instance->pdo)) {
self::$instance->pdo = self::$instance->pdo_connect();
}
@ -115,7 +91,7 @@ class Db
}
public static function sql_random_function() {
if (DB_TYPE == "mysql") {
if (Config::get(Config::DB_TYPE) == "mysql") {
return "RAND()";
} else {
return "RANDOM()";

@ -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,85 +0,0 @@
<?php
class Db_Mysqli implements IDb {
private $link;
private $last_error;
function connect($host, $user, $pass, $db, $port) {
if ($port)
$this->link = mysqli_connect($host, $user, $pass, $db, $port);
else
$this->link = mysqli_connect($host, $user, $pass, $db);
if ($this->link) {
$this->init();
return $this->link;
} else {
print("Unable to connect to database (as $user to $host, database $db): " . mysqli_connect_error());
exit(102);
}
}
function escape_string($s, $strip_tags = true) {
if ($strip_tags) $s = strip_tags($s);
return mysqli_real_escape_string($this->link, $s);
}
function query($query, $die_on_error = true) {
$result = @mysqli_query($this->link, $query);
if (!$result) {
$this->last_error = @mysqli_error($this->link);
@mysqli_query($this->link, "ROLLBACK");
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
}
return $result;
}
function fetch_assoc($result) {
return mysqli_fetch_assoc($result);
}
function num_rows($result) {
return mysqli_num_rows($result);
}
function fetch_result($result, $row, $param) {
if (mysqli_data_seek($result, $row)) {
$line = mysqli_fetch_assoc($result);
return $line[$param];
} else {
return false;
}
}
function close() {
return mysqli_close($this->link);
}
function affected_rows($result) {
return mysqli_affected_rows($this->link);
}
function last_error() {
return mysqli_error($this->link);
}
function last_query_error() {
return $this->last_error;
}
function init() {
$this->query("SET time_zone = '+0:0'");
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
mysqli_set_charset($this->link, MYSQL_CHARSET);
}
return true;
}
}

@ -1,91 +0,0 @@
<?php
class Db_Pgsql implements IDb {
private $link;
private $last_error;
function connect($host, $user, $pass, $db, $port) {
$string = "dbname=$db user=$user";
if ($pass) {
$string .= " password=$pass";
}
if ($host) {
$string .= " host=$host";
}
if (is_numeric($port) && $port > 0) {
$string = "$string port=" . $port;
}
$this->link = pg_connect($string);
if (!$this->link) {
print("Unable to connect to database (as $user to $host, database $db):" . pg_last_error());
exit(102);
}
$this->init();
return $this->link;
}
function escape_string($s, $strip_tags = true) {
if ($strip_tags) $s = strip_tags($s);
return pg_escape_string($s);
}
function query($query, $die_on_error = true) {
$result = @pg_query($this->link, $query);
if (!$result) {
$this->last_error = @pg_last_error($this->link);
@pg_query($this->link, "ROLLBACK");
$query = htmlspecialchars($query); // just in case
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
}
return $result;
}
function fetch_assoc($result) {
return pg_fetch_assoc($result);
}
function num_rows($result) {
return pg_num_rows($result);
}
function fetch_result($result, $row, $param) {
return pg_fetch_result($result, $row, $param);
}
function close() {
return pg_close($this->link);
}
function affected_rows($result) {
return pg_affected_rows($result);
}
function last_error() {
return pg_last_error($this->link);
}
function last_query_error() {
return $this->last_error;
}
function init() {
$this->query("set client_encoding = 'UTF-8'");
pg_set_client_encoding("UNICODE");
$this->query("set datestyle = 'ISO, european'");
$this->query("set TIME ZONE 0");
$this->query("set cpu_tuple_cost = 0.5");
return true;
}
}

@ -1,173 +1,12 @@
<?php
class Db_Prefs {
private $pdo;
private static $instance;
private $cache;
function __construct() {
$this->pdo = Db::pdo();
$this->cache = array();
if ($_SESSION["uid"]) $this->cache();
}
private function __clone() {
//
}
public static function get() {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
function cache() {
$user_id = $_SESSION["uid"];
@$profile = $_SESSION["profile"];
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
$sth = $this->pdo->prepare("SELECT
value,ttrss_prefs_types.type_name as type_name,ttrss_prefs.pref_name AS pref_name
FROM
ttrss_user_prefs,ttrss_prefs,ttrss_prefs_types
WHERE
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
ttrss_prefs.pref_name NOT LIKE '_MOBILE%' AND
ttrss_prefs_types.id = type_id AND
owner_uid = :uid AND
ttrss_user_prefs.pref_name = ttrss_prefs.pref_name");
$sth->execute([":profile" => $profile, ":uid" => $user_id]);
while ($line = $sth->fetch()) {
if ($user_id == $_SESSION["uid"]) {
$pref_name = $line["pref_name"];
$this->cache[$pref_name]["type"] = $line["type_name"];
$this->cache[$pref_name]["value"] = $line["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"];
} else {
$profile = false;
}
if ($user_id == $_SESSION['uid'] && 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
value,ttrss_prefs_types.type_name as type_name
FROM
ttrss_user_prefs,ttrss_prefs,ttrss_prefs_types
WHERE
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
ttrss_user_prefs.pref_name = :pref_name AND
ttrss_prefs_types.id = type_id AND
owner_uid = :uid AND
ttrss_user_prefs.pref_name = ttrss_prefs.pref_name");
$sth->execute([":uid" => $user_id, ":profile" => $profile, ":pref_name" => $pref_name]);
if ($row = $sth->fetch()) {
$value = $row["value"];
$type_name = $row["type_name"];
if ($user_id == $_SESSION["uid"]) {
$this->cache[$pref_name]["type"] = $type_name;
$this->cache[$pref_name]["value"] = $value;
}
return $this->convert($value, $type_name);
} else if ($die_on_error) {
user_error("Fatal error, unknown preferences key: $pref_name (owner: $user_id)", E_USER_ERROR);
return null;
} else {
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"];
} else {
$profile = null;
}
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;
}
}
return set_pref($pref_name, $value, $user_id, $strip_tags);
}
}

@ -1,83 +0,0 @@
<?php
class DbUpdater {
private $pdo;
private $db_type;
private $need_version;
function __construct($pdo, $db_type, $need_version) {
$this->pdo = Db::pdo(); //$pdo;
$this->db_type = $db_type;
$this->need_version = (int) $need_version;
}
function getSchemaVersion() {
$row = $this->pdo->query("SELECT schema_version FROM ttrss_version")->fetch();
return (int) $row['schema_version'];
}
function isUpdateRequired() {
return $this->getSchemaVersion() < $this->need_version;
}
function getSchemaLines($version) {
$filename = "schema/versions/".$this->db_type."/$version.sql";
if (file_exists($filename)) {
return explode(";", preg_replace("/[\r\n]/", "", file_get_contents($filename)));
} else {
user_error("DB Updater: schema file for version $version is not found.");
return false;
}
}
function performUpdateTo($version, $html_output = true) {
if ($this->getSchemaVersion() == $version - 1) {
$lines = $this->getSchemaLines($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->getSchemaVersion();
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;

@ -1,14 +1,6 @@
<?php
class Digest
{
/**
* Send by mail a digest of last articles.
*
* @param mixed $link The database connection.
* @param integer $limit The maximum number of articles by digest.
* @return boolean Return false if digests are not enabled.
*/
static function send_headlines_digests() {
$user_limit = 15; // amount of users to process (e.g. emails to send out)
@ -16,9 +8,9 @@ class Digest
Debug::log("Sending digests, batch of max $user_limit users, headline limit = $limit");
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "last_digest_sent < NOW() - INTERVAL '1 days'";
} else if (DB_TYPE == "mysql") {
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "last_digest_sent < DATE_SUB(NOW(), INTERVAL 1 DAY)";
}
@ -29,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 &&
@ -39,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;
@ -56,11 +48,11 @@ class Digest
$mailer = new Mailer();
//$rc = $mail->quickMail($line["email"], $line["login"], DIGEST_SUBJECT, $digest, $digest_text);
//$rc = $mail->quickMail($line["email"], $line["login"], Config::get(Config::DIGEST_SUBJECT), $digest, $digest_text);
$rc = $mailer->mail(["to_name" => $line["login"],
"to_address" => $line["email"],
"subject" => DIGEST_SUBJECT,
"subject" => Config::get(Config::DIGEST_SUBJECT),
"message" => $digest_text,
"message_html" => $digest]);
@ -70,7 +62,7 @@ class Digest
if ($rc && $do_catchup) {
Debug::log("Marking affected articles as read...");
Article::catchupArticlesById($affected_ids, 0, $line["id"]);
Article::_catchup_by_id($affected_ids, 0, $line["id"]);
}
} else {
Debug::log("No headlines");
@ -83,12 +75,10 @@ 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();
@ -96,24 +86,26 @@ 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', 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', SELF_URL_PATH);
$tpl_t->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$affected_ids = array();
$days = (int) $days;
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'";
} else if (DB_TYPE == "mysql") {
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)";
}
@ -127,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
@ -140,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();
@ -162,11 +152,11 @@ 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'];
}
$article_labels = Article::get_article_labels($line["ref_id"], $user_id);
$article_labels = Article::_get_labels($line["ref_id"], $user_id);
$article_labels_formatted = "";
if (is_array($article_labels) && count($article_labels) > 0) {
@ -212,5 +202,4 @@ class Digest
return array($tmp, $headlines_count, $affected_ids, $tmp_t);
}
}

@ -191,23 +191,23 @@ class DiskCache {
];
public function __construct($dir) {
$this->dir = CACHE_DIR . "/" . basename(clean($dir));
$this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir));
}
public function getDir() {
public function get_dir() {
return $this->dir;
}
public function makeDir() {
public function make_dir() {
if (!is_dir($this->dir)) {
return mkdir($this->dir);
}
}
public function isWritable($filename = "") {
public function is_writable($filename = "") {
if ($filename) {
if (file_exists($this->getFullPath($filename)))
return is_writable($this->getFullPath($filename));
if (file_exists($this->get_full_path($filename)))
return is_writable($this->get_full_path($filename));
else
return is_writable($this->dir);
} else {
@ -216,44 +216,44 @@ class DiskCache {
}
public function exists($filename) {
return file_exists($this->getFullPath($filename));
return file_exists($this->get_full_path($filename));
}
public function getSize($filename) {
public function get_size($filename) {
if ($this->exists($filename))
return filesize($this->getFullPath($filename));
return filesize($this->get_full_path($filename));
else
return -1;
}
public function getFullPath($filename) {
public function get_full_path($filename) {
return $this->dir . "/" . basename(clean($filename));
}
public function put($filename, $data) {
return file_put_contents($this->getFullPath($filename), $data);
return file_put_contents($this->get_full_path($filename), $data);
}
public function touch($filename) {
return touch($this->getFullPath($filename));
return touch($this->get_full_path($filename));
}
public function get($filename) {
if ($this->exists($filename))
return file_get_contents($this->getFullPath($filename));
return file_get_contents($this->get_full_path($filename));
else
return null;
}
public function getMimeType($filename) {
public function get_mime_type($filename) {
if ($this->exists($filename))
return mime_content_type($this->getFullPath($filename));
return mime_content_type($this->get_full_path($filename));
else
return null;
}
public function getFakeExtension($filename) {
$mimetype = $this->getMimeType($filename);
public function get_fake_extension($filename) {
$mimetype = $this->get_mime_type($filename);
if ($mimetype)
return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : "";
@ -262,25 +262,25 @@ class DiskCache {
}
public function send($filename) {
$fake_extension = $this->getFakeExtension($filename);
$fake_extension = $this->get_fake_extension($filename);
if ($fake_extension)
$fake_extension = ".$fake_extension";
header("Content-Disposition: inline; filename=\"${filename}${fake_extension}\"");
return $this->send_local_file($this->getFullPath($filename));
return $this->send_local_file($this->get_full_path($filename));
}
public function getUrl($filename) {
return get_self_url_prefix() . "/public.php?op=cached_url&file=" . basename($this->dir) . "/" . basename($filename);
public function get_url($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
// this is called separately after sanitize() and plugin render article hooks to allow
// plugins work on original source URLs used before caching
// NOTE: URLs should be already absolutized because this is called after sanitize()
static public function rewriteUrls($str)
static public function rewrite_urls($str)
{
$res = trim($str);
if (!$res) return '';
@ -301,7 +301,7 @@ class DiskCache {
$cached_filename = sha1($url);
if ($cache->exists($cached_filename)) {
$url = $cache->getUrl($cached_filename);
$url = $cache->get_url($cached_filename);
$entry->setAttribute($attr, $url);
$entry->removeAttribute("srcset");
@ -318,7 +318,7 @@ class DiskCache {
$cached_filename = sha1($matches[$i]["url"]);
if ($cache->exists($cached_filename)) {
$matches[$i]["url"] = $cache->getUrl($cached_filename);
$matches[$i]["url"] = $cache->get_url($cached_filename);
$need_saving = true;
}
@ -329,7 +329,9 @@ class DiskCache {
}
if ($need_saving) {
$doc->removeChild($doc->firstChild); //remove doctype
if (isset($doc->firstChild))
$doc->removeChild($doc->firstChild); //remove doctype
$res = $doc->saveHTML();
}
}
@ -337,7 +339,7 @@ class DiskCache {
}
static function expire() {
$dirs = array_filter(glob(CACHE_DIR . "/*"), "is_dir");
$dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir");
foreach ($dirs as $cache_dir) {
$num_deleted = 0;
@ -347,7 +349,7 @@ class DiskCache {
if ($files) {
foreach ($files as $file) {
if (time() - filemtime($file) > 86400*CACHE_MAX_DAYS) {
if (time() - filemtime($file) > 86400*Config::get(Config::CACHE_MAX_DAYS)) {
unlink($file);
++$num_deleted;
@ -384,7 +386,7 @@ class DiskCache {
$mimetype_blacklist = [ "image/svg+xml" ];
/* only serve video and images */
if (!preg_match("/(image|audio|video)\//", $mimetype) || in_array($mimetype, $mimetype_blacklist)) {
if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) {
http_response_code(400);
header("Content-type: text/plain");
@ -394,16 +396,15 @@ class DiskCache {
$tmppluginhost = new PluginHost();
$tmppluginhost->load(PLUGINS, PluginHost::KIND_SYSTEM);
$tmppluginhost->load_data();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM);
//$tmppluginhost->load_data();
foreach ($tmppluginhost->get_hooks(PluginHost::HOOK_SEND_LOCAL_FILE) as $plugin) {
if ($plugin->hook_send_local_file($filename)) return true;
}
if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename))
return true;
header("Content-type: $mimetype");
$stamp = gmdate("D, d M Y H:i:s", filemtime($filename)) . " GMT";
$stamp = gmdate("D, d M Y H:i:s", (int)filemtime($filename)) . " GMT";
header("Last-Modified: $stamp", true);
return readfile($filename);

@ -1,207 +0,0 @@
<?php
class Dlg extends Handler_Protected {
private $param;
private $params;
function before($method) {
if (parent::before($method)) {
header("Content-Type: text/html"); # required for iframe
$this->param = $_REQUEST["param"];
return true;
}
return false;
}
function importOpml() {
print_notice("If you have imported labels and/or filters, you might need to reload preferences to see your new data.");
print "<div class='panel panel-scrollable'>";
$opml = new Opml($_REQUEST);
$opml->opml_import($_SESSION["uid"]);
print "</div>";
print "<footer class='text-center'>";
print "<button dojoType='dijit.form.Button'
onclick=\"dijit.byId('opmlImportDlg').execute()\">".
__('Close this window')."</button>";
print "</footer>";
print "</div>";
//return;
}
function pubOPMLUrl() {
$url_path = Opml::opml_publish_url();
print "<header>" . __("Your Public OPML URL is:") . "</header>";
print "<section>";
print "<div class='panel text-center'>";
print "<a id='pub_opml_url' href='$url_path' target='_blank'>$url_path</a>";
print "</div>";
print "</section>";
print "<footer class='text-center'>";
print "<button dojoType='dijit.form.Button' onclick=\"return Helpers.OPML.changeKey()\">".
__('Generate new URL')."</button> ";
print "<button dojoType='dijit.form.Button' onclick=\"return CommonDialogs.closeInfoBox()\">".
__('Close this window')."</button>";
print "</footer>";
//return;
}
function explainError() {
print "<div class=\"errorExplained\">";
if ($this->param == 1) {
print __("Update daemon is enabled in configuration, but daemon process is not running, which prevents all feeds from updating. Please start the daemon process or contact instance owner.");
$stamp = (int) file_get_contents(LOCK_DIRECTORY . "/update_daemon.stamp");
print "<p>" . __("Last update:") . " " . date("Y.m.d, G:i", $stamp);
}
if ($this->param == 3) {
print __("Update daemon is taking too long to perform a feed update. This could indicate a problem like crash or a hang. Please check the daemon process or contact instance owner.");
$stamp = (int) file_get_contents(LOCK_DIRECTORY . "/update_daemon.stamp");
print "<p>" . __("Last update:") . " " . date("Y.m.d, G:i", $stamp);
}
print "</div>";
print "<footer class='text-center'>";
print "<button onclick=\"return CommonDialogs.closeInfoBox()\">".
__('Close this window')."</button>";
print "</footer>";
//return;
}
function printTagCloud() {
print "<div class='panel text-center'>";
// from here: http://www.roscripts.com/Create_tag_cloud-71.html
$sth = $this->pdo->prepare("SELECT tag_name, COUNT(post_int_id) AS count
FROM ttrss_tags WHERE owner_uid = ?
GROUP BY tag_name ORDER BY count DESC LIMIT 50");
$sth->execute([$_SESSION['uid']]);
$tags = array();
while ($line = $sth->fetch()) {
$tags[$line["tag_name"]] = $line["count"];
}
if(count($tags) == 0 ){ return; }
ksort($tags);
$max_size = 32; // max font size in pixels
$min_size = 11; // min font size in pixels
// largest and smallest array values
$max_qty = max(array_values($tags));
$min_qty = min(array_values($tags));
// find the range of values
$spread = $max_qty - $min_qty;
if ($spread == 0) { // we don't want to divide by zero
$spread = 1;
}
// set the font-size increment
$step = ($max_size - $min_size) / ($spread);
// loop through the tag array
foreach ($tags as $key => $value) {
// calculate font-size
// find the $value in excess of $min_qty
// multiply by the font-size increment ($size)
// and add the $min_size set above
$size = round($min_size + (($value - $min_qty) * $step));
$key_escaped = str_replace("'", "\\'", $key);
echo "<a href=\"#\" onclick=\"Feeds.open({feed:'$key_escaped'}) \" style=\"font-size: " .
$size . "px\" title=\"$value articles tagged with " .
$key . '">' . $key . '</a> ';
}
print "</div>";
print "<footer class='text-center'>";
print "<button dojoType='dijit.form.Button'
onclick=\"return CommonDialogs.closeInfoBox()\">".
__('Close this window')."</button>";
print "</footer>";
}
function generatedFeed() {
$this->params = explode(":", $this->param, 3);
$feed_id = $this->params[0];
$is_cat = (bool) $this->params[1];
$key = Feeds::get_feed_access_key($feed_id, $is_cat);
$url_path = htmlspecialchars($this->params[2]) . "&key=" . $key;
$feed_title = Feeds::getFeedTitle($feed_id, $is_cat);
print "<header>".T_sprintf("%s can be accessed via the following secret URL:", $feed_title)."</header>";
print "<section>";
print "<div class='panel text-center'>";
print "<a id='gen_feed_url' href='$url_path' target='_blank'>$url_path</a>";
print "</div>";
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button' style='float : left' class='alt-info' onclick='window.open(\"https://tt-rss.org/wiki/GeneratedFeeds\")'>
<i class='material-icons'>help</i> ".__("More info...")."</button>";
print "<button dojoType='dijit.form.Button' onclick=\"return CommonDialogs.genUrlChangeKey('$feed_id', '$is_cat')\">".
__('Generate new URL')."</button> ";
print "<button dojoType='dijit.form.Button' onclick=\"return CommonDialogs.closeInfoBox()\">".
__('Close this window')."</button>";
print "</footer>";
//return;
}
function defaultPasswordWarning() {
print_warning(__("You are using default tt-rss password. Please change it in the Preferences (Personal data / Authentication)."));
print "<footer class='text-center'>";
print "<button dojoType='dijit.form.Button' class='alt-primary'
onclick=\"document.location.href = 'prefs.php'\">".
__('Open Preferences')."</button> ";
print "<button dojoType='dijit.form.Button'
onclick=\"return dijit.byId('defaultPasswordDlg').hide();\">".
__('Close this window')."</button>";
print "</footeer>";
}
}

@ -0,0 +1,13 @@
<?php
class Errors {
const E_SUCCESS = "E_SUCCESS";
const E_UNAUTHORIZED = "E_UNAUTHORIZED";
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, array $params = []) {
return json_encode(["error" => ["code" => $code, "params" => $params]]);
}
}

@ -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));
}
}
@ -122,16 +155,22 @@ class FeedItem_Atom extends FeedItem_Common {
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);
}

@ -179,7 +179,7 @@ abstract class FeedItem_Common extends FeedItem {
$cat = preg_replace('/[,\'\"]/', "", $cat);
if (DB_TYPE == "mysql") {
if (Config::get(Config::DB_TYPE) == "mysql") {
$cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat);
}
@ -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);

@ -53,7 +53,7 @@ class FeedParser {
$root = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)");
if ($root && $root->length > 0) {
if (!empty($root) && $root->length > 0) {
$root = $root->item(0);
if ($root) {
@ -106,7 +106,7 @@ class FeedParser {
$articles = $xpath->query("//atom:entry");
if (!$articles || $articles->length == 0)
if (empty($articles) || $articles->length == 0)
$articles = $xpath->query("//atom03:entry");
foreach ($articles as $article) {

File diff suppressed because it is too large Load Diff

@ -0,0 +1,11 @@
<?php
class Handler_Administrative extends Handler_Protected {
function before($method) {
if (parent::before($method)) {
if (($_SESSION["access_level"] ?? 0) >= 10) {
return true;
}
}
return false;
}
}

@ -2,6 +2,6 @@
class Handler_Protected extends Handler {
function before($method) {
return parent::before($method) && $_SESSION['uid'];
return parent::before($method) && !empty($_SESSION['uid']);
}
}

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
<?php
interface IAuthModule {
function authenticate($login, $password); // + optional third parameter: $service
function hook_auth_user(...$args); // compatibility wrapper due to how hooks work
}

@ -1,13 +0,0 @@
<?php
interface IDb {
function connect($host, $user, $pass, $db, $port);
function escape_string($s, $strip_tags = true);
function query($query, $die_on_error = true);
function fetch_assoc($result);
function num_rows($result);
function fetch_result($result, $row, $param);
function close();
function affected_rows($result);
function last_error();
function last_query_error();
}

@ -37,7 +37,18 @@ class Labels
}
}
static function get_all_labels($owner_uid) {
static function get_as_hash($owner_uid) {
$rv = [];
$labels = Labels::get_all($owner_uid);
foreach ($labels as $i => $label) {
$rv[$label["id"]] = $labels[$i];
}
return $rv;
}
static function get_all($owner_uid) {
$rv = array();
$pdo = Db::pdo();
@ -46,7 +57,7 @@ class Labels
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
array_push($rv, $line);
}
@ -60,7 +71,7 @@ class Labels
self::clear_cache($id);
if (!$labels)
$labels = Article::get_article_labels($id);
$labels = Article::_get_labels($id);
$labels = json_encode($labels);
@ -196,6 +207,8 @@ class Labels
$sth->execute([$caption, $owner_uid, $fg_color, $bg_color]);
$result = $sth->rowCount();
} else {
$result = false;
}
if (!$tr_in_progress) $pdo->commit();

@ -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() {
@ -42,26 +54,33 @@ class Logger {
}
function __construct() {
switch (LOG_DESTINATION) {
case "sql":
switch (Config::get(Config::LOG_DESTINATION)) {
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,22 +1,29 @@
<?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"] ? $_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);
$server_params = [
"IP" => "REMOTE_ADDR",
"Real IP" => "HTTP_X_REAL_IP",
"Forwarded For" => "HTTP_X_FORWARDED_FOR",
"Forwarded Protocol" => "HTTP_X_FORWARDED_PROTO",
"Remote IP" => "REMOTE_ADDR",
"Request URI" => "REQUEST_URI",
"User agent" => "HTTP_USER_AGENT",
];
@ -31,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,10 +1,7 @@
<?php
class Logger_Stdout {
class Logger_Stdout implements Logger_Adapter {
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
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:
@ -24,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,10 +1,7 @@
<?php
class Logger_Syslog {
class Logger_Syslog implements Logger_Adapter {
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
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:
@ -24,10 +21,10 @@ 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"] : SMTP_FROM_NAME;
$from_address = $params["from_address"] ? $params["from_address"] : 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 (defined('_LOG_SENT_MAIL') && _LOG_SENT_MAIL)
Logger::get()->log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message");
if (Config::get(Config::LOG_SENT_MAIL))
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() {

@ -1,5 +1,5 @@
<?php
class Opml extends Handler_Protected {
class OPML extends Handler_Protected {
function csrf_ignore($method) {
$csrf_ignored = array("export", "import");
@ -31,7 +31,7 @@ class Opml extends Handler_Protected {
<body class='claro ttrss_utility'>
<h1>".__('OPML Utility')."</h1><div class='content'>";
Feeds::add_feed_category("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();
@ -204,36 +202,36 @@ class Opml extends Handler_Protected {
$cat_filter = $tmp_line["cat_filter"];
if (!$tmp_line["match_on"]) {
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
$tmp_line["feed"] = Feeds::getFeedTitle(
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
$cat_filter);
} else {
$tmp_line["feed"] = "";
}
} else {
$match = [];
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::getCategoryTitle($feed_id), true, false]);
} else {
array_push($match, [0, true, true]);
}
} else {
if ($feed_id) {
array_push($match, [Feeds::getFeedTitle((int)$feed_id), false, false]);
} else {
array_push($match, [0, false, true]);
}
}
}
$tmp_line["match"] = $match;
unset($tmp_line["match_on"]);
}
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
$tmp_line["feed"] = Feeds::_get_title(
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
$cat_filter);
} else {
$tmp_line["feed"] = "";
}
} else {
$match = [];
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::_get_cat_title($feed_id), true, false]);
} else {
array_push($match, [0, true, true]);
}
} else {
if ($feed_id) {
array_push($match, [Feeds::_get_title((int)$feed_id), false, false]);
} else {
array_push($match, [0, false, true]);
}
}
}
$tmp_line["match"] = $match;
unset($tmp_line["match_on"]);
}
unset($tmp_line["feed_id"]);
unset($tmp_line["cat_id"]);
@ -272,7 +270,7 @@ class Opml extends Handler_Protected {
$doc->preserveWhiteSpace = false;
$doc->loadXML($out);
$xpath = new DOMXpath($doc);
$xpath = new DOMXPath($doc);
$outlines = $xpath->query("//outline[@title]");
// cleanup empty categories
@ -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,47 +391,58 @@ 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 = [];
$match_on = [];
foreach ($rule["match"] as $match) {
list ($name, $is_cat, $is_id) = $match;
foreach ($rule["match"] as $match) {
list ($name, $is_cat, $is_id) = $match;
if ($is_id) {
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
} else {
if ($is_id) {
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
} else {
if (!$is_cat) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
$match_id = Feeds::_find_by_title($name, $is_cat, $owner_uid);
$tsth->execute([$name, $_SESSION['uid']]);
if ($match_id) {
if ($is_cat) {
array_push($match_on, "CAT:$match_id");
} else {
array_push($match_on, $match_id);
}
}
if ($row = $tsth->fetch()) {
$match_id = $row['id'];
/*if (!$is_cat) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$name, $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$match_id = $row['id'];
array_push($match_on, $match_id);
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$name, $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
@ -441,54 +450,64 @@ class Opml extends Handler_Protected {
array_push($match_on, "CAT:$match_id");
}
}
}
}
} */
}
}
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$match_on = json_encode($match_on);
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$match_on = json_encode($match_on);
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
(feed_id,cat_id,match_on,filter_id,filter_type,reg_exp,cat_filter,inverse)
VALUES
(NULL, NULL, ?, ?, ?, ?, false, ?)");
$usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]);
VALUES
(NULL, NULL, ?, ?, ?, ?, false, ?)");
$usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]);
} else {
$match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $owner_uid);
} else {
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 = ?");
/*if (!$rule["cat_filter"]) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
} else {
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
}
} */
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
(feed_id,cat_id,filter_id,filter_type,reg_exp,cat_filter,inverse)
VALUES
(?, ?, ?, ?, ?, ?, ?)");
$usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]);
}
VALUES
(?, ?, ?, ?, ?, ?, ?)");
$usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]);
}
}
foreach ($filter["actions"] as $action) {
@ -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_feed_category($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 {
@ -534,27 +552,28 @@ class Opml extends Handler_Protected {
$outlines = $root_node->childNodes;
} else {
$xpath = new DOMXpath($doc);
$xpath = new DOMXPath($doc);
$outlines = $xpath->query("//opml/body/outline");
$cat_id = 0;
$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) {
@ -565,98 +584,112 @@ 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 ($_FILES['opml_file']['error'] != 0) {
print_error(T_sprintf("Upload failed with error code %d",
$_FILES['opml_file']['error']));
return;
}
if (!$filename) {
if ($_FILES['opml_file']['error'] != 0) {
print_error(T_sprintf("Upload failed with error code %d",
$_FILES['opml_file']['error']));
return false;
}
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
$tmp_file = tempnam(CACHE_DIR . '/upload', 'opml');
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
$tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml');
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
$tmp_file);
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
$tmp_file);
if (!$result) {
print_error(__("Unable to move uploaded file."));
return;
if (!$result) {
print_error(__("Unable to move uploaded file."));
return false;
}
} else {
print_error(__('Error: please upload OPML file.'));
return false;
}
} else {
print_error(__('Error: please upload OPML file.'));
return;
$tmp_file = $filename;
}
if (!is_readable($tmp_file)) {
$this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename));
return false;
}
if (is_file($tmp_file)) {
$doc = new DOMDocument();
$loaded = false;
$doc = new DOMDocument();
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(false);
$loaded = $doc->load($tmp_file);
}
$loaded = $doc->load($tmp_file);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
unlink($tmp_file);
} else if (!$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 opml_publish_url(){
$url_path = get_self_url_prefix();
$url_path .= "/opml.php?op=publish&key=" .
Feeds::get_feed_access_key('OPML:Publish', false, $_SESSION["uid"]);
return $url_path;
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);
@ -22,13 +21,14 @@ abstract class Plugin {
return array();
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
function is_public_method($method) {
return false;
}
function csrf_ignore($method) {
return false;
}
function get_js() {
return "";
}
@ -57,4 +57,5 @@ abstract class Plugin {
return vsprintf($this->__($msgid), $args);
}
}

@ -7,17 +7,23 @@ class PluginHandler extends Handler_Protected {
function catchall($method) {
$plugin_name = clean($_REQUEST["plugin"]);
$plugin = PluginHost::getInstance()->get_plugin($plugin_name);
$csrf_token = ($_POST["csrf_token"] ?? "");
if ($plugin) {
if (method_exists($plugin, $method)) {
$plugin->$method();
if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) {
$plugin->$method();
} else {
user_error("Rejected ${plugin_name}->${method}(): invalid CSRF token.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
user_error("PluginHandler: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
print error_json(13);
user_error("Rejected ${plugin_name}->${method}(): unknown method.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
} else {
user_error("PluginHandler: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING);
print error_json(14);
user_error("Rejected ${plugin_name}->${method}(): unknown plugin.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
}
}
}

@ -14,66 +14,168 @@ class PluginHost {
private $plugin_actions = array();
private $owner_uid;
private $last_registered;
private $data_loaded;
private static $instance;
const API_VERSION = 2;
const PUBLIC_METHOD_DELIMITER = "--";
// Hooks marked with *1 are run in global context and available
// to plugins loaded in config.php only
const HOOK_ARTICLE_BUTTON = 1;
const HOOK_ARTICLE_FILTER = 2;
const HOOK_PREFS_TAB = 3;
const HOOK_PREFS_TAB_SECTION = 4;
const HOOK_PREFS_TABS = 5;
const HOOK_FEED_PARSED = 6;
const HOOK_UPDATE_TASK = 7; // *1
const HOOK_AUTH_USER = 8;
const HOOK_HOTKEY_MAP = 9;
const HOOK_RENDER_ARTICLE = 10;
const HOOK_RENDER_ARTICLE_CDM = 11;
const HOOK_FEED_FETCHED = 12;
const HOOK_SANITIZE = 13;
const HOOK_RENDER_ARTICLE_API = 14;
const HOOK_TOOLBAR_BUTTON = 15;
const HOOK_ACTION_ITEM = 16;
const HOOK_HEADLINE_TOOLBAR_BUTTON = 17;
const HOOK_HOTKEY_INFO = 18;
const HOOK_ARTICLE_LEFT_BUTTON = 19;
const HOOK_PREFS_EDIT_FEED = 20;
const HOOK_PREFS_SAVE_FEED = 21;
const HOOK_FETCH_FEED = 22;
const HOOK_QUERY_HEADLINES = 23;
const HOOK_HOUSE_KEEPING = 24; // *1
const HOOK_SEARCH = 25;
const HOOK_FORMAT_ENCLOSURES = 26;
const HOOK_SUBSCRIBE_FEED = 27;
const HOOK_HEADLINES_BEFORE = 28;
const HOOK_RENDER_ENCLOSURE = 29;
const HOOK_ARTICLE_FILTER_ACTION = 30;
const HOOK_ARTICLE_EXPORT_FEED = 31;
const HOOK_MAIN_TOOLBAR_BUTTON = 32;
const HOOK_ENCLOSURE_ENTRY = 33;
const HOOK_FORMAT_ARTICLE = 34;
const HOOK_FORMAT_ARTICLE_CDM = 35; /* RIP */
const HOOK_FEED_BASIC_INFO = 36;
const HOOK_SEND_LOCAL_FILE = 37;
const HOOK_UNSUBSCRIBE_FEED = 38;
const HOOK_SEND_MAIL = 39;
const HOOK_FILTER_TRIGGERED = 40;
const HOOK_GET_FULL_TEXT = 41;
const HOOK_ARTICLE_IMAGE = 42;
const HOOK_FEED_TREE = 43;
const HOOK_IFRAME_WHITELISTED = 44;
const HOOK_ENCLOSURE_IMPORTED = 45;
const HOOK_HEADLINES_CUSTOM_SORT_MAP = 46;
const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = 47;
/** 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_headline_toolbar_select_menu_item($feed_id, $is_cat) */
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM = "hook_headline_toolbar_select_menu_item";
/** hook_pre_subscribe($url, $auth_login, $auth_pass) (byref) */
const HOOK_PRE_SUBSCRIBE = "hook_pre_subscribe";
const KIND_ALL = 1;
const KIND_SYSTEM = 2;
const KIND_USER = 3;
static function object_to_domain($plugin) {
static function object_to_domain(Plugin $plugin) {
return strtolower(get_class($plugin));
}
@ -86,28 +188,29 @@ class PluginHost {
//
}
public static function getInstance() {
public static function getInstance(): PluginHost {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
private function register_plugin($name, $plugin) {
private function register_plugin(string $name, Plugin $plugin) {
//array_push($this->plugins, $plugin);
$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 (?) */
function get_dbh() {
return Db::get();
return false;
}
function get_pdo() {
function get_pdo(): PDO {
return $this->pdo;
}
@ -125,24 +228,97 @@ class PluginHost {
return $this->plugins;
}
function get_plugin($name) {
return $this->plugins[strtolower($name)];
function get_plugin(string $name) {
return $this->plugins[strtolower($name)] ?? null;
}
function run_hooks($type, $method, $args) {
foreach ($this->get_hooks($type) as $hook) {
$hook->$method($args);
function run_hooks(string $hook, ...$args) {
$method = strtolower($hook);
foreach ($this->get_hooks($hook) as $plugin) {
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
try {
$plugin->$method(...$args);
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
function add_hook($type, $sender, $priority = 50) {
function run_hooks_until(string $hook, $check, ...$args) {
$method = strtolower($hook);
foreach ($this->get_hooks($hook) as $plugin) {
try {
$result = $plugin->$method(...$args);
if ($result == $check)
return true;
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
return false;
}
function run_hooks_callback(string $hook, Closure $callback, ...$args) {
$method = strtolower($hook);
foreach ($this->get_hooks($hook) as $plugin) {
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
try {
if ($callback($plugin->$method(...$args), $plugin))
break;
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
function chain_hooks_callback(string $hook, Closure $callback, &...$args) {
$method = strtolower($hook);
foreach ($this->get_hooks($hook) as $plugin) {
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
try {
if ($callback($plugin->$method(...$args), $plugin))
break;
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
function add_hook(string $type, Plugin $sender, int $priority = 50) {
$priority = (int) $priority;
if (!is_array($this->hooks[$type])) {
if (!method_exists($sender, strtolower($type))) {
user_error(
sprintf("Plugin %s tried to register a hook without implementation: %s",
get_class($sender), $type),
E_USER_WARNING
);
return;
}
if (empty($this->hooks[$type])) {
$this->hooks[$type] = [];
}
if (!is_array($this->hooks[$type][$priority])) {
if (empty($this->hooks[$type][$priority])) {
$this->hooks[$type][$priority] = [];
}
@ -150,7 +326,7 @@ class PluginHost {
ksort($this->hooks[$type]);
}
function del_hook($type, $sender) {
function del_hook(string $type, Plugin $sender) {
if (is_array($this->hooks[$type])) {
foreach (array_keys($this->hooks[$type]) as $prio) {
$key = array_search($sender, $this->hooks[$type][$prio]);
@ -162,7 +338,7 @@ class PluginHost {
}
}
function get_hooks($type) {
function get_hooks(string $type) {
if (isset($this->hooks[$type])) {
$tmp = [];
@ -175,7 +351,7 @@ class PluginHost {
return [];
}
}
function load_all($kind, $owner_uid = false, $skip_init = false) {
function load_all(int $kind, int $owner_uid = null, bool $skip_init = false) {
$plugins = array_merge(glob("plugins/*"), glob("plugins.local/*"));
$plugins = array_filter($plugins, "is_dir");
@ -186,7 +362,7 @@ class PluginHost {
$this->load(join(",", $plugins), $kind, $owner_uid, $skip_init);
}
function load($classlist, $kind, $owner_uid = false, $skip_init = false) {
function load(string $classlist, int $kind, int $owner_uid = null, bool $skip_init = false) {
$plugins = explode(",", $classlist);
$this->owner_uid = (int) $owner_uid;
@ -195,44 +371,27 @@ 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])) {
if (file_exists($file)) require_once $file;
try {
if (file_exists($file)) require_once $file;
} catch (Error $err) {
user_error($err, E_USER_WARNING);
continue;
}
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) {
@ -247,42 +406,50 @@ class PluginHost {
$this->last_registered = $class;
switch ($kind) {
case $this::KIND_SYSTEM:
if ($this->is_system($plugin)) {
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
}
break;
case $this::KIND_USER:
if (!$this->is_system($plugin)) {
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
}
break;
case $this::KIND_ALL:
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
break;
try {
switch ($kind) {
case $this::KIND_SYSTEM:
if ($this->is_system($plugin)) {
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
}
break;
case $this::KIND_USER:
if (!$this->is_system($plugin)) {
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
}
break;
case $this::KIND_ALL:
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
break;
}
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
}
$this->load_data();
}
function is_system($plugin) {
function is_system(Plugin $plugin) {
$about = $plugin->about();
return @$about[3];
return $about[3] ?? false;
}
// only system plugins are allowed to modify routing
function add_handler($handler, $method, $sender) {
function add_handler(string $handler, $method, Plugin $sender) {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
if ($this->is_system($sender)) {
if (!is_array($this->handlers[$handler])) {
if (!isset($this->handlers[$handler])) {
$this->handlers[$handler] = array();
}
@ -290,7 +457,7 @@ class PluginHost {
}
}
function del_handler($handler, $method, $sender) {
function del_handler(string $handler, $method, Plugin $sender) {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
@ -303,7 +470,7 @@ class PluginHost {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
if (is_array($this->handlers[$handler])) {
if (isset($this->handlers[$handler])) {
if (isset($this->handlers[$handler]["*"])) {
return $this->handlers[$handler]["*"];
} else {
@ -314,7 +481,7 @@ class PluginHost {
return false;
}
function add_command($command, $description, $sender, $suffix = "", $arghelp = "") {
function add_command(string $command, string $description, Plugin $sender, string $suffix = "", string $arghelp = "") {
$command = str_replace("-", "_", strtolower($command));
$this->commands[$command] = array("description" => $description,
@ -323,7 +490,7 @@ class PluginHost {
"class" => $sender);
}
function del_command($command) {
function del_command(string $command) {
$command = "-" . strtolower($command);
unset($this->commands[$command]);
@ -343,7 +510,7 @@ class PluginHost {
return $this->commands;
}
function run_commands($args) {
function run_commands(array $args) {
foreach ($this->get_commands() as $command => $data) {
if (isset($args[$command])) {
$command = str_replace("-", "", $command);
@ -352,8 +519,8 @@ class PluginHost {
}
}
function load_data() {
if ($this->owner_uid) {
private function load_data() {
if ($this->owner_uid && !$this->data_loaded && get_schema_version() > 100) {
$sth = $this->pdo->prepare("SELECT name, content FROM ttrss_plugin_storage
WHERE owner_uid = ?");
$sth->execute([$this->owner_uid]);
@ -361,10 +528,12 @@ class PluginHost {
while ($line = $sth->fetch()) {
$this->storage[$line["name"]] = unserialize($line["content"]);
}
$this->data_loaded = true;
}
}
private function save_data($plugin) {
private function save_data(string $plugin) {
if ($this->owner_uid) {
if (!$this->pdo_data)
@ -384,20 +553,43 @@ class PluginHost {
if ($sth->fetch()) {
$sth = $this->pdo_data->prepare("UPDATE ttrss_plugin_storage SET content = ?
WHERE owner_uid= ? AND name = ?");
$sth->execute([(string)$content, $this->owner_uid, $plugin]);
$sth->execute([$content, $this->owner_uid, $plugin]);
} else {
$sth = $this->pdo_data->prepare("INSERT INTO ttrss_plugin_storage
(name,owner_uid,content) VALUES
(?, ?, ?)");
$sth->execute([$plugin, $this->owner_uid, (string)$content]);
$sth->execute([$plugin, $this->owner_uid, $content]);
}
$this->pdo_data->commit();
}
}
function set($sender, $name, $value, $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]))
@ -405,12 +597,46 @@ class PluginHost {
$this->storage[$idx][$name] = $value;
if ($sync) $this->save_data(get_class($sender));
$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;
$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($sender, $name, $default_value = false) {
function get(Plugin $sender, string $name, $default_value = false) {
$idx = get_class($sender);
$this->load_data();
if (isset($this->storage[$idx][$name])) {
return $this->storage[$idx][$name];
} else {
@ -418,15 +644,21 @@ 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);
$data = $this->storage[$idx];
return $data ? $data : [];
return $this->storage[$idx] ?? [];
}
function clear_data($sender) {
function clear_data(Plugin $sender) {
if ($this->owner_uid) {
$idx = get_class($sender);
@ -441,23 +673,25 @@ class PluginHost {
// Plugin feed functions are *EXPERIMENTAL*!
// cat_id: only -1 is supported (Special)
function add_feed($cat_id, $title, $icon, $sender) {
if (!$this->feeds[$cat_id]) $this->feeds[$cat_id] = array();
function add_feed(int $cat_id, string $title, string $icon, Plugin $sender) {
if (empty($this->feeds[$cat_id]))
$this->feeds[$cat_id] = [];
$id = count($this->feeds[$cat_id]);
array_push($this->feeds[$cat_id],
array('id' => $id, 'title' => $title, 'sender' => $sender, 'icon' => $icon));
['id' => $id, 'title' => $title, 'sender' => $sender, 'icon' => $icon]);
return $id;
}
function get_feeds($cat_id) {
return $this->feeds[$cat_id];
function get_feeds(int $cat_id) {
return $this->feeds[$cat_id] ?? [];
}
// convert feed_id (e.g. -129) to pfeed_id first
function get_feed_handler($pfeed_id) {
function get_feed_handler(int $pfeed_id) {
foreach ($this->feeds as $cat) {
foreach ($cat as $feed) {
if ($feed['id'] == $pfeed_id) {
@ -467,25 +701,25 @@ class PluginHost {
}
}
static function pfeed_to_feed_id($label) {
return PLUGIN_FEED_BASE_INDEX - 1 - abs($label);
static function pfeed_to_feed_id(int $pfeed) {
return PLUGIN_FEED_BASE_INDEX - 1 - abs($pfeed);
}
static function feed_to_pfeed_id($feed) {
static function feed_to_pfeed_id(int $feed) {
return PLUGIN_FEED_BASE_INDEX - 1 + abs($feed);
}
function add_api_method($name, $sender) {
function add_api_method(string $name, Plugin $sender) {
if ($this->is_system($sender)) {
$this->api_methods[strtolower($name)] = $sender;
}
}
function get_api_method($name) {
function get_api_method(string $name) {
return $this->api_methods[$name];
}
function add_filter_action($sender, $action_name, $action_desc) {
function add_filter_action(Plugin $sender, string $action_name, string $action_desc) {
$sender_class = get_class($sender);
if (!isset($this->plugin_actions[$sender_class]))
@ -504,8 +738,8 @@ class PluginHost {
}
// handled by classes/pluginhandler.php, requires valid session
function get_method_url($sender, $method, $params) {
return get_self_url_prefix() . "/backend.php?" .
function get_method_url(Plugin $sender, string $method, $params = []) {
return Config::get_self_url() . "/backend.php?" .
http_build_query(
array_merge(
[
@ -516,20 +750,40 @@ class PluginHost {
$params));
}
// shortcut syntax (disabled for now)
/* function get_method_url(Plugin $sender, string $method, $params) {
return Config::get_self_url() . "/backend.php?" .
http_build_query(
array_merge(
[
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
],
$params));
} */
// WARNING: endpoint in public.php, exposed to unauthenticated users
function get_public_method_url($sender, $method, $params) {
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(
[
"op" => "pluginhandler",
"plugin" => strtolower(get_class($sender)),
"pmethod" => $method
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
],
$params));
} else {
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";
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -2,80 +2,18 @@
class Pref_Labels extends Handler_Protected {
function csrf_ignore($method) {
$csrf_ignored = array("index", "getlabeltree", "edit");
$csrf_ignored = array("index", "getlabeltree");
return array_search($method, $csrf_ignored) !== false;
}
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 * FROM ttrss_labels2 WHERE
id = ? AND owner_uid = ?");
$sth->execute([$label_id, $_SESSION['uid']]);
if ($line = $sth->fetch()) {
print_hidden("id", "$label_id");
print_hidden("op", "pref-labels");
print_hidden("method", "save");
print "<form onsubmit='return false;'>";
print "<header>".__("Caption")."</header>";
print "<section>";
$fg_color = $line['fg_color'];
$bg_color = $line['bg_color'] ? $line['bg_color'] : '#fff7d5';
print "<input style='font-size : 16px; color : $fg_color; background : $bg_color; transition : background 0.1s linear'
id='labelEdit_caption' name='caption' dojoType='dijit.form.ValidationTextBox'
required='true' value=\"".htmlspecialchars($line['caption'])."\">";
print "</section>";
print "<header>" . __("Colors") . "</header>";
print "<section>";
print "<table>";
print "<tr><th style='text-align : left'>".__("Foreground:")."</th><th style='text-align : left'>".__("Background:")."</th></tr>";
print "<tr><td style='padding-right : 10px'>";
print "<input dojoType='dijit.form.TextBox'
style='display : none' id='labelEdit_fgColor'
name='fg_color' value='$fg_color'>";
print "<input dojoType='dijit.form.TextBox'
style='display : none' id='labelEdit_bgColor'
name='bg_color' value='$bg_color'>";
print "<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='fg_color'>
dijit.byId('labelEdit_fgColor').attr('value', fg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({color: fg_color});
</script>
</div>";
print "</td><td>";
print "<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='bg_color'>
dijit.byId('labelEdit_bgColor').attr('value', bg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({backgroundColor: bg_color});
</script>
</div>";
print "</td></tr></table>";
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button' type='submit' class='alt-primary' onclick=\"dijit.byId('labelEditDlg').execute()\">".
__('Save')."</button>";
print "<button dojoType='dijit.form.Button' onclick=\"dijit.byId('labelEditDlg').hide()\">".
__('Cancel')."</button>";
print "</footer>";
print "</form>";
if ($label) {
print json_encode($label->as_array());
}
}
@ -166,7 +104,7 @@ class Pref_Labels extends Handler_Protected {
function save() {
$id = clean($_REQUEST["id"]);
$caption = trim(clean($_REQUEST["caption"]));
$caption = clean($_REQUEST["caption"]);
$this->pdo->beginTransaction();
@ -197,7 +135,7 @@ class Pref_Labels extends Handler_Protected {
$sth->execute([$caption, $old_caption, $_SESSION['uid']]);
print clean($_REQUEST["value"]);
print clean($_REQUEST["caption"]);
} else {
print $old_caption;
}
@ -222,92 +160,67 @@ 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)) {
if (!$output) {
print T_sprintf("Created label <b>%s</b>", htmlspecialchars($caption));
}
}
if ($output == "select") {
header("Content-Type: text/xml");
print "<rpc-reply><payload>";
print_label_select("select_label",
$caption, "");
print "</payload></rpc-reply>";
}
}
return;
}
function index() {
print "<div dojoType='dijit.layout.BorderContainer' gutters='false'>";
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>";
print "<div dojoType='fox.Toolbar'>";
print "<div dojoType='fox.form.DropDownButton'>".
"<span>" . __('Select')."</span>";
print "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
print "<div onclick=\"dijit.byId('labelTree').model.setAllChecked(true)\"
dojoType=\"dijit.MenuItem\">".__('All')."</div>";
print "<div onclick=\"dijit.byId('labelTree').model.setAllChecked(false)\"
dojoType=\"dijit.MenuItem\">".__('None')."</div>";
print "</div></div>";
print"<button dojoType=\"dijit.form.Button\" onclick=\"CommonDialogs.addLabel()\">".
__('Create label')."</button dojoType=\"dijit.form.Button\"> ";
print "<button dojoType=\"dijit.form.Button\" onclick=\"dijit.byId('labelTree').removeSelected()\">".
__('Remove')."</button dojoType=\"dijit.form.Button\"> ";
print "<button dojoType=\"dijit.form.Button\" onclick=\"dijit.byId('labelTree').resetColors()\">".
__('Clear colors')."</button dojoType=\"dijit.form.Button\">";
print "</div>"; #toolbar
print "</div>"; #pane
print "<div style='padding : 0px' dojoType=\"dijit.layout.ContentPane\" region=\"center\">";
print "<div id=\"labellistLoading\">
<img src='images/indicator_tiny.gif'>".
__("Loading, please wait...")."</div>";
print "<div dojoType=\"dojo.data.ItemFileWriteStore\" jsId=\"labelStore\"
url=\"backend.php?op=pref-labels&method=getlabeltree\">
</div>
<div dojoType=\"lib.CheckBoxStoreModel\" jsId=\"labelModel\" store=\"labelStore\"
query=\"{id:'root'}\" rootId=\"root\"
childrenAttrs=\"items\" checkboxStrict=\"false\" checkboxAll=\"false\">
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span><?= __('Select') ?></span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="dijit.byId('labelTree').model.setAllChecked(true)"
dojoType='dijit.MenuItem'><?=('All') ?></div>
<div onclick="dijit.byId('labelTree').model.setAllChecked(false)"
dojoType='dijit.MenuItem'><?=('None') ?></div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='CommonDialogs.addLabel()'>
<?=('Create label') ?></button dojoType='dijit.form.Button'>
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').removeSelected()">
<?=('Remove') ?></button dojoType='dijit.form.Button'>
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').resetColors()">
<?=('Clear colors') ?></button dojoType='dijit.form.Button'>
</div>
</div>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
<div dojoType='dojo.data.ItemFileWriteStore' jsId='labelStore'
url='backend.php?op=pref-labels&method=getlabeltree'>
</div>
<div dojoType='lib.CheckBoxStoreModel' jsId='labelModel' store='labelStore'
query="{id:'root'}" rootId='root'
childrenAttrs='items' checkboxStrict='false' checkboxAll='false'>
</div>
<div dojoType='fox.PrefLabelTree' id='labelTree' model='labelModel' openOnClick='true'>
<script type='dojo/method' event='onClick' args='item'>
var id = String(item.id);
var bare_id = id.substr(id.indexOf(':')+1);
if (id.match('LABEL:')) {
dijit.byId('labelTree').editLabel(bare_id);
}
</script>
</div>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefLabels") ?>
</div>
<div dojoType=\"fox.PrefLabelTree\" id=\"labelTree\"
model=\"labelModel\" openOnClick=\"true\">
<script type=\"dojo/method\" event=\"onLoad\" args=\"item\">
Element.hide(\"labellistLoading\");
</script>
<script type=\"dojo/method\" event=\"onClick\" args=\"item\">
var id = String(item.id);
var bare_id = id.substr(id.indexOf(':')+1);
if (id.match('LABEL:')) {
dijit.byId('labelTree').editLabel(bare_id);
}
</script>
</div>";
print "</div>"; #pane
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB,
"hook_prefs_tab", "prefLabels");
print "</div>"; #container
<?php
}
}

File diff suppressed because it is too large Load Diff

@ -1,17 +1,8 @@
<?php
class Pref_System extends Handler_Protected {
function before($method) {
if (parent::before($method)) {
if ($_SESSION["access_level"] < 10) {
print __("Your access level is insufficient to open this tab.");
return false;
}
return true;
}
return false;
}
class Pref_System extends Handler_Administrative {
private $log_page_limit = 15;
function csrf_ignore($method) {
$csrf_ignored = array("index");
@ -23,81 +14,213 @@ class Pref_System extends Handler_Protected {
$this->pdo->query("DELETE FROM ttrss_error_log");
}
function index() {
print "<div dojoType=\"dijit.layout.AccordionContainer\" region=\"center\">";
print "<div dojoType=\"dijit.layout.AccordionPane\"
title=\"<i class='material-icons'>report</i> ".__('Event Log')."\">";
if (LOG_DESTINATION == "sql") {
$res = $this->pdo->query("SELECT errno, errstr, filename, lineno,
created_at, login, context FROM ttrss_error_log
LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id)
ORDER BY ttrss_error_log.id DESC
LIMIT 100");
function sendTestEmail() {
$mail_address = clean($_REQUEST["mail_address"]);
print "<button dojoType=\"dijit.form.Button\"
onclick=\"Helpers.updateEventLog()\">".__('Refresh')."</button> ";
$mailer = new Mailer();
print "&nbsp;<button dojoType=\"dijit.form.Button\"
class=\"alt-danger\" onclick=\"Helpers.clearEventLog()\">".__('Clear')."</button> ";
$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 "<p><table width=\"100%\" cellspacing=\"10\" class=\"prefErrorLog\">";
print "<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>";
while ($line = $res->fetch()) {
print "<tr>";
print json_encode(['rc' => $rc, 'error' => $mailer->error()]);
}
foreach ($line as $k => $v) {
$line[$k] = htmlspecialchars($v);
}
function getphpinfo() {
ob_start();
phpinfo();
$info = ob_get_contents();
ob_end_clean();
print "<td class='errno'>" . Logger::$errornames[$line["errno"]] . " (" . $line["errno"] . ")</td>";
print "<td class='filename'>" . $line["filename"] . ":" . $line["lineno"] . "</td>";
print "<td class='errstr'>" . $line["errstr"] . "<hr/>" . nl2br($line["context"]) . "</td>";
print "<td class='login'>" . $line["login"] . "</td>";
print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', (string)$info);
}
print "<td class='timestamp'>" .
TimeHelper::make_local_datetime($line["created_at"], false) . "</td>";
private function _log_viewer(int $page, int $severity) {
$errno_values = [];
print "</tr>";
}
switch ($severity) {
case E_USER_ERROR:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR ];
break;
case E_USER_WARNING:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
break;
}
print "</table>";
if (count($errno_values) > 0) {
$errno_qmarks = arr_qmarks($errno_values);
$errno_filter_qpart = "errno IN ($errno_qmarks)";
} else {
print_notice("Please set LOG_DESTINATION to 'sql' in config.php to enable database logging.");
$errno_filter_qpart = "true";
}
print "</div>";
$limit = $this->log_page_limit;
$offset = $limit * $page;
print "<div dojoType=\"dijit.layout.AccordionPane\"
title=\"<i class='material-icons'>info</i> ".__('PHP Information')."\">";
$sth = $this->pdo->prepare("SELECT
COUNT(id) AS total_pages
FROM
ttrss_error_log
WHERE
$errno_filter_qpart");
ob_start();
phpinfo();
$info = ob_get_contents();
ob_end_clean();
$sth->execute($errno_values);
print "<div class='phpinfo'>";
print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', $info);
print "</div>";
if ($res = $sth->fetch()) {
$total_pages = (int)($res["total_pages"] / $limit);
} else {
$total_pages = 0;
}
print "</div>";
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div region='top' dojoType='fox.Toolbar'>
<button dojoType='dijit.form.Button' onclick='Helpers.EventLog.refresh()'>
<?= __('Refresh') ?>
</button>
<button dojoType='dijit.form.Button' <?= ($page <= 0 ? "disabled" : "") ?>
onclick='Helpers.EventLog.prevPage()'>
<?= __('&lt;&lt;') ?>
</button>
<button dojoType='dijit.form.Button' disabled>
<?= T_sprintf('Page %d of %d', $page+1, $total_pages+1) ?>
</button>
<button dojoType='dijit.form.Button' <?= ($page >= $total_pages ? "disabled" : "") ?>
onclick='Helpers.EventLog.nextPage()'>
<?= __('&gt;&gt;') ?>
</button>
<button dojoType='dijit.form.Button'
onclick='Helpers.EventLog.clear()'>
<?= __('Clear') ?>
</button>
<div class='pull-right'>
<label><?= __("Severity:") ?></label>
<?= \Controls\select_hash("severity", $severity,
[
E_USER_ERROR => __("Errors"),
E_USER_WARNING => __("Warnings"),
E_USER_NOTICE => __("Everything")
], ["onchange"=> "Helpers.EventLog.refresh()"], "severity") ?>
</div>
</div>
<div style="padding : 0px" dojoType="dijit.layout.ContentPane" region="center">
<table width='100%' class='event-log'>
<tr>
<th width='5%'><?= __("Error") ?></th>
<th><?= __("Filename") ?></th>
<th><?= __("Message") ?></th>
<th width='5%'><?= __("User") ?></th>
<th width='5%'><?= __("Date") ?></th>
</tr>
<?php
$sth = $this->pdo->prepare("SELECT
errno, errstr, filename, lineno, created_at, login, context
FROM
ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id)
WHERE
$errno_filter_qpart
ORDER BY
ttrss_error_log.id DESC
LIMIT $limit OFFSET $offset");
$sth->execute($errno_values);
while ($line = $sth->fetch()) {
foreach ($line as $k => $v) { $line[$k] = htmlspecialchars($v); }
?>
<tr>
<td class='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>
<td class='login'><?= $line["login"] ?></td>
<td class='timestamp'>
<?= TimeHelper::make_local_datetime($line["created_at"], false) ?>
</td>
</tr>
<?php } ?>
</table>
</div>
</div>
<?php
}
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB,
"hook_prefs_tab", "prefSystem");
function index() {
print "</div>"; #container
$severity = (int) ($_REQUEST["severity"] ?? E_USER_WARNING);
$page = (int) ($_REQUEST["page"] ?? 0);
?>
<div dojoType='dijit.layout.AccordionContainer' region='center'>
<?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
$this->_log_viewer($page, $severity);
?>
</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'))
window.setTimeout(() => {
xhr.post("backend.php", {op: 'pref-system', method: 'getphpinfo'}, (reply) => {
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
});
}, 200);
</script>
<span class='loading'><?= __("Loading, please wait...") ?></span>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem") ?>
</div>
<?php
}
}

@ -1,124 +1,23 @@
<?php
class Pref_Users extends Handler_Protected {
function before($method) {
if (parent::before($method)) {
if ($_SESSION["access_level"] < 10) {
print __("Your access level is insufficient to open this tab.");
return false;
}
return true;
}
return false;
}
class Pref_Users extends Handler_Administrative {
function csrf_ignore($method) {
$csrf_ignored = array("index", "edit", "userdetails");
return array_search($method, $csrf_ignored) !== false;
return $method == "index";
}
function edit() {
global $access_level_names;
print "<form id='user_edit_form' onsubmit='return false' dojoType='dijit.form.Form'>";
print '<div dojoType="dijit.layout.TabContainer" style="height : 400px">
<div dojoType="dijit.layout.ContentPane" title="'.__('Edit user').'">';
//print "<form id=\"user_edit_form\" onsubmit='return false' dojoType=\"dijit.form.Form\">";
$id = (int) clean($_REQUEST["id"]);
print_hidden("id", "$id");
print_hidden("op", "pref-users");
print_hidden("method", "editSave");
$sth = $this->pdo->prepare("SELECT * FROM ttrss_users WHERE id = ?");
$sth->execute([$id]);
if ($row = $sth->fetch()) {
$login = $row["login"];
$access_level = $row["access_level"];
$email = $row["email"];
$sel_disabled = ($id == $_SESSION["uid"] || $login == "admin") ? "disabled" : "";
print "<header>".__("User")."</header>";
print "<section>";
if ($sel_disabled) {
print_hidden("login", "$login");
}
print "<fieldset>";
print "<label>" . __("Login:") . "</label>";
print "<input style='font-size : 16px'
dojoType='dijit.form.ValidationTextBox' required='1'
$sel_disabled name='login' value=\"$login\">";
print "</fieldset>";
print "</section>";
print "<header>".__("Authentication")."</header>";
print "<section>";
print "<fieldset>";
print "<label>" . __('Access level: ') . "</label> ";
if (!$sel_disabled) {
print_select_hash("access_level", $access_level, $access_level_names,
"dojoType=\"fox.form.Select\" $sel_disabled");
} else {
print_select_hash("", $access_level, $access_level_names,
"dojoType=\"fox.form.Select\" $sel_disabled");
print_hidden("access_level", "$access_level");
}
print "</fieldset>";
print "<fieldset>";
$user = ORM::for_table('ttrss_users')
->select_expr("id,login,access_level,email,full_name,otp_enabled")
->find_one((int)$_REQUEST["id"])
->as_array();
print "<label>" . __("New password:") . "</label> ";
print "<input dojoType='dijit.form.TextBox' type='password' size='20' placeholder='Change password'
name='password'>";
print "</fieldset>";
print "</section>";
print "<header>".__("Options")."</header>";
print "<section>";
print "<fieldset>";
print "<label>" . __("E-mail:") . "</label> ";
print "<input dojoType='dijit.form.TextBox' size='30' name='email'
value=\"$email\">";
print "</fieldset>";
print "</section>";
print "</table>";
global $access_level_names;
if ($user) {
print json_encode([
"user" => $user,
"access_level_names" => $access_level_names
]);
}
print '</div>'; #tab
print "<div href=\"backend.php?op=pref-users&method=userdetails&id=$id\"
dojoType=\"dijit.layout.ContentPane\" title=\"".__('User details')."\">";
print '</div>';
print '</div>';
print "<footer>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick=\"dijit.byId('userEditDlg').execute()\">".
__('Save')."</button>
<button dojoType='dijit.form.Button' onclick=\"dijit.byId('userEditDlg').hide()\">".
__('Cancel')."</button>
</footer>";
print "</form>";
return;
}
function userdetails() {
@ -135,7 +34,6 @@ class Pref_Users extends Handler_Protected {
$sth->execute([$id]);
if ($row = $sth->fetch()) {
print "<table width='100%'>";
$last_login = TimeHelper::make_local_datetime(
$row["last_login"], true);
@ -145,71 +43,93 @@ class Pref_Users extends Handler_Protected {
$stored_articles = $row["stored_articles"];
print "<tr><td>".__('Registered')."</td><td>$created</td></tr>";
print "<tr><td>".__('Last logged in')."</td><td>$last_login</td></tr>";
$sth = $this->pdo->prepare("SELECT COUNT(id) as num_feeds FROM ttrss_feeds
WHERE owner_uid = ?");
$sth->execute([$id]);
$row = $sth->fetch();
$num_feeds = $row["num_feeds"];
print "<tr><td>".__('Subscribed feeds count')."</td><td>$num_feeds</td></tr>";
print "<tr><td>".__('Stored articles')."</td><td>$stored_articles</td></tr>";
print "</table>";
$num_feeds = $row["num_feeds"];
print "<h1>".__('Subscribed feeds')."</h1>";
?>
$sth = $this->pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$id]);
<fieldset>
<label><?= __('Registered') ?>:</label>
<?= $created ?>
</fieldset>
print "<ul class=\"panel panel-scrollable list list-unstyled\">";
<fieldset>
<label><?= __('Last logged in') ?>:</label>
<?= $last_login ?>
</fieldset>
while ($line = $sth->fetch()) {
<fieldset>
<label><?= __('Subscribed feeds') ?>:</label>
<?= $num_feeds ?>
</fieldset>
$icon_file = ICONS_URL."/".$line["id"].".ico";
<fieldset>
<label><?= __('Stored articles') ?>:</label>
<?= $stored_articles ?>
</fieldset>
if (file_exists($icon_file) && filesize($icon_file) > 0) {
$feed_icon = "<img class=\"icon\" src=\"$icon_file\">";
} else {
$feed_icon = "<img class=\"icon\" src=\"images/blank_icon.gif\">";
}
<?php
$sth = $this->pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$id]);
?>
print "<li>$feed_icon&nbsp;<a href=\"".$line["site_url"]."\">".$line["title"]."</a></li>";
<ul class="panel panel-scrollable list list-unstyled">
<?php while ($row = $sth->fetch()) { ?>
<li>
<?php
$icon_file = Config::get(Config::ICONS_URL) . "/" . $row["id"] . ".ico";
$icon = file_exists($icon_file) ? $icon_file : "images/blank_icon.gif";
?>
}
<img class="icon" src="<?= $icon_file ?>">
print "</ul>";
<a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>">
<?= htmlspecialchars($row["title"]) ?>
</a>
</li>
<?php } ?>
</ul>
<?php
} else {
print "<h1>".__('User not found')."</h1>";
print_error(__('User not found'));
}
}
function editSave() {
$login = trim(clean($_REQUEST["login"]));
$uid = clean($_REQUEST["id"]);
$access_level = (int) clean($_REQUEST["access_level"]);
$email = trim(clean($_REQUEST["email"]));
$id = (int)$_REQUEST['id'];
$password = clean($_REQUEST["password"]);
$user = ORM::for_table('ttrss_users')->find_one($id);
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 = "";
}
if ($user) {
$login = clean($_REQUEST["login"]);
if ($id == 1) $login = "admin";
if (!$login) return;
$sth = $this->pdo->prepare("UPDATE ttrss_users SET $pass_query_part login = ?,
access_level = ?, email = ?, otp_enabled = false WHERE id = ?");
$sth->execute([$login, $access_level, $email, $uid]);
$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"] ?? "");
// force new OTP secret when next enabled
if (Config::get_schema_version() >= 143 && !$user->otp_enabled) {
$user->otp_secret = null;
}
$user->save();
}
if ($password) {
UserHelper::reset_password($id, false, $password);
}
}
function remove() {
@ -230,238 +150,146 @@ class Pref_Users extends Handler_Protected {
}
function add() {
$login = trim(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);
$login = clean($_REQUEST["login"]);
if (!$login) return; // no blank usernames
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE
login = ?");
$sth->execute([$login]);
if (!$sth->fetch()) {
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 (?, ?, 0, null, NOW(), ?)");
$sth->execute([$login, $pwd_hash, $salt]);
$new_password = make_password();
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE
login = ? AND pwd_hash = ?");
$sth->execute([$login, $pwd_hash]);
$user = ORM::for_table('ttrss_users')->create();
if ($row = $sth->fetch()) {
$new_uid = $row['id'];
$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, $tmp_user_pwd);
$this->initialize_user($new_uid);
$login, $new_password);
} else {
print T_sprintf("Could not create user %s", $login);
}
} else {
print T_sprintf("User %s already exists.", $login);
}
}
static function resetUserPassword($uid, $format_output = false) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT login FROM ttrss_users WHERE id = ?");
$sth->execute([$uid]);
if ($row = $sth->fetch()) {
$login = $row["login"];
$new_salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$tmp_user_pwd = make_password();
$pwd_hash = encrypt_password($tmp_user_pwd, $new_salt, true);
$sth = $pdo->prepare("UPDATE ttrss_users
SET pwd_hash = ?, salt = ?, otp_enabled = false
WHERE id = ?");
$sth->execute([$pwd_hash, $new_salt, $uid]);
$message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>");
if ($format_output)
print_notice($message);
else
print $message;
}
}
function resetPass() {
$uid = clean($_REQUEST["id"]);
self::resetUserPassword($uid);
UserHelper::reset_password(clean($_REQUEST["id"]));
}
function index() {
global $access_level_names;
print "<div dojoType='dijit.layout.BorderContainer' gutters='false'>";
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>";
print "<div dojoType='fox.Toolbar'>";
$user_search = trim(clean($_REQUEST["search"]));
$user_search = clean($_REQUEST["search"] ?? "");
if (array_key_exists("search", $_REQUEST)) {
$_SESSION["prefs_user_search"] = $user_search;
} else {
$user_search = $_SESSION["prefs_user_search"];
$user_search = ($_SESSION["prefs_user_search"] ?? "");
}
print "<div style='float : right; padding-right : 4px;'>
<input dojoType='dijit.form.TextBox' id='user_search' size='20' type='search'
value=\"$user_search\">
<button dojoType='dijit.form.Button' onclick='Users.reload()'>".
__('Search')."</button>
</div>";
$sort = clean($_REQUEST["sort"]);
$sort = clean($_REQUEST["sort"] ?? "");
if (!$sort || $sort == "undefined") {
$sort = "login";
}
print "<div dojoType='fox.form.DropDownButton'>".
"<span>" . __('Select')."</span>";
print "<div dojoType='dijit.Menu' style='display: none'>";
print "<div onclick=\"Tables.select('prefUserList', true)\"
dojoType='dijit.MenuItem'>".__('All')."</div>";
print "<div onclick=\"Tables.select('prefUserList', false)\"
dojoType='dijit.MenuItem'>".__('None')."</div>";
print "</div></div>";
print "<button dojoType='dijit.form.Button' onclick='Users.add()'>".__('Create user')."</button>";
print "
<button dojoType='dijit.form.Button' onclick='Users.editSelected()'>".
__('Edit')."</button dojoType=\"dijit.form.Button\">
<button dojoType='dijit.form.Button' onclick='Users.removeSelected()'>".
__('Remove')."</button dojoType=\"dijit.form.Button\">
<button dojoType='dijit.form.Button' onclick='Users.resetSelected()'>".
__('Reset password')."</button dojoType=\"dijit.form.Button\">";
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION,
"hook_prefs_tab_section", "prefUsersToolbar");
print "</div>"; #toolbar
print "</div>"; #pane
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>";
$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";
$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%" : ""]);
print "<p><table width='100%' cellspacing='0' class='prefUserList' id='prefUserList'>";
print "<tr class='title'>
<td align='center' width='5%'>&nbsp;</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>";
$lnum = 0;
while ($line = $sth->fetch()) {
$uid = $line["id"];
print "<tr data-row-id='$uid' onclick='Users.edit($uid)'>";
$line["login"] = htmlspecialchars($line["login"]);
$line["created"] = TimeHelper::make_local_datetime($line["created"], false);
$line["last_login"] = TimeHelper::make_local_datetime($line["last_login"], false);
print "<td align='center'><input onclick='Tables.onRowChecked(this); event.stopPropagation();'
dojoType='dijit.form.CheckBox' type='checkbox'></td>";
print "<td title='".__('Click to edit')."'><i class='material-icons'>person</i> " . $line["login"] . "</td>";
print "<td>" . $access_level_names[$line["access_level"]] . "</td>";
print "<td>" . $line["num_feeds"] . "</td>";
print "<td>" . $line["created"] . "</td>";
print "<td>" . $line["last_login"] . "</td>";
print "</tr>";
++$lnum;
}
print "</table>";
if ($lnum == 0) {
if (!$user_search) {
print_warning(__('No users defined.'));
} else {
print_warning(__('No matching users found.'));
}
}
print "</div>"; #pane
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB,
"hook_prefs_tab", "prefUsers");
print "</div>"; #container
}
function validate_field($string, $allowed, $default = "") {
if (in_array($string, $allowed))
return $string;
else
return $default;
}
// this is called after user is created to initialize default feeds, labels
// or whatever else
// user preferences are checked on every login, not here
static function initialize_user($uid) {
$pdo = Db::pdo();
$sth = $pdo->prepare("insert into ttrss_feeds (owner_uid,title,feed_url)
values (?, 'Tiny Tiny RSS: Forum',
'https://tt-rss.org/forum/rss.php')");
$sth->execute([$uid]);
}
static function logout_user() {
@session_destroy();
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time()-42000, '/');
}
session_commit();
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
<div dojoType='fox.Toolbar'>
<div style='float : right'>
<input dojoType='dijit.form.TextBox' id='user_search' size='20' type='search'
value="<?= htmlspecialchars($user_search) ?>">
<button dojoType='dijit.form.Button' onclick='Users.reload()'>
<?= __('Search') ?>
</button>
</div>
<div dojoType='fox.form.DropDownButton'>
<span><?= __('Select') ?></span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="Tables.select('users-list', true)"
dojoType='dijit.MenuItem'><?= __('All') ?></div>
<div onclick="Tables.select('users-list', false)"
dojoType='dijit.MenuItem'><?= __('None') ?></div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='Users.add()'>
<?= __('Create user') ?>
</button>
<button dojoType='dijit.form.Button' onclick='Users.removeSelected()'>
<?= __('Remove') ?>
</button>
<button dojoType='dijit.form.Button' onclick='Users.resetSelected()'>
<?= __('Reset password') ?>
</button>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar") ?>
</div>
</div>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
<table width='100%' class='users-list' id='users-list'>
<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
$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 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>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers") ?>
</div>
<?php
}
}

@ -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]);
}
}

@ -1,110 +1,41 @@
<?php
class RPC extends Handler_Protected {
function csrf_ignore($method) {
$csrf_ignored = array("completelabels", "saveprofile");
/*function csrf_ignore($method) {
$csrf_ignored = array("completelabels");
return array_search($method, $csrf_ignored) !== false;
}
function setprofile() {
$_SESSION["profile"] = (int) clean($_REQUEST["id"]);
// default value
if (!$_SESSION["profile"]) $_SESSION["profile"] = null;
}
function remprofiles() {
$ids = explode(",", trim(clean($_REQUEST["ids"])));
foreach ($ids as $id) {
if ($_SESSION["profile"] != $id) {
$sth = $this->pdo->prepare("DELETE FROM ttrss_settings_profiles WHERE id = ? AND
owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
}
}
}
}*/
// Silent
function addprofile() {
$title = trim(clean($_REQUEST["title"]));
private function _translations_as_array() {
if ($title) {
$this->pdo->beginTransaction();
global $text_domains;
$sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles
WHERE title = ? AND owner_uid = ?");
$sth->execute([$title, $_SESSION['uid']]);
if (!$sth->fetch()) {
$sth = $this->pdo->prepare("INSERT INTO ttrss_settings_profiles (title, owner_uid)
VALUES (?, ?)");
$rv = [];
$sth->execute([$title, $_SESSION['uid']]);
foreach (array_keys($text_domains) as $domain) {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles WHERE
title = ? AND owner_uid = ?");
$sth->execute([$title, $_SESSION['uid']]);
/** @var gettext_reader $l10n */
$l10n = _get_reader($domain);
if ($row = $sth->fetch()) {
$profile_id = $row['id'];
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);
if ($profile_id) {
Pref_Prefs::initialize_user_prefs($_SESSION["uid"], $profile_id);
$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;
}
}
}
$this->pdo->commit();
}
}
function saveprofile() {
$id = clean($_REQUEST["id"]);
$title = trim(clean($_REQUEST["value"]));
if ($id == 0) {
print __("Default profile");
return;
}
if ($title) {
$sth = $this->pdo->prepare("UPDATE ttrss_settings_profiles
SET title = ? WHERE id = ? AND
owner_uid = ?");
$sth->execute([$title, $id, $_SESSION['uid']]);
print $title;
}
}
// Silent
function remarchive() {
$ids = explode(",", clean($_REQUEST["ids"]));
$sth = $this->pdo->prepare("DELETE FROM ttrss_archived_feeds WHERE
(SELECT COUNT(*) FROM ttrss_user_entries
WHERE orig_feed_id = :id) = 0 AND
id = :id AND owner_uid = :uid");
foreach ($ids as $id) {
$sth->execute([":id" => $id, ":uid" => $_SESSION['uid']]);
}
return $rv;
}
function addfeed() {
$feed = clean($_REQUEST['feed']);
$cat = clean($_REQUEST['cat']);
$need_auth = isset($_REQUEST['need_auth']);
$login = $need_auth ? clean($_REQUEST['login']) : '';
$pass = $need_auth ? trim(clean($_REQUEST['pass'])) : '';
$rc = Feeds::subscribe_to_feed($feed, $cat, $login, $pass);
print json_encode(array("result" => $rc));
}
function togglepref() {
$key = clean($_REQUEST["key"]);
@ -119,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));
}
@ -145,118 +76,11 @@ class RPC extends Handler_Protected {
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute(array_merge($ids, [$_SESSION['uid']]));
Article::purge_orphans();
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function unarchive() {
$ids = explode(",", clean($_REQUEST["ids"]));
foreach ($ids as $id) {
$this->pdo->beginTransaction();
$sth = $this->pdo->prepare("SELECT feed_url,site_url,title FROM ttrss_archived_feeds
WHERE id = (SELECT orig_feed_id FROM ttrss_user_entries WHERE ref_id = :id
AND owner_uid = :uid) AND owner_uid = :uid");
$sth->execute([":uid" => $_SESSION['uid'], ":id" => $id]);
if ($row = $sth->fetch()) {
$feed_url = $row['feed_url'];
$site_url = $row['site_url'];
$title = $row['title'];
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE feed_url = ?
AND owner_uid = ?");
$sth->execute([$feed_url, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$feed_id = $row["id"];
} else {
if (!$title) $title = '[Unknown]';
$sth = $this->pdo->prepare("INSERT INTO ttrss_feeds
(owner_uid,feed_url,site_url,title,cat_id,auth_login,auth_pass,update_method)
VALUES (?, ?, ?, ?, NULL, '', '', 0)");
$sth->execute([$_SESSION['uid'], $feed_url, $site_url, $title]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE feed_url = ?
AND owner_uid = ?");
$sth->execute([$feed_url, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$feed_id = $row['id'];
}
}
if ($feed_id) {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
SET feed_id = ?, orig_feed_id = NULL
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $id, $_SESSION['uid']]);
}
}
$this->pdo->commit();
}
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function archive() {
$ids = explode(",", clean($_REQUEST["ids"]));
foreach ($ids as $id) {
$this->archive_article($id, $_SESSION["uid"]);
}
Article::_purge_orphans();
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
private function archive_article($id, $owner_uid) {
$this->pdo->beginTransaction();
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
$sth = $this->pdo->prepare("SELECT feed_id FROM ttrss_user_entries
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $owner_uid]);
if ($row = $sth->fetch()) {
/* prepare the archived table */
$feed_id = (int) $row['feed_id'];
if ($feed_id) {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_archived_feeds
WHERE id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $owner_uid]);
if ($row = $sth->fetch()) {
$new_feed_id = $row['id'];
} else {
$row = $this->pdo->query("SELECT MAX(id) AS id FROM ttrss_archived_feeds")->fetch();
$new_feed_id = (int)$row['id'] + 1;
$sth = $this->pdo->prepare("INSERT INTO ttrss_archived_feeds
(id, owner_uid, title, feed_url, site_url, created)
SELECT ?, owner_uid, title, feed_url, site_url, NOW() from ttrss_feeds
WHERE id = ?");
$sth->execute([$new_feed_id, $feed_id]);
}
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
SET orig_feed_id = ?, feed_id = NULL
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$new_feed_id, $id, $owner_uid]);
}
}
$this->pdo->commit();
}
function publ() {
$pub = clean($_REQUEST["pub"]);
$id = clean($_REQUEST["id"]);
@ -270,67 +94,116 @@ class RPC extends Handler_Protected {
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function getRuntimeInfo() {
$reply = [
'runtime-info' => $this->_make_runtime_info()
];
print json_encode($reply);
}
function getAllCounters() {
@$seq = (int) $_REQUEST['seq'];
$feed_id_count = (int)$_REQUEST["feed_id_count"];
$label_id_count = (int)$_REQUEST["label_id_count"];
// it seems impossible to distinguish empty array [] from a null - both become unset in $_REQUEST
// so, count is >= 0 means we had an array, -1 means null
// we need null because it means "return all counters"; [] would return nothing
if ($feed_id_count == -1)
$feed_ids = null;
else
$feed_ids = array_map("intval", clean($_REQUEST["feed_ids"] ?? []));
if ($label_id_count == -1)
$label_ids = null;
else
$label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? []));
$counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ?
Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
$reply = [
'counters' => Counters::getAllCounters(),
'counters' => $counters,
'seq' => $seq
];
if ($seq % 2 == 0)
$reply['runtime-info'] = $this->make_runtime_info();
print json_encode($reply);
}
/* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */
function catchupSelected() {
$ids = explode(",", clean($_REQUEST["ids"]));
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
Article::catchupArticlesById($ids, $cmode);
if (count($ids) > 0)
Article::_catchup_by_id($ids, $cmode);
print json_encode(array("message" => "UPDATE_COUNTERS", "ids" => $ids));
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function markSelected() {
$ids = explode(",", clean($_REQUEST["ids"]));
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
$this->markArticlesById($ids, $cmode);
if (count($ids) > 0)
$this->markArticlesById($ids, $cmode);
print json_encode(array("message" => "UPDATE_COUNTERS"));
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function publishSelected() {
$ids = explode(",", clean($_REQUEST["ids"]));
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
$this->publishArticlesById($ids, $cmode);
if (count($ids) > 0)
$this->publishArticlesById($ids, $cmode);
print json_encode(array("message" => "UPDATE_COUNTERS"));
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function sanityCheck() {
$_SESSION["hasAudio"] = clean($_REQUEST["hasAudio"]) === "true";
$_SESSION["hasSandbox"] = clean($_REQUEST["hasSandbox"]) === "true";
$_SESSION["hasMp3"] = clean($_REQUEST["hasMp3"]) === "true";
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
$reply = array();
$client_location = $_REQUEST["clientLocation"];
$error = Errors::E_SUCCESS;
$error_params = [];
$reply['error'] = sanity_check();
$client_scheme = parse_url($client_location, PHP_URL_SCHEME);
$server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME);
if ($reply['error']['code'] == 0) {
$reply['init-params'] = $this->make_init_params();
$reply['runtime-info'] = $this->make_runtime_info();
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();
}
print json_encode($reply);
if ($error == Errors::E_SUCCESS) {
$reply = [];
$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, $error_params);
}
}
function completeLabels() {
/*function completeLabels() {
$search = clean($_REQUEST["search"]);
$sth = $this->pdo->prepare("SELECT DISTINCT caption FROM
@ -345,118 +218,69 @@ class RPC extends Handler_Protected {
print "<li>" . $line["caption"] . "</li>";
}
print "</ul>";
}
// Silent
function massSubscribe() {
$payload = json_decode(clean($_REQUEST["payload"]), false);
$mode = clean($_REQUEST["mode"]);
if (!$payload || !is_array($payload)) return;
if ($mode == 1) {
foreach ($payload as $feed) {
$title = $feed[0];
$feed_url = $feed[1];
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
feed_url = ? AND owner_uid = ?");
$sth->execute([$feed_url, $_SESSION['uid']]);
if (!$sth->fetch()) {
$sth = $this->pdo->prepare("INSERT INTO ttrss_feeds
(owner_uid,feed_url,title,cat_id,site_url)
VALUES (?, ?, ?, NULL, '')");
$sth->execute([$_SESSION['uid'], $feed_url, $title]);
}
}
} else if ($mode == 2) {
// feed archive
foreach ($payload as $id) {
$sth = $this->pdo->prepare("SELECT * FROM ttrss_archived_feeds
WHERE id = ? AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$site_url = $row['site_url'];
$feed_url = $row['feed_url'];
$title = $row['title'];
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
feed_url = ? AND owner_uid = ?");
$sth->execute([$feed_url, $_SESSION['uid']]);
if (!$sth->fetch()) {
$sth = $this->pdo->prepare("INSERT INTO ttrss_feeds
(owner_uid,feed_url,title,cat_id,site_url)
VALUES (?, ?, ?, NULL, ?)");
$sth->execute([$_SESSION['uid'], $feed_url, $title, $site_url]);
}
}
}
}
}
}*/
function catchupFeed() {
$feed_id = clean($_REQUEST['feed_id']);
$is_cat = clean($_REQUEST['is_cat']) == "true";
$mode = clean($_REQUEST['mode']);
$mode = clean($_REQUEST['mode'] ?? '');
$search_query = clean($_REQUEST['search_query']);
$search_lang = clean($_REQUEST['search_lang']);
Feeds::catchup_feed($feed_id, $is_cat, false, $mode, [$search_query, $search_lang]);
Feeds::_catchup($feed_id, $is_cat, false, $mode, [$search_query, $search_lang]);
// return counters here synchronously so that frontend can figure out next unread feed properly
print json_encode(['counters' => Counters::getAllCounters()]);
print json_encode(['counters' => Counters::get_all()]);
//print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function setpanelmode() {
function setWidescreen() {
$wide = (int) clean($_REQUEST["wide"]);
setcookie("ttrss_widescreen", $wide,
time() + COOKIE_LIFETIME_LONG);
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 (DB_TYPE == "pgsql") {
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
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
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
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
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 (DB_TYPE == "pgsql") {
$updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < NOW() - INTERVAL '5 minutes')";
if (Config::get(Config::DB_TYPE) == "pgsql") {
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')";
} else {
$updstart_thresh_qpart = "AND (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();
@ -464,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;
@ -499,7 +323,7 @@ class RPC extends Handler_Protected {
}
// Purge orphans and cleanup tags
Article::purge_orphans();
Article::_purge_orphans();
//cleanup_tags(14, 50000);
if ($num_updated > 0) {
@ -557,49 +381,29 @@ class RPC extends Handler_Protected {
$sth->execute(array_merge($ids, [$_SESSION['uid']]));
}
function getlinktitlebyid() {
$id = clean($_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(array("link" => $link, "title" => $title));
} else {
echo json_encode(array("error" => "ARTICLE_NOT_FOUND"));
}
}
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"));
} else {
echo json_encode(array("error" => "MESSAGE_NOT_FOUND"));
}
}
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 (defined('CHECK_FOR_UPDATES') && CHECK_FOR_UPDATES && $_SESSION["access_level"] >= 10 && $git_timestamp) {
if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= 10 && $git_timestamp) {
$content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
if ($content) {
@ -609,37 +413,41 @@ 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);
}
$params["check_for_updates"] = CHECK_FOR_UPDATES;
$params["icons_url"] = ICONS_URL;
$params["cookie_lifetime"] = 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["label_base_index"] = (int) LABEL_BASE_INDEX;
$theme = get_pref( "USER_CSS_THEME", false, false);
$params["safe_mode"] = !empty($_SESSION["safe_mode"]);
$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(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(Prefs::USER_CSS_THEME);
$params["theme"] = theme_exists($theme) ? $theme : "";
$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
@ -657,19 +465,17 @@ 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"];
$params['simple_update'] = defined('SIMPLE_UPDATE_MODE') && SIMPLE_UPDATE_MODE;
$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["labels"] = Labels::get_all_labels($_SESSION["uid"]);
$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;
}
@ -678,13 +484,15 @@ class RPC extends Handler_Protected {
if (file_exists($filename)) {
$ext = pathinfo($filename, PATHINFO_EXTENSION);
return "data:image/$ext;base64," . base64_encode(file_get_contents($filename));
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();
@ -699,17 +507,23 @@ 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["labels"] = Labels::get_all_labels($_SESSION["uid"]);
$data['cdm_expanded'] = get_pref(Prefs::CDM_EXPANDED);
$data["labels"] = Labels::get_all($_SESSION["uid"]);
if (LOG_DESTINATION == 'sql' && $_SESSION['access_level'] >= 10) {
if (DB_TYPE == 'pgsql') {
if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= 10) {
if (Config::get(Config::DB_TYPE) == 'pgsql') {
$log_interval = "created_at > NOW() - interval '1 hour'";
} else {
$log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)";
}
$sth = $pdo->prepare("SELECT COUNT(id) AS cid FROM ttrss_error_log WHERE errno != 1024 AND $log_interval");
$sth = $pdo->prepare("SELECT COUNT(id) AS cid
FROM ttrss_error_log
WHERE
errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
$log_interval AND
errstr NOT LIKE '%Returning bool from comparison function is deprecated%' AND
errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'");
$sth->execute();
if ($row = $sth->fetch()) {
@ -717,13 +531,13 @@ class RPC extends Handler_Protected {
}
}
if (file_exists(LOCK_DIRECTORY . "/update_daemon.lock")) {
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.lock")) {
$data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock");
if (time() - $_SESSION["daemon_stamp_check"] > 30) {
if (time() - ($_SESSION["daemon_stamp_check"] ?? 0) > 30) {
$stamp = (int) @file_get_contents(LOCK_DIRECTORY . "/update_daemon.stamp");
$stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp");
if ($stamp) {
$stamp_delta = time() - $stamp;
@ -751,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"),
@ -795,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"),
@ -807,7 +624,6 @@ class RPC extends Handler_Protected {
"goto_marked" => __("Starred"),
"goto_published" => __("Published"),
"goto_read" => __("Recently read"),
"goto_tagcloud" => __("Tag cloud"),
"goto_prefs" => __("Preferences")),
__("Other") => array(
"create_label" => __("Create label"),
@ -816,9 +632,11 @@ class RPC extends Handler_Protected {
"help_dialog" => __("Show help dialog"))
);
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_INFO) as $plugin) {
$hotkeys = $plugin->hook_hotkey_info($hotkeys);
}
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_INFO,
function ($result) use (&$hotkeys) {
$hotkeys = $result;
},
$hotkeys);
return $hotkeys;
}
@ -828,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",
@ -854,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",
@ -867,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",
@ -878,7 +700,6 @@ class RPC extends Handler_Protected {
"g s" => "goto_marked",
"g p" => "goto_published",
"g r" => "goto_read",
"g t" => "goto_tagcloud",
"g P" => "goto_prefs",
"r" => "select_article_cursor",
"c l" => "create_label",
@ -887,14 +708,16 @@ class RPC extends Handler_Protected {
"?" => "help_dialog",
);
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_MAP) as $plugin) {
$hotkeys = $plugin->hook_hotkey_map($hotkeys);
}
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_MAP,
function ($result) use (&$hotkeys) {
$hotkeys = $result;
},
$hotkeys);
$prefixes = array();
foreach (array_keys($hotkeys) as $hotkey) {
$pair = explode(" ", $hotkey, 2);
$pair = explode(" ", (string)$hotkey, 2);
if (count($pair) > 1 && !in_array($pair[0], $prefixes)) {
array_push($prefixes, $pair[0]);
@ -904,4 +727,73 @@ class RPC extends Handler_Protected {
return array($prefixes, $hotkeys);
}
function hotkeyHelp() {
$info = self::get_hotkeys_info();
$imap = self::get_hotkeys_map();
$omap = array();
foreach ($imap[1] as $sequence => $action) {
if (!isset($omap[$action])) $omap[$action] = array();
array_push($omap[$action], $sequence);
}
?>
<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>
<?php
foreach ($info as $section => $hotkeys) {
?>
<li><h3><?= $section ?></h3></li>
<?php
foreach ($hotkeys as $action => $description) {
if (!empty($omap[$action])) {
foreach ($omap[$action] as $sequence) {
if (strpos($sequence, "|") !== false) {
$sequence = substr($sequence,
strpos($sequence, "|")+1,
strlen($sequence));
} else {
$keys = explode(" ", $sequence);
for ($i = 0; $i < count($keys); $i++) {
if (strlen($keys[$i]) > 1) {
$tmp = '';
foreach (str_split($keys[$i]) as $c) {
switch ($c) {
case '*':
$tmp .= __('Shift') . '+';
break;
case '^':
$tmp .= __('Ctrl') . '+';
break;
default:
$tmp .= $c;
}
}
$keys[$i] = $tmp;
}
}
$sequence = join(" ", $keys);
}
?>
<li>
<div class='hk'><code><?= $sequence ?></code></div>
<div class='desc'><?= $description ?></div>
</li>
<?php
}
}
}
}
?>
</ul>
<footer class='text-center'>
<?= \Controls\submit_tag(__('Close this window')) ?>
</footer>
<?php
}
}

File diff suppressed because it is too large Load Diff

@ -41,20 +41,22 @@ class Sanitizer {
}
public static function iframe_whitelisted($entry) {
@$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST);
$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST);
if ($src) {
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_IFRAME_WHITELISTED) as $plugin) {
if ($plugin->hook_iframe_whitelisted($src))
return true;
}
}
if (!empty($src))
return PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_IFRAME_WHITELISTED, true, $src);
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) $owner = $_SESSION["uid"];
if (!$owner && isset($_SESSION["uid"]))
$owner = $_SESSION["uid"];
$res = trim($str); if (!$res) return '';
@ -62,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");
@ -78,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') {
@ -90,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"]) {
($owner && get_pref(Prefs::STRIP_IMAGES, $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) {
$p = $doc->createElement('p');
@ -127,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")));
@ -147,20 +156,21 @@ class Sanitizer {
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time',
'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' );
if ($_SESSION['hasSandbox']) $allowed_elements[] = 'iframe';
if ($_SESSION['hasSandbox'] ?? false) $allowed_elements[] = 'iframe';
$disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow');
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SANITIZE) as $plugin) {
$retval = $plugin->hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id);
if (is_array($retval)) {
$doc = $retval[0];
$allowed_elements = $retval[1];
$disallowed_attributes = $retval[2];
} else {
$doc = $retval;
}
}
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SANITIZE,
function ($result) use (&$doc, &$allowed_elements, &$disallowed_attributes) {
if (is_array($result)) {
$doc = $result[0];
$allowed_elements = $result[1];
$disallowed_attributes = $result[2];
} else {
$doc = $result;
}
},
$doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id);
$doc->removeChild($doc->firstChild); //remove doctype
$doc = self::strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes);
@ -186,16 +196,16 @@ class Sanitizer {
$text = $child->textContent;
while (($pos = mb_stripos($text, $word)) !== false) {
$fragment->appendChild(new DomText(mb_substr($text, 0, $pos)));
$word = mb_substr($text, $pos, mb_strlen($word));
$fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos)));
$word = mb_substr($text, (int)$pos, mb_strlen($word));
$highlight = $doc->createElement('span');
$highlight->appendChild(new DomText($word));
$highlight->appendChild(new DOMText($word));
$highlight->setAttribute('class', 'highlight');
$fragment->appendChild($highlight);
$text = mb_substr($text, $pos + mb_strlen($word));
}
if (!empty($text)) $fragment->appendChild(new DomText($text));
if (!empty($text)) $fragment->appendChild(new DOMText($text));
$child->parentNode->replaceChild($fragment, $child);
}

@ -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') {
@ -49,7 +49,7 @@ class TimeHelper {
$tz_offset = $user_tz->getOffset($dt);
} else {
$tz_offset = (int) -$_SESSION["clientTzOffset"];
$tz_offset = (int) -($_SESSION["clientTzOffset"] ?? 0);
}
$user_timestamp = $dt->format('U') + $tz_offset;
@ -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);
}
@ -82,7 +82,8 @@ class TimeHelper {
}
$dt = new DateTime(date('Y-m-d H:i:s', $timestamp), $source_tz);
return $dt->format('U') + $dest_tz->getOffset($dt);
return (int)$dt->format('U') + $dest_tz->getOffset($dt);
}
}

@ -1,8 +1,24 @@
<?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'] . $parts['path'];
$tmp = $parts['scheme'] . "://" . $parts['host'];
if (isset($parts['path'])) $tmp .= $parts['path'];
if (isset($parts['query'])) $tmp .= '?' . $parts['query'];
if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment'];
@ -10,36 +26,55 @@ 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 ($rel_parts['host'] && $rel_parts['scheme']) {
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'] = $base_parts['host'];
$rel_parts['scheme'] = $base_parts['scheme'];
$rel_parts['host'] = $parts['host'];
$rel_parts['scheme'] = $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)
$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
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']);
}
return self::validate(self::build_url($rel_parts));
}
@ -58,7 +93,7 @@ class UrlHelper {
// this isn't really necessary because filter_var(... FILTER_VALIDATE_URL) requires host and scheme
// as per https://php.watch/versions/7.3/filter-var-flag-deprecation but it might save time
if (!$tokens['host'])
if (empty($tokens['host']))
return false;
if (!in_array(strtolower($tokens['scheme']), ['http', 'https']))
@ -79,7 +114,7 @@ class UrlHelper {
// (used for validation only, we actually request the original URL, in case of urlencode breaking it)
$tokens_filter_var = $tokens;
if ($tokens['path']) {
if ($tokens['path'] ?? false) {
$tokens_filter_var['path'] = implode("/",
array_map("rawurlencode",
array_map("rawurldecode",
@ -93,7 +128,7 @@ class UrlHelper {
return false;
if ($extended_filtering) {
if (!in_array($tokens['port'], [80, 443, '']))
if (!in_array($tokens['port'] ?? '', [80, 443, '']))
return false;
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0)
@ -120,9 +155,9 @@ class UrlHelper {
'protocol_version'=> 1.1)
);
if (defined('_HTTP_PROXY')) {
if (Config::get(Config::HTTP_PROXY)) {
$context_options['http']['request_fulluri'] = true;
$context_options['http']['proxy'] = _HTTP_PROXY;
$context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY);
}
$context = stream_context_create($context_options);
@ -155,27 +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;
global $fetch_domain_hits;
$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 = "";
if (!is_array($fetch_domain_hits))
$fetch_domain_hits = [];
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)) {
@ -210,7 +232,7 @@ class UrlHelper {
$last_modified = isset($options["last_modified"]) ? $options["last_modified"] : "";
$useragent = isset($options["useragent"]) ? $options["useragent"] : false;
$followlocation = isset($options["followlocation"]) ? $options["followlocation"] : true;
$max_size = isset($options["max_size"]) ? $options["max_size"] : MAX_DOWNLOAD_FILE_SIZE; // in bytes
$max_size = isset($options["max_size"]) ? $options["max_size"] : Config::get(Config::MAX_DOWNLOAD_FILE_SIZE); // in bytes
$http_accept = isset($options["http_accept"]) ? $options["http_accept"] : false;
$http_referrer = isset($options["http_referrer"]) ? $options["http_referrer"] : false;
@ -220,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;
}
@ -228,23 +250,18 @@ 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;
}
$fetch_domain_hits[$url_host] += 1;
/*if ($fetch_domain_hits[$url_host] > MAX_FETCH_REQUESTS_PER_HOST) {
user_error("Exceeded fetch request quota for $url_host: " . $fetch_domain_hits[$url_host], E_USER_WARNING);
#return false;
}*/
if (function_exists('curl_init') && !ini_get("open_basedir")) {
if (!defined('NO_CURL') && function_exists('curl_init') && !ini_get("open_basedir")) {
$fetch_curl_used = true;
self::$fetch_curl_used = true;
$ch = curl_init($url);
if (!$ch) return false;
$curl_http_headers = [];
if ($last_modified && !$post_query)
@ -256,16 +273,15 @@ class UrlHelper {
if (count($curl_http_headers) > 0)
curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_http_headers);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : FILE_FETCH_TIMEOUT);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT));
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : Config::get(Config::FILE_FETCH_TIMEOUT));
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, !ini_get("open_basedir") && $followlocation);
curl_setopt($ch, CURLOPT_MAXREDIRS, 20);
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
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)
@ -277,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;
});
}
@ -289,8 +310,8 @@ class UrlHelper {
curl_setopt($ch, CURLOPT_COOKIEJAR, "/dev/null");
}
if (defined('_HTTP_PROXY')) {
curl_setopt($ch, CURLOPT_PROXY, _HTTP_PROXY);
if (Config::get(Config::HTTP_PROXY)) {
curl_setopt($ch, CURLOPT_PROXY, Config::get(Config::HTTP_PROXY));
}
if ($post_query) {
@ -312,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;
}
}
@ -328,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;
}
@ -369,7 +390,7 @@ class UrlHelper {
$is_gzipped = RSSUtils::is_gzipped($contents);
if ($is_gzipped) {
if ($is_gzipped && is_string($contents)) {
$tmp = @gzdecode($contents);
if ($tmp) $contents = $tmp;
@ -378,7 +399,7 @@ class UrlHelper {
return $contents;
} else {
$fetch_curl_used = false;
self::$fetch_curl_used = false;
if ($login && $pass){
$url_parts = array();
@ -401,7 +422,7 @@ class UrlHelper {
),
'method' => 'GET',
'ignore_errors' => true,
'timeout' => $timeout ? $timeout : FILE_FETCH_TIMEOUT,
'timeout' => $timeout ? $timeout : Config::get(Config::FILE_FETCH_TIMEOUT),
'protocol_version'=> 1.1)
);
@ -414,73 +435,71 @@ class UrlHelper {
if ($http_referrer)
array_push($context_options['http']['header'], "Referer: $http_referrer");
if (defined('_HTTP_PROXY')) {
if (Config::get(Config::HTTP_PROXY)) {
$context_options['http']['request_fulluri'] = true;
$context_options['http']['proxy'] = _HTTP_PROXY;
$context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY);
}
$context = stream_context_create($context_options);
$old_error = error_get_last();
$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : 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;
}
$data = @file_get_contents($url, false, $context);
if (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header as $header) {
if (strstr($header, ": ") !== false) {
list ($key, $value) = explode(": ", $header);
$key = strtolower($key);
if ($key == 'content-type') {
$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;
} else if ($key == 'location') {
$fetch_effective_url = $value;
}
}
foreach ($http_response_header as $header) {
if (strstr($header, ": ") !== false) {
list ($key, $value) = explode(": ", $header);
if (substr(strtolower($header), 0, 7) == 'http/1.') {
$fetch_last_error_code = (int) substr($header, 9, 3);
$fetch_last_error = $header;
$key = strtolower($key);
if ($key == 'content-type') {
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') {
self::$fetch_last_modified = $value;
} else if ($key == 'location') {
self::$fetch_effective_url = $value;
}
}
if (substr(strtolower($header), 0, 7) == 'http/1.') {
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;
}
$is_gzipped = RSSUtils::is_gzipped($data);
if ($is_gzipped) {
if ($is_gzipped && $data) {
$tmp = @gzdecode($data);
if ($tmp) $data = $tmp;
@ -490,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,52 +1,67 @@
<?php
class UserHelper {
use OTPHP\TOTP;
static function authenticate($login, $password, $check_only = false, $service = false) {
class UserHelper {
if (!SINGLE_USER_MODE) {
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;
$auth_module = false;
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_AUTH_USER) as $plugin) {
$user_id = (int) $plugin->authenticate($login, $password, $service);
if ($user_id) {
$auth_module = strtolower(get_class($plugin));
break;
}
}
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_AUTH_USER,
function ($result, $plugin) use (&$user_id, &$auth_module) {
if ($result) {
$user_id = (int)$result;
$auth_module = strtolower(get_class($plugin));
return true;
}
},
$login, $password, $service);
if ($user_id && !$check_only) {
session_start();
session_regenerate_id(true);
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
$_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_regenerate_id(true);
$_SESSION["name"] = $row["login"];
$_SESSION["access_level"] = $row["access_level"];
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
$user = ORM::for_table('ttrss_users')->find_one($user_id);
$usth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
$usth->execute([$user_id]);
if ($user) {
$_SESSION["uid"] = $user_id;
$_SESSION["auth_module"] = $auth_module;
$_SESSION["name"] = $user->login;
$_SESSION["access_level"] = $user->access_level;
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
$_SESSION["ip_address"] = UserHelper::get_user_ip();
$_SESSION["pwd_hash"] = $user->pwd_hash;
$_SESSION["ip_address"] = $_SERVER["REMOTE_ADDR"];
$_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']);
$_SESSION["pwd_hash"] = $row["pwd_hash"];
$user->last_login = Db::NOW();
$user->save();
Pref_Prefs::initialize_user_prefs($_SESSION["uid"]);
return true;
}
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 {
@ -60,62 +75,64 @@ 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"] = $_SERVER["REMOTE_ADDR"];
Pref_Prefs::initialize_user_prefs($_SESSION["uid"]);
$_SESSION["ip_address"] = UserHelper::get_user_ip();
return true;
}
}
static function load_user_plugins($owner_uid, $pluginhost = false) {
static function load_user_plugins(int $owner_uid, PluginHost $pluginhost = null) {
if (!$pluginhost) $pluginhost = PluginHost::getInstance();
if ($owner_uid && SCHEMA_VERSION >= 100 && !$_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($plugins, PluginHost::KIND_USER, $owner_uid);
$pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid);
if (get_schema_version() > 100) {
/*if (get_schema_version() > 100) {
$pluginhost->load_data();
}
}*/
}
}
static function login_sequence() {
$pdo = Db::pdo();
if (SINGLE_USER_MODE) {
@session_start();
if (Config::get(Config::SINGLE_USER_MODE)) {
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
self::authenticate("admin", null);
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
} else {
if (!validate_session()) $_SESSION["uid"] = false;
if (!\Sessions\validate_session())
$_SESSION["uid"] = null;
if (!$_SESSION["uid"]) {
if (empty($_SESSION["uid"])) {
if (AUTH_AUTO_LOGIN && self::authenticate(null, null)) {
$_SESSION["ref_schema_version"] = get_schema_version(true);
if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
$_SESSION["ref_schema_version"] = get_schema_version();
} else {
self::authenticate(null, null, true);
}
if (!$_SESSION["uid"]) {
Pref_Users::logout_user();
if (empty($_SESSION["uid"])) {
UserHelper::logout();
Handler_Public::render_login_form();
Handler_Public::_render_login_form();
exit;
}
} 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();
}
@ -128,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'>";
@ -138,4 +155,208 @@ class UserHelper {
}
static function get_user_ip() {
foreach (["HTTP_X_REAL_IP", "REMOTE_ADDR"] as $hdr) {
if (isset($_SERVER[$hdr]))
return $_SERVER[$hdr];
}
return null;
}
static function get_login_by_id(int $id) {
$user = ORM::for_table('ttrss_users')
->find_one($id);
if ($user)
return $user->login;
else
return null;
}
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() {
if (session_status() === PHP_SESSION_ACTIVE)
session_destroy();
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time()-42000, '/');
}
session_commit();
}
static function get_salt() {
return substr(bin2hex(get_random_bytes(125)), 0, 250);
}
static function reset_password($uid, $format_output = false, $new_password = "") {
$user = ORM::for_table('ttrss_users')->find_one($uid);
$message = "";
if ($user) {
$login = $user->login;
$new_salt = self::get_salt();
$tmp_user_pwd = $new_password ? $new_password : make_password();
$pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]);
$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"
}

@ -1,170 +1,18 @@
<?php
// *******************************************
// *** Database configuration (important!) ***
// *******************************************
/*
This file can be used to customize global defaults if environment method is not available (i.e. no Docker).
define('DB_TYPE', '%DB_TYPE'); // pgsql or mysql
define('DB_HOST', '%DB_HOST');
define('DB_USER', '%DB_USER');
define('DB_NAME', '%DB_NAME');
define('DB_PASS', '%DB_PASS');
define('DB_PORT', '%DB_PORT'); // usually 5432 for PostgreSQL, 3306 for MySQL
Use the following syntax to override defaults (options are declared in classes/config.php, prefixed by TTRSS_):
define('MYSQL_CHARSET', 'UTF8');
// 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.
putenv('TTRSS_DB_HOST=myserver');
putenv('TTRSS_SELF_URL_PATH=http://example.com/tt-rss');
// ***********************************
// *** Basic settings (important!) ***
// ***********************************
Plugin-required constants also go here, using define():
define('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.org/tt-rss/
// The value should be a constant string literal. Please don't use
// PHP server variables here - you might introduce security
// issues on your install and cause hard to debug problems.
// If your tt-rss instance is behind a reverse proxy, use the external URL.
define('LEGACY_CONSTANT', 'value');
define('SINGLE_USER_MODE', false);
// 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).
etc.
define('SIMPLE_UPDATE_MODE', false);
// 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.
// Still, there are more robust (and recommended) updating methods
// available, you can read about them here: https://tt-rss.org/wiki/UpdatingFeeds
See this page for more information: https://tt-rss.org/wiki/GlobalConfig
*/
// *****************************
// *** Files and directories ***
// *****************************
define('PHP_EXECUTABLE', '/usr/bin/php');
// Path to PHP *COMMAND LINE* executable, used for various command-line tt-rss
// programs and update daemon. Do not try to use CGI binary here, it won't work.
// If you see HTTP headers being displayed while running tt-rss scripts,
// then most probably you are using the CGI binary. If you are unsure what to
// put in here, ask your hosting provider.
define('LOCK_DIRECTORY', 'lock');
// Directory for lockfiles, must be writable to the user you run
// daemon process or cronjobs under.
define('CACHE_DIR', 'cache');
// Local cache directory for RSS feed content.
define('ICONS_DIR', "feed-icons");
define('ICONS_URL', "feed-icons");
// Local and URL path to the directory, where feed favicons are stored.
// Unless you really know what you're doing, please keep those relative
// to tt-rss main directory.
// **********************
// *** Authentication ***
// **********************
// Please see PLUGINS below to configure various authentication modules.
define('AUTH_AUTO_CREATE', true);
// Allow authentication modules to auto-create users in tt-rss internal
// database when authenticated successfully.
define('AUTH_AUTO_LOGIN', true);
// Automatically login user on remote or other kind of externally supplied
// authentication, otherwise redirect to login form as normal.
// If set to true, users won't be able to set application language
// and settings profile.
// *********************
// *** Feed settings ***
// *********************
define('FORCE_ARTICLE_PURGE', 0);
// When this option is not 0, users ability to control feed purging
// intervals is disabled and all articles (which are not starred)
// older than this amount of days are purged.
// ***********************************
// *** Self-registrations by users ***
// ***********************************
define('ENABLE_REGISTRATION', false);
// Allow users to register themselves. Please be aware that allowing
// random people to access your tt-rss installation is a security risk
// and potentially might lead to data loss or server exploit. Disabled
// by default.
define('REG_NOTIFY_ADDRESS', 'user@your.domain.dom');
// Email address to send new user notifications to.
define('REG_MAX_USERS', 10);
// Maximum amount of users which will be allowed to register on this
// system. 0 - no limit.
// **********************************
// *** Cookies and login sessions ***
// **********************************
define('SESSION_COOKIE_LIFETIME', 86400);
// Default lifetime of a session (e.g. login) cookie. In seconds,
// 0 means cookie will be deleted when browser closes.
// *********************************
// *** Email and digest settings ***
// *********************************
// Tiny Tiny RSS sends mail via PHP mail() function, unless handled
// by a plugin.
// If you need SMTP support, take a look here:
// https://git.tt-rss.org/fox/ttrss-mailer-smtp
define('SMTP_FROM_NAME', 'Tiny Tiny RSS');
define('SMTP_FROM_ADDRESS', 'noreply@your.domain.dom');
// Name, address and subject for sending outgoing mail. This applies
// to password reset notifications, digest emails and any other mail.
define('DIGEST_SUBJECT', '[tt-rss] New headlines for last 24 hours');
// Subject line for email digests
// ***************************************
// *** Other settings (less important) ***
// ***************************************
define('CHECK_FOR_UPDATES', true);
// Check for updates automatically if running Git version
define('ENABLE_GZIP_OUTPUT', false);
// Selectively gzip output to improve wire performance. This requires
// PHP Zlib extension on the server.
// Enabling this can break tt-rss in several httpd/php configurations,
// if you experience weird errors and tt-rss failing to start, blank pages
// after login, or content encoding errors, disable it.
define('PLUGINS', 'auth_internal, note');
// Comma-separated list of plugins to load automatically for all users.
// System plugins have to be specified here. Please enable at least one
// authentication plugin here (auth_*).
// Users may enable other user plugins from Preferences/Plugins but may not
// disable plugins specified in this list.
// Disabling auth_internal in this list would automatically disable
// reset password link on the login form.
define('LOG_DESTINATION', 'sql');
// Error log destination to use. Possible values: sql (uses internal logging
// you can read in Preferences -> System), syslog - logs to system log.
// Setting this to blank uses PHP logging (usually to http server
// error.log).
// Note that feed updating daemons don't use this logging facility
// for normal output.
define('CONFIG_VERSION', 26);
// Expected config version. Please update this option in config.php
// if necessary (after migrating all new options from this file).
// vim:ft=php

@ -1,56 +0,0 @@
<?php
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
get_include_path());
require_once "functions.php";
$ERRORS[0] = "";
$ERRORS[1] = __("This program requires XmlHttpRequest " .
"to function properly. Your browser doesn't seem to support it.");
$ERRORS[2] = __("This program requires cookies " .
"to function properly. Your browser doesn't seem to support them.");
$ERRORS[3] = __("Backend sanity check failed.");
$ERRORS[4] = __("Frontend sanity check failed.");
$ERRORS[5] = __("Incorrect database schema version. &lt;a href='db-updater.php'&gt;Please update&lt;/a&gt;.");
$ERRORS[6] = __("Request not authorized.");
$ERRORS[7] = __("No operation to perform.");
$ERRORS[8] = __("Could not display feed: query failed. Please check label match syntax or local configuration.");
$ERRORS[8] = __("Denied. Your access level is insufficient to access this page.");
$ERRORS[9] = __("Configuration check failed");
$ERRORS[10] = __("Your version of MySQL is not currently supported. Please see official site for more information.");
$ERRORS[11] = "[This error is not returned by server]";
$ERRORS[12] = __("SQL escaping test failed, check your database and PHP configuration");
$ERRORS[13] = __("Method not found");
$ERRORS[14] = __("Plugin not found");
$ERRORS[15] = __("Encoding data as JSON failed");
if ($_REQUEST['mode'] == 'js') {
header("Content-Type: text/javascript; charset=UTF-8");
print "var ERRORS = [];\n";
foreach ($ERRORS as $id => $error) {
$error = preg_replace("/\n/", "", $error);
$error = preg_replace("/\"/", "\\\"", $error);
print "ERRORS[$id] = \"$error\";\n";
}
}
?>

@ -1,6 +1,7 @@
// Less configuration
const gulp = require('gulp');
const less = require('gulp-less');
const touch = require('gulp-touch-fd');
function swallowError(error) {
console.log(error.toString())
@ -11,14 +12,14 @@ 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) {
return f.base;
})
);
).pipe(touch());
cb();
});

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,26 +1,17 @@
<?php
require_once "functions.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're in tt-rss/include
$root_dir = dirname(__DIR__); // we were in tt-rss/include
// 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?)
// - 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/
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";
}
$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";

@ -1,4 +1,5 @@
<?php
namespace Colors;
if (file_exists("lib/floIcon.php")) {
require_once "lib/floIcon.php";
@ -216,6 +217,7 @@ function _color_unpack($hex, $normalize = false) {
### Convert an RGB triplet to a hex color.
function _color_pack($rgb, $normalize = false) {
$out = 0;
foreach ($rgb as $k => $v) {
$out |= (($v * ($normalize ? 255 : 1)) << (16 - $k * 8));
}return '#'. str_pad(dechex($out), 6, 0, STR_PAD_LEFT);
@ -296,7 +298,7 @@ function hsl2rgb($arr) {
if (class_exists("floIcon")) {
$ico = new floIcon();
$ico = new \floIcon();
@$ico->readICO($imageFile);
if(count($ico->images)==0)

@ -1,339 +1,183 @@
<?php
namespace Controls;
function print_select($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
function attributes_to_string(array $attributes) {
$rv = [];
print "<select name=\"$name\" id=\"$id\" $attributes>";
foreach ($values as $v) {
if ($v == $default)
$sel = "selected=\"1\"";
else
$sel = "";
foreach ($attributes as $k => $v) {
$v = trim($v);
// special handling for "disabled"
if ($k === "disabled" && !sql_bool_to_bool($v))
continue;
print "<option value=\"$v\" $sel>$v</option>";
}
print "</select>";
}
array_push($rv, "$k=\"" . htmlspecialchars($v) . "\"");
}
function print_select_hash($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
return implode(" ", $rv);
}
print "<select name=\"$name\" id='$id' $attributes>";
foreach (array_keys($values) as $v) {
if ($v == $default)
$sel = 'selected="selected"';
else
$sel = "";
// shortcut syntax (disabled)
/* function pluginhandler_tags(\Plugin $plugin, string $method) {
return hidden_tag("op", strtolower(get_class($plugin) . \PluginHost::PUBLIC_METHOD_DELIMITER . $method));
} */
$v = trim($v);
function public_method_tags(\Plugin $plugin, string $method) {
return hidden_tag("op", strtolower(get_class($plugin) . \PluginHost::PUBLIC_METHOD_DELIMITER . $method));
}
print "<option $sel value=\"$v\">".$values[$v]."</option>";
}
function pluginhandler_tags(\Plugin $plugin, string $method) {
return hidden_tag("op", "pluginhandler") .
hidden_tag("plugin", strtolower(get_class($plugin))) .
hidden_tag("method", $method);
}
print "</select>";
}
function button_tag(string $value, string $type, array $attributes = []) {
return "<button dojoType=\"dijit.form.Button\" ".attributes_to_string($attributes)." type=\"$type\">$value</button>";
}
function print_hidden($name, $value) {
print "<input dojoType=\"dijit.form.TextBox\" style=\"display : none\" name=\"$name\" value=\"$value\">";
}
function input_tag(string $name, string $value, string $type = "text", array $attributes = [], string $id = "") {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='dijit.form.TextBox'" : "";
function print_checkbox($id, $checked, $value = "", $attributes = "") {
$checked_str = $checked ? "checked" : "";
$value_str = $value ? "value=\"$value\"" : "";
return "<input name=\"".htmlspecialchars($name)."\" $dojo_type ".attributes_to_string($attributes)." id=\"".htmlspecialchars($id)."\"
type=\"$type\" value=\"".htmlspecialchars($value)."\">";
}
print "<input dojoType=\"dijit.form.CheckBox\" id=\"$id\" $value_str $checked_str $attributes name=\"$id\">";
}
function number_spinner_tag(string $name, string $value, array $attributes = [], string $id = "") {
return input_tag($name, $value, "text", array_merge(["dojoType" => "dijit.form.NumberSpinner"], $attributes), $id);
}
function print_button($type, $value, $attributes = "") {
print "<p><button dojoType=\"dijit.form.Button\" $attributes type=\"$type\">$value</button>";
}
function submit_tag(string $value, array $attributes = []) {
return button_tag($value, "submit", array_merge(["class" => "alt-primary"], $attributes));
}
function print_radio($id, $default, $true_is, $values, $attributes = "") {
foreach ($values as $v) {
function cancel_dialog_tag(string $value, array $attributes = []) {
return button_tag($value, "", array_merge(["onclick" => "App.dialogOf(this).hide()"], $attributes));
}
if ($v == $default)
$sel = "checked";
else
$sel = "";
function icon(string $icon, array $attributes = []) {
return "<i class=\"material-icons\" ".attributes_to_string($attributes).">$icon</i>";
}
if ($v == $true_is) {
$sel .= " value=\"1\"";
} else {
$sel .= " value=\"0\"";
}
function select_tag(string $name, $value, array $values, array $attributes = [], string $id = "") {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
print "<input class=\"noborder\" dojoType=\"dijit.form.RadioButton\"
type=\"radio\" $sel $attributes name=\"$id\">&nbsp;$v&nbsp;";
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";
}
}
foreach ($values as $v) {
$is_sel = ($v == $value) ? "selected=\"selected\"" : "";
function print_feed_multi_select($id, $default_ids = [],
$attributes = "", $include_all_feeds = true,
$root_id = null, $nest_level = 0) {
$rv .= "<option value=\"".htmlspecialchars($v)."\" $is_sel>".htmlspecialchars($v)."</option>";
}
$pdo = Db::pdo();
$rv .= "</select>";
print_r(in_array("CAT:6",$default_ids));
return $rv;
}
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>";
}
}
/*function select_labels(string $name, string $value, array $attributes = [], string $id = "") {
$values = \Labels::get_as_hash($_SESSION["uid"]);
if (get_pref('ENABLE_FEED_CATS')) {
return select_tag($name, $value, $values, $attributes, $id);
}*/
if (!$root_id) $root_id = null;
function select_hash(string $name, $value, array $values, array $attributes = [], string $id = "") {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
$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");
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";
$sth->execute([":uid" => $_SESSION['uid'], ":root_id" => $root_id]);
foreach ($values as $k => $v) {
$is_sel = ($k == $value) ? "selected=\"selected\"" : "";
while ($line = $sth->fetch()) {
$rv .= "<option value=\"".htmlspecialchars($k)."\" $is_sel>".htmlspecialchars($v)."</option>";
}
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = " - " . $line["title"];
$rv .= "</select>";
$is_selected = in_array("CAT:".$line["id"], $default_ids) ? "selected=\"1\"" : "";
return $rv;
}
printf("<option $is_selected value='CAT:%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
function hidden_tag(string $name, string $value, array $attributes = []) {
return "<input dojoType=\"dijit.form.TextBox\" style=\"display : none\"
".attributes_to_string($attributes)." name=\"".htmlspecialchars($name)."\"
value=\"".htmlspecialchars($value)."\">";
}
if ($line["num_children"] > 0)
print_feed_multi_select($id, $default_ids, $attributes,
$include_all_feeds, $line["id"], $nest_level+1);
function checkbox_tag(string $name, bool $checked = false, string $value = "", array $attributes = [], string $id = "") {
$is_checked = $checked ? "checked" : "";
$value_str = $value ? "value=\"".htmlspecialchars($value)."\"" : "";
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE cat_id = ? AND owner_uid = ? ORDER BY title");
return "<input dojoType='dijit.form.CheckBox' name=\"".htmlspecialchars($name)."\"
$value_str $is_checked ".attributes_to_string($attributes)." id=\"".htmlspecialchars($id)."\">";
}
$f_sth->execute([$line['id'], $_SESSION['uid']]);
function select_feeds_cats(string $name, int $default_id = null, array $attributes = [],
bool $include_all_cats = true, string $root_id = null, int $nest_level = 0, string $id = "") {
while ($fline = $f_sth->fetch()) {
$is_selected = (in_array($fline["id"], $default_ids)) ? "selected=\"1\"" : "";
$ret = "";
$fline["title"] = " + " . $fline["title"];
if (!$root_id) {
$ret .= "<select name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\"
default=\"".((string)$default_id)."\"
dojoType=\"fox.form.Select\" ".attributes_to_string($attributes).">";
}
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = " - " . $fline["title"];
$pdo = \Db::pdo();
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
if (!$root_id) $root_id = null;
if (!$root_id) {
$is_selected = in_array("CAT:0", $default_ids) ? "selected=\"1\"" : "";
$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]);
printf("<option $is_selected value='CAT:0'>%s</option>",
__("Uncategorized"));
$found = 0;
$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 ($line = $sth->fetch()) {
++$found;
while ($fline = $f_sth->fetch()) {
$is_selected = in_array($fline["id"], $default_ids) ? "selected=\"1\"" : "";
if ($line["id"] == $default_id) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
$fline["title"] = " + " . $fline["title"];
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = "" . $line["title"];
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = " - " . $fline["title"];
if ($line["title"])
$ret .= sprintf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
if ($line["num_children"] > 0)
$ret .= select_feeds_cats($id, $default_id, $attributes,
$include_all_cats, $line["id"], $nest_level+1, $id);
}
} else {
$sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$_SESSION['uid']]);
if (!$root_id) {
if ($include_all_cats) {
if ($found > 0) {
$ret .= "<option disabled=\"1\">―――――――――――――――</option>";
}
while ($line = $sth->fetch()) {
if ($default_id == 0) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
$is_selected = (in_array($line["id"], $default_ids)) ? "selected=\"1\"" : "";
$ret .= "<option $is_selected value=\"0\">".__('Uncategorized')."</option>";
}
$ret .= "</select>";
}
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) {
if (!$root_id) {
print "<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"])
printf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
if ($line["num_children"] > 0)
print_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) {
print "<option disabled=\"1\">--------</option>";
}
if ($default_id == 0) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
print "<option $is_selected value=\"0\">".__('Uncategorized')."</option>";
}
print "</select>";
}
}
function stylesheet_tag($filename, $id = false) {
$timestamp = filemtime($filename);
$id_part = $id ? "id=\"$id\"" : "";
return "<link rel=\"stylesheet\" $id_part type=\"text/css\" data-orig-href=\"$filename\" href=\"$filename?$timestamp\"/>\n";
}
function javascript_tag($filename) {
$query = "";
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";
}
function format_warning($msg, $id = "") {
return "<div class=\"alert\" id=\"$id\">$msg</div>";
}
function format_notice($msg, $id = "") {
return "<div class=\"alert alert-info\" id=\"$id\">$msg</div>";
}
function format_error($msg, $id = "") {
return "<div class=\"alert alert-danger\" id=\"$id\">$msg</div>";
}
function print_notice($msg) {
return print format_notice($msg);
}
function print_warning($msg) {
return print format_warning($msg);
}
function print_error($msg) {
return print format_error($msg);
}
function format_inline_player($url, $ctype) {
$entry = "";
$url = htmlspecialchars($url);
if (strpos($ctype, "audio/") === 0) {
$entry .= "<div class='inline-player'>";
if ($_SESSION["hasAudio"] && (strpos($ctype, "ogg") !== false ||
$_SESSION["hasMp3"])) {
$entry .= "<audio preload=\"none\" controls>
<source type=\"$ctype\" src=\"$url\"/>
</audio> ";
}
if ($entry) $entry .= "<a target=\"_blank\" rel=\"noopener noreferrer\"
href=\"$url\">" . basename($url) . "</a>";
$entry .= "</div>";
return $entry;
}
return "";
}
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>";
}
return $ret;
}

@ -0,0 +1,54 @@
<?php
function stylesheet_tag($filename, $attributes = []) {
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"href" => "$filename?" . filemtime($filename),
"rel" => "stylesheet",
"type" => "text/css",
"data-orig-href" => $filename
],
$attributes));
return "<link $attributes_str/>\n";
}
function javascript_tag($filename, $attributes = []) {
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"src" => "$filename?" . filemtime($filename),
"type" => "text/javascript",
"charset" => "utf-8"
],
$attributes));
return "<script $attributes_str></script>\n";
}
function format_warning($msg, $id = "") {
return "<div class=\"alert\" id=\"$id\">$msg</div>";
}
function format_notice($msg, $id = "") {
return "<div class=\"alert alert-info\" id=\"$id\">$msg</div>";
}
function format_error($msg, $id = "") {
return "<div class=\"alert alert-danger\" id=\"$id\">$msg</div>";
}
function print_notice($msg) {
return print format_notice($msg);
}
function print_warning($msg) {
return print format_warning($msg);
}
function print_error($msg) {
return print format_error($msg);
}

@ -1,10 +0,0 @@
<?php
require_once "db.php";
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 set_pref($pref_name, $value, $user_id = false, $strip_tags = true) {
return Db_Prefs::get()->write($pref_name, $value, $user_id, $strip_tags);
}

@ -1,38 +0,0 @@
<?php
function db_escape_string($s, $strip_tags = true) {
return Db::get()->escape_string($s, $strip_tags);
}
function db_query($query, $die_on_error = true) {
return Db::get()->query($query, $die_on_error);
}
function db_fetch_assoc($result) {
return Db::get()->fetch_assoc($result);
}
function db_num_rows($result) {
return Db::get()->num_rows($result);
}
function db_fetch_result($result, $row, $param) {
return Db::get()->fetch_result($result, $row, $param);
}
function db_affected_rows($result) {
return Db::get()->affected_rows($result);
}
function db_last_error() {
return Db::get()->last_error();
}
function db_last_query_error() {
return Db::get()->last_query_error();
}
function db_quote($str){
return Db::get()->quote($str);
}

@ -8,12 +8,16 @@ 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, $a);
if (is_object($a)) {
array_push($fmt_args, "{" . get_class($a) . "}");
} else if (is_array($a)) {
array_push($fmt_args, "[" . truncate_string(json_encode($a), 256, "...")) . "]";
} else if (is_resource($a)) {
array_push($fmt_args, truncate_string(get_resource_type($a), 256, "..."));
} else {
array_push($fmt_args, "[" . get_class($a) . "]");
array_push($fmt_args, truncate_string($a, 256, "..."));
}
}
}
@ -21,7 +25,11 @@ function format_backtrace($trace) {
$filename = str_replace(dirname(__DIR__) . "/", "", $e["file"]);
$rv .= sprintf("%d. %s(%s): %s(%s)\n",
$idx, $filename, $e["line"], $e["function"], implode(", ", $fmt_args));
$idx,
$filename,
$e["line"],
$e["function"],
implode(", ", $fmt_args));
$idx++;
}
@ -31,21 +39,27 @@ function format_backtrace($trace) {
return $rv;
}
function ttrss_error_handler($errno, $errstr, $file, $line, $context) {
if (error_reporting() == 0 || !$errno) return false;
function ttrss_error_handler($errno, $errstr, $file, $line) {
/*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;*/
$file = substr(str_replace(dirname(dirname(__FILE__)), "", $file), 1);
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
$context = format_backtrace(debug_backtrace());
$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) {
@ -58,12 +72,10 @@ function ttrss_fatal_handler() {
$context = format_backtrace(debug_backtrace());
$file = substr(str_replace(dirname(dirname(__FILE__)), "", $file), 1);
if ($last_query) $errstr .= " [Last query: $last_query]";
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
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,24 +1,14 @@
<?php
define('EXPECTED_CONFIG_VERSION', 26);
define('SCHEMA_VERSION', 140);
define('LABEL_BASE_INDEX', -1024);
define('PLUGIN_FEED_BASE_INDEX', -128);
define('COOKIE_LIFETIME_LONG', 86400*365);
// this CSS file is included for everyone (if it exists in themes.local)
// on login, registration, and main (index and prefs) pages
define('LOCAL_OVERRIDE_STYLESHEET', '.local-overrides.css');
/** constant is @deprecated, use Config::SCHEMA_VERSION instead */
define('SCHEMA_VERSION', Config::SCHEMA_VERSION);
$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;
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
}
libxml_disable_entity_loader(true);
libxml_use_internal_errors(true);
// separate test because this is included before sanity checks
@ -31,71 +21,31 @@
error_reporting(E_ALL & ~E_NOTICE);
}
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
require_once 'config.php';
/**
* Define a constant if not already defined
*/
function define_default($name, $value) {
defined($name) or define($name, $value);
}
/* Some tunables you can override in config.php using define(): */
define_default('FEED_FETCH_TIMEOUT', 45);
// How may seconds to wait for response when requesting feed from a site
define_default('FEED_FETCH_NO_CACHE_TIMEOUT', 15);
// How may seconds to wait for response when requesting feed from a
// site when that feed wasn't cached before
define_default('FILE_FETCH_TIMEOUT', 45);
// Default timeout when fetching files from remote sites
define_default('FILE_FETCH_CONNECT_TIMEOUT', 15);
// How many seconds to wait for initial response from website when
// fetching files from remote sites
define_default('DAEMON_UPDATE_LOGIN_LIMIT', 30);
// stop updating feeds if users haven't logged in for X days
define_default('DAEMON_FEED_LIMIT', 500);
// feed limit for one update batch
define_default('DAEMON_SLEEP_INTERVAL', 120);
// default sleep interval between feed updates (sec)
define_default('MAX_CACHE_FILE_SIZE', 64*1024*1024);
// do not cache files larger than that (bytes)
define_default('MAX_DOWNLOAD_FILE_SIZE', 16*1024*1024);
// do not download general files larger than that (bytes)
define_default('CACHE_MAX_DAYS', 7);
// max age in days for various automatically cached (temporary) files
define_default('MAX_CONDITIONAL_INTERVAL', 3600*12);
// max interval between forced unconditional updates for servers
// not complying with http if-modified-since (seconds)
// define_default('MAX_FETCH_REQUESTS_PER_HOST', 25);
// a maximum amount of allowed HTTP requests per destination host
// during a single update (i.e. within PHP process lifetime)
// this is used to not cause excessive load on the origin server on
// e.g. feed subscription when all articles are being processes
// (not implemented)
define_default('DAEMON_UNSUCCESSFUL_DAYS_LIMIT', 30);
// automatically disable updates for feeds which failed to
// update for this amount of days; 0 disables
/* tunables end here */
if (DB_TYPE == "pgsql") {
ini_set('display_errors', "false");
ini_set('display_startup_errors', "false");
// config.php is optional
if (stream_resolve_include_path("config.php"))
require_once "config.php";
require_once "autoload.php";
if (Config::get(Config::DB_TYPE) == "pgsql") {
define('SUBSTRING_FOR_DATE', 'SUBSTRING_FOR_DATE');
} else {
define('SUBSTRING_FOR_DATE', 'SUBSTRING');
}
/**
* Return available translations names.
*
* @access public
* @return array A array of available translations.
*/
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(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() {
$tr = array(
$t = array(
"auto" => __("Detect automatically"),
"ar_SA" => "العربيّة (Arabic)",
"bg_BG" => "Bulgarian",
@ -126,34 +76,76 @@
"fi_FI" => "Suomi",
"tr_TR" => "Türkçe");
return $tr;
return $t;
}
require_once "lib/accept-to-gettext.php";
require_once "lib/gettext/gettext.inc.php";
function startup_gettext() {
# Get locale from Accept-Language header
$lang = al2gt(array_keys(get_translations()), "text/html");
$selected_locale = "";
// https://www.codingwithjesse.com/blog/use-accept-language-header/
if (!empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$valid_langs = [];
$translations = array_keys(get_translations());
array_shift($translations); // remove "auto"
// full locale first
foreach ($translations as $t) {
$lang = strtolower(str_replace("_", "-", (string)$t));
$valid_langs[$lang] = $t;
$lang = substr($lang, 0, 2);
if (!isset($valid_langs[$lang]))
$valid_langs[$lang] = $t;
}
// break up string into pieces (languages and q factors)
preg_match_all('/([a-z]{1,8}(-[a-z]{1,8})?)\s*(;\s*q\s*=\s*(1|0\.[0-9]+))?/i',
$_SERVER['HTTP_ACCEPT_LANGUAGE'], $lang_parse);
if (count($lang_parse[1])) {
// create a list like "en" => 0.8
$langs = array_combine($lang_parse[1], $lang_parse[4]);
if (is_array($langs)) {
// set default to 1 for any without q factor
foreach ($langs as $lang => $val) {
if ($val === '') $langs[$lang] = 1;
}
// sort list based on value
arsort($langs, SORT_NUMERIC);
foreach (array_keys($langs) as $lang) {
$lang = strtolower($lang);
if (defined('_TRANSLATION_OVERRIDE_DEFAULT')) {
$lang = _TRANSLATION_OVERRIDE_DEFAULT;
foreach ($valid_langs as $vlang => $vlocale) {
if ($vlang == $lang) {
$selected_locale = $vlocale;
break 2;
}
}
}
}
}
}
if ($_SESSION["uid"] && get_schema_version() >= 120) {
$pref_lang = get_pref("USER_LANGUAGE", $_SESSION["uid"]);
if (!empty($_SESSION["uid"]) && get_schema_version() >= 120) {
$pref_locale = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]);
if ($pref_lang && $pref_lang != 'auto') {
$lang = $pref_lang;
if (!empty($pref_locale) && $pref_locale != 'auto') {
$selected_locale = $pref_locale;
}
}
if ($lang) {
if ($selected_locale) {
if (defined('LC_MESSAGES')) {
_setlocale(LC_MESSAGES, $lang);
_setlocale(LC_MESSAGES, $selected_locale);
} else if (defined('LC_ALL')) {
_setlocale(LC_ALL, $lang);
_setlocale(LC_ALL, $selected_locale);
}
_bindtextdomain("messages", "locale");
@ -162,89 +154,95 @@
}
}
require_once 'db-prefs.php';
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::getFeedArticles($feed, $is_cat, true, $_SESSION["uid"]);
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"]) {
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"]) {
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)) {
return array_map("strip_tags", $param);
return array_map("trim", array_map("strip_tags", $param));
} else if (is_string($param)) {
return strip_tags($param);
return trim(strip_tags($param));
} else {
return $param;
}
}
function with_trailing_slash(string $str) : string {
if (substr($str, -1) === "/") {
return $str;
} else {
return "$str/";
}
}
function make_password($length = 12) {
$password = "";
$possible = "0123456789abcdfghjkmnpqrstvwxyzABCDFGHJKMNPQRSTVWXYZ*%+^";
@ -271,7 +269,7 @@
}
function validate_csrf($csrf_token) {
return isset($csrf_token) && hash_equals($_SESSION['csrf_token'], $csrf_token);
return isset($csrf_token) && hash_equals($_SESSION['csrf_token'] ?? "", $csrf_token);
}
function truncate_string($str, $max_len, $suffix = '&hellip;') {
@ -307,42 +305,10 @@
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 sanity_check() {
require_once 'errors.php';
global $ERRORS;
$error_code = 0;
$schema_version = get_schema_version(true);
if ($schema_version != SCHEMA_VERSION) {
$error_code = 5;
}
return array("code" => $error_code, "message" => $ERRORS[$error_code]);
}
function file_is_locked($filename) {
if (file_exists(LOCK_DIRECTORY . "/$filename")) {
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/$filename")) {
if (function_exists('flock')) {
$fp = @fopen(LOCK_DIRECTORY . "/$filename", "r");
$fp = @fopen(Config::get(Config::LOCK_DIRECTORY) . "/$filename", "r");
if ($fp) {
if (flock($fp, LOCK_EX | LOCK_NB)) {
flock($fp, LOCK_UN);
@ -362,11 +328,11 @@
}
function make_lockfile($filename) {
$fp = fopen(LOCK_DIRECTORY . "/$filename", "w");
$fp = fopen(Config::get(Config::LOCK_DIRECTORY) . "/$filename", "w");
if ($fp && flock($fp, LOCK_EX | LOCK_NB)) {
$stat_h = fstat($fp);
$stat_f = stat(LOCK_DIRECTORY . "/$filename");
$stat_f = stat(Config::get(Config::LOCK_DIRECTORY) . "/$filename");
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
if ($stat_h["ino"] != $stat_f["ino"] ||
@ -390,13 +356,7 @@
}
function uniqid_short() {
return uniqid(base_convert(rand(), 10, 36));
}
function trim_array($array) {
$tmp = $array;
array_walk($tmp, 'trim');
return $tmp;
return uniqid(base_convert((string)rand(), 10, 36));
}
function T_sprintf() {
@ -404,35 +364,13 @@
return vsprintf(__(array_shift($args)), $args);
}
function is_server_https() {
return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) || $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https';
}
function is_prefix_https() {
return parse_url(SELF_URL_PATH, PHP_URL_SCHEME) == 'https';
}
// this returns SELF_URL_PATH sans ending slash
function get_self_url_prefix() {
if (strrpos(SELF_URL_PATH, "/") === strlen(SELF_URL_PATH)-1) {
return substr(SELF_URL_PATH, 0, strlen(SELF_URL_PATH)-1);
} else {
return SELF_URL_PATH;
}
function T_nsprintf() {
$args = func_get_args();
return vsprintf(_ngettext(array_shift($args), array_shift($args), array_shift($args)), $args);
}
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(PLUGINS, PluginHost::KIND_ALL);
PluginHost::getInstance()->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
return true;
}
@ -475,80 +413,20 @@
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) {
return file_exists("themes/$theme") || file_exists("themes.local/$theme");
}
/**
* @SuppressWarnings(unused)
*/
function error_json($code) {
require_once "errors.php";
global $ERRORS;
@$message = $ERRORS[$code];
return json_encode(array("error" =>
array("code" => $code, "message" => $message)));
}
function arr_qmarks($arr) {
return str_repeat('?,', count($arr) - 1) . '?';
}
@ -565,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(dirname(__FILE__));
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'];
}

@ -6,21 +6,16 @@
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<?php
foreach (array("lib/prototype.js",
"lib/dojo/dojo.js",
foreach (["lib/dojo/dojo.js",
"lib/dojo/tt-rss-layer.js",
"lib/prototype.js",
"js/common.js",
"js/utility.js",
"errors.php?mode=js") as $jsfile) {
"js/utility.js"] as $jsfile) {
echo javascript_tag($jsfile);
} ?>
<?php if (theme_exists(LOCAL_OVERRIDE_STYLESHEET)) {
echo stylesheet_tag(get_theme_path(LOCAL_OVERRIDE_STYLESHEET));
} ?>
<?= Config::get_override_links() ?>
<style type="text/css">
@media (prefers-color-scheme: dark) {
@ -61,13 +56,13 @@
if (login && login != this.previousLogin) {
this.previousLogin = login;
xhrJson("public.php", {op: "getprofiles", login: login},
xhr.json("public.php", {op: "getprofiles", login: login},
(reply) => {
const profile = dijit.byId('profile');
profile.removeOption(profile.getOptions());
reply.each((p) => {
reply.forEach((p) => {
profile
.attr("disabled", false)
.addOption(p);
@ -81,40 +76,40 @@
},
bwLimitChange: function(elem) {
Cookie.set("ttrss_bwlimit", elem.checked,
<?php print SESSION_COOKIE_LIFETIME ?>);
<?php print Config::get(Config::SESSION_COOKIE_LIFETIME) ?>);
}
};
</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">
<h1><?php echo "Authentication" ?></h1>
<h1><?= "Authentication" ?></h1>
<div class="content">
<form action="public.php?return=<?php echo $return ?>"
<form action="public.php?return=<?= $return ?>"
dojoType="dijit.form.Form" method="POST">
<?php print_hidden("op", "login"); ?>
<?= \Controls\hidden_tag("op", "login"); ?>
<?php if ($_SESSION["login_error_msg"]) { ?>
<?php echo format_error($_SESSION["login_error_msg"]) ?>
<?php if (!empty($_SESSION["login_error_msg"])) { ?>
<?= format_error($_SESSION["login_error_msg"]) ?>
<?php $_SESSION["login_error_msg"] = ""; ?>
<?php } ?>
<fieldset>
<label><?php echo __("Login:") ?></label>
<label><?= __("Login:") ?></label>
<input name="login" id="login" dojoType="dijit.form.TextBox" type="text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
required="1" value="<?php echo $_SESSION["fake_login"] ?>" />
required="1" value="<?= $_SESSION["fake_login"] ?? "" ?>" />
</fieldset>
<fieldset>
<label><?php echo __("Password:") ?></label>
<label><?= __("Password:") ?></label>
<input type="password" name="password" required="1"
dojoType="dijit.form.TextBox"
@ -122,49 +117,54 @@
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
value="<?php echo $_SESSION["fake_password"] ?>"/>
value="<?= $_SESSION["fake_password"] ?? "" ?>"/>
</fieldset>
<?php if (strpos(PLUGINS, "auth_internal") !== false) { ?>
<?php if (strpos(Config::get(Config::PLUGINS), "auth_internal") !== false) { ?>
<fieldset class="align-right">
<a href="public.php?op=forgotpass"><?php echo __("I forgot my password") ?></a>
<a href="public.php?op=forgotpass"><?= __("I forgot my password") ?></a>
</fieldset>
<?php } ?>
<fieldset>
<label><?php echo __("Profile:") ?></label>
<label><?= __("Profile:") ?></label>
<select disabled='disabled' name="profile" id="profile" dojoType='dijit.form.Select'>
<option><?php echo __("Default profile") ?></option>
<option><?= __("Default profile") ?></option>
</select>
</fieldset>
<fieldset class="narrow">
<label> </label>
<label id="bw_limit_label"><input dojoType="dijit.form.CheckBox" name="bw_limit" id="bw_limit"
type="checkbox" onchange="UtilityApp.bwLimitChange(this)">
<?php echo __("Use less traffic") ?></label>
<label id="bw_limit_label">
<?= \Controls\checkbox_tag("bw_limit", false, "",
["onchange" => 'UtilityApp.bwLimitChange(this)'], 'bw_limit') ?>
<?= __("Use less traffic") ?></label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="bw_limit_label" position="below" style="display:none">
<?php echo __("Does not display images in articles, reduces automatic refreshes."); ?>
<?= __("Does not display images in articles, reduces automatic refreshes."); ?>
</div>
<fieldset class="narrow">
<label> </label>
<label ><input dojoType="dijit.form.CheckBox" name="safe_mode" id="safe_mode"
type="checkbox">
<?php echo __("Safe mode (no plugins)") ?></label>
<label id="safe_mode_label">
<?= \Controls\checkbox_tag("safe_mode") ?>
<?= __("Safe mode") ?>
</label>
</fieldset>
<?php if (SESSION_COOKIE_LIFETIME > 0) { ?>
<div dojoType="dijit.Tooltip" connectId="safe_mode_label" position="below" style="display:none">
<?= __("Uses default theme and prevents all plugins from loading."); ?>
</div>
<?php if (Config::get(Config::SESSION_COOKIE_LIFETIME) > 0) { ?>
<fieldset class="narrow">
<label> </label>
<label>
<input dojoType="dijit.form.CheckBox" name="remember_me" id="remember_me" type="checkbox">
<?php echo __("Remember me") ?>
<?= \Controls\checkbox_tag("remember_me") ?>
<?= __("Remember me") ?>
</label>
</fieldset>
@ -174,13 +174,7 @@
<fieldset class="align-right">
<label> </label>
<button dojoType="dijit.form.Button" type="submit" class="alt-primary"><?php echo __('Log in') ?></button>
<?php if (defined('ENABLE_REGISTRATION') && ENABLE_REGISTRATION) { ?>
<button onclick="return UtilityApp.gotoRegForm()" dojoType="dijit.form.Button">
<?php echo __("Create new account") ?></button>
<?php } ?>
<?= \Controls\submit_tag(__('Log in')) ?>
</fieldset>
</form>
@ -188,7 +182,7 @@
<div class="footer">
<a href="https://tt-rss.org/">Tiny Tiny RSS</a>
&copy; 2005&ndash;<?php echo date('Y') ?> <a href="https://fakecake.org/">Andrew Dolgov</a>
&copy; 2005&ndash;<?= date('Y') ?> <a href="https://fakecake.org/">Andrew Dolgov</a>
</div>
</div>

@ -1,249 +0,0 @@
<?php
/* WARNING!
*
* If you modify this file, you are ON YOUR OWN!
*
* Believe it or not, all of the checks below are required to succeed for
* tt-rss to actually function properly.
*
* If you think you have a better idea about what is or isn't required, feel
* free to modify the file, note though that you are therefore automatically
* disqualified from any further support by official channels, e.g. tt-rss.org
* issue tracker or the forums.
*
* If you come crying when stuff inevitably breaks, you will be mocked and told
* to get out. */
function make_self_url() {
$proto = is_server_https() ? 'https' : 'http';
return $proto . '://' . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"];
}
function make_self_url_path() {
$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([DB_NAME]);
$bad_tables = [];
while ($line = $sth->fetch()) {
array_push($bad_tables, $line);
}
return $bad_tables;
}
/**
* @SuppressWarnings(PHPMD.UnusedLocalVariable)
*/
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 {
require_once "sanity_config.php";
if (file_exists("install") && !file_exists("config.php")) {
array_push($errors, "Please copy config.php-dist to config.php or run the installer in install/");
}
if (strpos(PLUGINS, "auth_") === false) {
array_push($errors, "Please enable at least one authentication module via 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, '5.6.0', '<')) {
array_push($errors, "PHP version 5.6.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 (CONFIG_VERSION != EXPECTED_CONFIG_VERSION) {
array_push($errors, "Configuration file (config.php) has incorrect version. Update it with new options from config.php-dist and set CONFIG_VERSION to the correct value.");
}
if (!is_writable(CACHE_DIR . "/images")) {
array_push($errors, "Image cache is not writable (chmod -R 777 ".CACHE_DIR."/images)");
}
if (!is_writable(CACHE_DIR . "/upload")) {
array_push($errors, "Upload cache is not writable (chmod -R 777 ".CACHE_DIR."/upload)");
}
if (!is_writable(CACHE_DIR . "/export")) {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".CACHE_DIR."/export)");
}
if (GENERATED_CONFIG_CHECK != EXPECTED_CONFIG_VERSION) {
array_push($errors,
"Configuration option checker sanity_config.php is outdated, please recreate it using ./utils/regen_config_checks.sh");
}
foreach ($required_defines as $d) {
if (!defined($d)) {
array_push($errors,
"Required configuration file parameter $d is not defined in config.php. You might need to copy it from config.php-dist.");
}
}
if (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, "SINGLE_USER_MODE is enabled in config.php but default admin account is not found.");
}
}
$ref_self_url_path = make_self_url_path();
$ref_self_url_path = preg_replace("/\w+\.php$/", "", $ref_self_url_path);
if (SELF_URL_PATH == "http://example.org/tt-rss/") {
array_push($errors,
"Please set SELF_URL_PATH to the correct value for your server (possible value: <b>$ref_self_url_path</b>)");
}
if (isset($_SERVER["HTTP_HOST"]) &&
(!defined('_SKIP_SELF_URL_PATH_CHECKS') || !_SKIP_SELF_URL_PATH_CHECKS) &&
SELF_URL_PATH != $ref_self_url_path && SELF_URL_PATH != mb_substr($ref_self_url_path, 0, mb_strlen($ref_self_url_path)-1)) {
array_push($errors,
"Please set SELF_URL_PATH to the correct value detected for your server: <b>$ref_self_url_path</b>");
}
if (!is_writable(ICONS_DIR)) {
array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".ICONS_DIR.").\n");
}
if (!is_writable(LOCK_DIRECTORY)) {
array_push($errors, "LOCK_DIRECTORY defined in config.php is not writable (chmod -R 777 ".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 (DB_TYPE == "mysql" && !function_exists("mysqli_connect")) {
array_push($errors, "PHP support for MySQL is required for configured DB_TYPE in config.php.");
}
if (DB_TYPE == "pgsql" && !function_exists("pg_connect")) {
array_push($errors, "PHP support for PostgreSQL is required for configured DB_TYPE in config.php");
}
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 (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 && $_SERVER['REQUEST_URI']) { ?>
<!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 " * $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();
?>

@ -1,3 +0,0 @@
<?php # This file has been generated at: Fri Dec 11 09:30:20 MSK 2020
define('GENERATED_CONFIG_CHECK', 26);
$required_defines = array( 'DB_TYPE', 'DB_HOST', 'DB_USER', 'DB_NAME', 'DB_PASS', 'MYSQL_CHARSET', 'SELF_URL_PATH', 'SINGLE_USER_MODE', 'SIMPLE_UPDATE_MODE', 'PHP_EXECUTABLE', 'LOCK_DIRECTORY', 'CACHE_DIR', 'ICONS_DIR', 'ICONS_URL', 'AUTH_AUTO_CREATE', 'AUTH_AUTO_LOGIN', 'FORCE_ARTICLE_PURGE', 'ENABLE_REGISTRATION', 'REG_NOTIFY_ADDRESS', 'REG_MAX_USERS', 'SESSION_COOKIE_LIFETIME', 'SMTP_FROM_NAME', 'SMTP_FROM_ADDRESS', 'DIGEST_SUBJECT', 'CHECK_FOR_UPDATES', 'ENABLE_GZIP_OUTPUT', 'PLUGINS', 'LOG_DESTINATION', 'CONFIG_VERSION'); ?>

@ -1,88 +1,56 @@
<?php
// Original from http://www.daniweb.com/code/snippet43.html
namespace Sessions;
require_once "config.php";
require_once "classes/db.php";
require_once "autoload.php";
require_once "functions.php";
require_once "errorhandler.php";
require_once "lib/accept-to-gettext.php";
require_once "lib/gettext/gettext.inc.php";
$session_expire = min(2147483647 - time() - 1, max(SESSION_COOKIE_LIFETIME, 86400));
$session_name = (!defined('TTRSS_SESSION_NAME')) ? "ttrss_sid" : TTRSS_SESSION_NAME;
$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()) {
ini_set("session.cookie_secure", true);
if (\Config::is_server_https()) {
ini_set("session.cookie_secure", "true");
}
ini_set("session.gc_probability", 75);
ini_set("session.gc_probability", "75");
ini_set("session.name", $session_name);
ini_set("session.use_only_cookies", true);
ini_set("session.use_only_cookies", "true");
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;
}
}
ini_set("session.cookie_lifetime", "0");
// 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 (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 (\Config::get(\Config::SINGLE_USER_MODE)) return true;
if ($_SESSION["uid"]) {
$pdo = \Db::pdo();
if (!defined('_SESSION_SKIP_UA_CHECKS') && $_SESSION["user_agent"] != sha1($_SERVER['HTTP_USER_AGENT'])) {
$_SESSION["login_error_msg"] = __("Session failed to validate (UA changed).");
return false;
}
if (!empty($_SESSION["uid"])) {
$user = \ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
$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)");
return false;
}
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)");
return false;
$_SESSION["login_error_msg"] = __("Session failed to validate (user not found)");
return false;
}
}
return true;
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
function ttrss_open ($s, $n) {
return true;
}
@ -90,7 +58,7 @@
function ttrss_read ($id){
global $session_expire;
$sth = Db::pdo()->prepare("SELECT data FROM ttrss_sessions WHERE id=?");
$sth = \Db::pdo()->prepare("SELECT data FROM ttrss_sessions WHERE id=?");
$sth->execute([$id]);
if ($row = $sth->fetch()) {
@ -99,7 +67,7 @@
} else {
$expire = time() + $session_expire;
$sth = Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
$sth = \Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
VALUES (?, '', ?)");
$sth->execute([$id, $expire]);
@ -115,14 +83,14 @@
$data = base64_encode($data);
$expire = time() + $session_expire;
$sth = Db::pdo()->prepare("SELECT id FROM ttrss_sessions WHERE id=?");
$sth = \Db::pdo()->prepare("SELECT id FROM ttrss_sessions WHERE id=?");
$sth->execute([$id]);
if ($row = $sth->fetch()) {
$sth = Db::pdo()->prepare("UPDATE ttrss_sessions SET data=?, expire=? WHERE id=?");
$sth = \Db::pdo()->prepare("UPDATE ttrss_sessions SET data=?, expire=? WHERE id=?");
$sth->execute([$data, $expire, $id]);
} else {
$sth = Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
$sth = \Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
VALUES (?, ?, ?)");
$sth->execute([$id, $data, $expire]);
}
@ -135,30 +103,29 @@
}
function ttrss_destroy($id) {
$sth = Db::pdo()->prepare("DELETE FROM ttrss_sessions WHERE id = ?");
$sth = \Db::pdo()->prepare("DELETE FROM ttrss_sessions WHERE id = ?");
$sth->execute([$id]);
return true;
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
function ttrss_gc ($expire) {
Db::pdo()->query("DELETE FROM ttrss_sessions WHERE expire < " . time());
\Db::pdo()->query("DELETE FROM ttrss_sessions WHERE expire < " . time());
return true;
}
if (!SINGLE_USER_MODE /* && DB_TYPE == "pgsql" */) {
session_set_save_handler("ttrss_open",
"ttrss_close", "ttrss_read", "ttrss_write",
"ttrss_destroy", "ttrss_gc");
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 (!defined('NO_SESSION_AUTOSTART')) {
if (isset($_COOKIE[session_name()])) {
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
}
}
}

@ -1,37 +1,26 @@
<?php
if (file_exists("install") && !file_exists("config.php")) {
header("Location: install/");
}
if (!file_exists("config.php")) {
print "<b>Fatal Error</b>: You forgot to copy
<b>config.php-dist</b> to <b>config.php</b> and edit it.\n";
exit;
}
// we need a separate check here because functions.php might get parsed
// incorrectly before 5.3 because of :: syntax.
if (version_compare(PHP_VERSION, '5.6.0', '<')) {
print "<b>Fatal Error</b>: PHP version 5.6.0 or newer required. You're using " . PHP_VERSION . ".\n";
if (version_compare(PHP_VERSION, '7.0.0', '<')) {
print "<b>Fatal Error</b>: PHP version 7.0.0 or newer required. You're using " . PHP_VERSION . ".\n";
exit;
}
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
get_include_path());
require_once "autoload.php";
require_once "sessions.php";
require_once "functions.php";
require_once "sanity_check.php";
require_once "config.php";
require_once "db-prefs.php";
Config::sanity_check();
if (!init_plugins()) return;
UserHelper::login_sequence();
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
@ -39,19 +28,17 @@
<title>Tiny Tiny RSS</title>
<meta name="viewport" content="initial-scale=1,width=device-width" />
<?php if ($_SESSION["uid"] && !isset($_REQUEST["ignore-theme"])) {
$theme = get_pref("USER_CSS_THEME", false, false);
<?php if ($_SESSION["uid"] && empty($_SESSION["safe_mode"])) {
$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(LOCAL_OVERRIDE_STYLESHEET)) {
echo stylesheet_tag(get_theme_path(LOCAL_OVERRIDE_STYLESHEET));
} ?>
<?= Config::get_override_links() ?>
<script type="text/javascript">
const __csrf_token = "<?php echo $_SESSION["csrf_token"]; ?>";
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
</script>
<?php UserHelper::print_user_stylesheet() ?>
@ -72,7 +59,7 @@
<script>
dojoConfig = {
async: true,
cacheBust: "<?php echo get_scripts_timestamp(); ?>",
cacheBust: "<?= get_scripts_timestamp(); ?>",
packages: [
{ name: "fox", location: "../../js" },
]
@ -80,13 +67,10 @@
</script>
<?php
foreach (array("lib/prototype.js",
"lib/scriptaculous/scriptaculous.js?load=effects,controls",
"lib/dojo/dojo.js",
foreach (["lib/dojo/dojo.js",
"lib/dojo/tt-rss-layer.js",
"js/tt-rss.js",
"js/common.js",
"errors.php?mode=js") as $jsfile) {
"js/common.js"] as $jsfile) {
echo javascript_tag($jsfile);
@ -111,8 +95,6 @@
}
}
}
init_js_translations();
?>
</script>
@ -128,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">
<?php echo __("Loading, please wait...") ?>
<?= __("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>
@ -149,13 +143,14 @@
<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'/>
<?php echo __("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
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_TREE) as $p) {
echo $p->hook_feed_tree();
}
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_FEED_TREE, function ($result) {
echo $result;
});
?>
<div id="feedTree"></div>
</div>
@ -164,106 +159,113 @@
<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="<?php echo __("Communication problem with server.") ?>">error_outline</i>
<i class="material-icons log-alert" style="display : none"
title="<?php echo __("Recent entries found in event log.") ?>">warning</i>
<i id="updates-available" class="material-icons icon-new-version" style="display : none"
title="<?php echo __('Updates are available from Git.') ?>">new_releases</i>
<?php
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_MAIN_TOOLBAR_BUTTON) as $p) {
echo $p->hook_main_toolbar_button();
}
?>
<form id="toolbar-headlines" action="" style="order : 10" onsubmit='return false'>
<!-- order 0, default -->
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_MAIN_TOOLBAR_BUTTON, function ($result) {
echo $result;
});
?>
<!-- order 5: alert icons -->
<i class="material-icons net-alert" style="display : none; order : 5"
title="<?= __("Communication problem with server.") ?>">error_outline</i>
<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="Feeds.onViewModeChanged()"
dojoType="fox.form.Select">
<option selected="selected" value="adaptive"><?= __('Adaptive') ?></option>
<option value="all_articles"><?= __('All Articles') ?></option>
<option value="marked"><?= __('Starred') ?></option>
<option value="published"><?= __('Published') ?></option>
<option value="unread"><?= __('Unread') ?></option>
<option value="has_note"><?= __('With Note') ?></option>
</select>
<select title="<?= __('Sort articles') ?>"
onchange="Feeds.onViewModeChanged()"
dojoType="fox.form.Select" name="order_by">
<option selected="selected" value="default"><?= __('Default') ?></option>
<option value="feed_dates"><?= __('Newest first') ?></option>
<option value="date_reverse"><?= __('Oldest first') ?></option>
<option value="title"><?= __('Title') ?></option>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) {
foreach ($result as $sort_value => $sort_title) {
print "<option value=\"" . htmlspecialchars($sort_value) . "\">$sort_title</option>";
}
});
?>
</select>
<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')">
<?= __('Older than one day') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1week')">
<?= __('Older than one week') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('2week')">
<?= __('Older than two weeks') ?>
</div>
</div>
</div>
</form>
<form id="toolbar-main" action="" style="order : 20" onsubmit='return false'>
<select name="view_mode" title="<?php echo __('Show articles') ?>"
onchange="App.onViewModeChanged()"
dojoType="fox.form.Select">
<option selected="selected" value="adaptive"><?php echo __('Adaptive') ?></option>
<option value="all_articles"><?php echo __('All Articles') ?></option>
<option value="marked"><?php echo __('Starred') ?></option>
<option value="published"><?php echo __('Published') ?></option>
<option value="unread"><?php echo __('Unread') ?></option>
<option value="has_note"><?php echo __('With Note') ?></option>
<!-- <option value="noscores"><?php echo __('Ignore Scoring') ?></option> -->
</select>
<select title="<?php echo __('Sort articles') ?>"
onchange="App.onViewModeChanged()"
dojoType="fox.form.Select" name="order_by">
<option selected="selected" value="default"><?php echo __('Default') ?></option>
<option value="feed_dates"><?php echo __('Newest first') ?></option>
<option value="date_reverse"><?php echo __('Oldest first') ?></option>
<option value="title"><?php echo __('Title') ?></option>
<?php foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP) as $p) {
$sort_map = $p->hook_headlines_custom_sort_map();
foreach ($sort_map as $sort_value => $sort_title) {
print "<option value=\"" . htmlspecialchars($sort_value) . "\">$sort_title</option>";
}
} ?>
</select>
<div dojoType="fox.form.ComboButton" onclick="Feeds.catchupCurrent()">
<span><?php echo __('Mark as read') ?></span>
<div dojoType="dijit.DropDownMenu">
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1day')">
<?php echo __('Older than one day') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1week')">
<?php echo __('Older than one week') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('2week')">
<?php echo __('Older than two weeks') ?>
</div>
</div>
</div>
</form>
<!-- toolbar actions dropdown: order 30 -->
<div class="action-chooser" style="order : 30">
<?php
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_TOOLBAR_BUTTON) as $p) {
echo $p->hook_toolbar_button();
}
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_TOOLBAR_BUTTON, function ($result) {
echo $result;
});
?>
<div dojoType="fox.form.DropDownButton" class="action-button" title="<?php echo __('Actions...') ?>">
<div dojoType="fox.form.DropDownButton" class="action-button" title="<?= __('Actions...') ?>">
<span><i class="material-icons">menu</i></span>
<div dojoType="dijit.Menu" style="display: none">
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcPrefs')"><?php echo __('Preferences...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcSearch')"><?php echo __('Search...') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?php echo __('Feed actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcAddFeed')"><?php echo __('Subscribe to feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcEditFeed')"><?php echo __('Edit this feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcRemoveFeed')"><?php echo __('Unsubscribe') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?php echo __('All feeds:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcCatchupAll')"><?php echo __('Mark as read') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcShowOnlyUnread')"><?php echo __('(Un)hide read feeds') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?php echo __('Other actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcToggleWidescreen')"><?php echo __('Toggle widescreen mode') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcHKhelp')"><?php echo __('Keyboard shortcuts help') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcPrefs')"><?= __('Preferences...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcSearch')"><?= __('Search...') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?= __('Feed actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcAddFeed')"><?= __('Subscribe to feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcEditFeed')"><?= __('Edit this feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcRemoveFeed')"><?= __('Unsubscribe') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?= __('All feeds:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcCatchupAll')"><?= __('Mark as read') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcShowOnlyUnread')"><?= __('(Un)hide read feeds') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?= __('Other actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcToggleWidescreen')"><?= __('Toggle widescreen mode') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcHKhelp')"><?= __('Keyboard shortcuts help') ?></div>
<?php
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_ACTION_ITEM) as $p) {
echo $p->hook_action_item();
}
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_ACTION_ITEM, function ($result) {
echo $result;
});
?>
<?php if (!$_SESSION["hide_logout"]) { ?>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcLogout')"><?php echo __('Logout') ?></div>
<?php if (empty($_SESSION["hide_logout"])) { ?>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcLogout')"><?= __('Logout') ?></div>
<?php } ?>
</div>
</div>
@ -274,7 +276,7 @@
<div id="headlines-frame" dojoType="dijit.layout.ContentPane" tabindex="0"
region="center">
<div id="headlinesInnerContainer">
<div class="whiteBox"><?php echo __('Loading, please wait...') ?></div>
<div class="whiteBox"><?= __('Loading, please wait...') ?></div>
</div>
</div>
<div id="content-insert" dojoType="dijit.layout.ContentPane" region="bottom"

@ -1,503 +0,0 @@
<?php
function stylesheet_tag($filename, $id = false) {
$timestamp = filemtime($filename);
$id_part = $id ? "id=\"$id\"" : "";
return "<link rel=\"stylesheet\" $id_part type=\"text/css\" href=\"$filename?$timestamp\"/>\n";
}
function javascript_tag($filename) {
$query = "";
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";
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Tiny Tiny RSS - Installer</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css">
textarea { font-size : 12px; }
</style>
<?php
echo stylesheet_tag("../themes/light.css");
echo javascript_tag("../lib/prototype.js");
echo javascript_tag("../lib/dojo/dojo.js");
echo javascript_tag("../lib/dojo/tt-rss-layer.js");
?>
</head>
<body class="flat ttrss_utility installer">
<script type="text/javascript">
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
ready(function() {
parser.parse();
});
});
</script>
<?php
// could be needed because of existing config.php
function define_default($param, $value) {
//
}
function make_password($length = 12) {
$password = "";
$possible = "0123456789abcdfghjkmnpqrstvwxyzABCDFGHJKMNPQRSTVWXYZ*%+^";
$i = 0;
while ($i < $length) {
try {
$idx = function_exists("random_int") ? random_int(0, strlen($possible) - 1) : mt_rand(0, strlen($possible) - 1);
} catch (Exception $e) {
$idx = mt_rand(0, strlen($possible) - 1);
}
$char = substr($possible, $idx, 1);
if (!strstr($password, $char)) {
$password .= $char;
$i++;
}
}
return $password;
}
function sanity_check($db_type) {
$errors = array();
if (version_compare(PHP_VERSION, '5.6.0', '<')) {
array_push($errors, "PHP version 5.6.0 or newer required. You're using " . PHP_VERSION . ".");
}
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 (!function_exists("iconv")) {
array_push($errors, "PHP support for iconv is required to handle multiple charsets.");
}
if (ini_get("safe_mode")) {
array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss.");
}
if (!class_exists("DOMDocument")) {
array_push($errors, "PHP support for DOMDocument is required, but was not found.");
}
return $errors;
}
function print_error($msg) {
print "<div class='alert alert-error'>$msg</div>";
}
function print_notice($msg) {
print "<div class=\"alert alert-info\">$msg</div>";
}
function pdo_connect($host, $user, $pass, $db, $type, $port = false) {
$db_port = $port ? ';port=' . $port : '';
$db_host = $host ? ';host=' . $host : '';
try {
$pdo = new PDO($type . ':dbname=' . $db . $db_host . $db_port,
$user,
$pass);
return $pdo;
} catch (Exception $e) {
print "<div class='alert alert-danger'>" . $e->getMessage() . "</div>";
return null;
}
}
function make_config($DB_TYPE, $DB_HOST, $DB_USER, $DB_NAME, $DB_PASS,
$DB_PORT, $SELF_URL_PATH) {
$rv = file_get_contents("../config.php-dist");
$escape_chars = "\\'";
$settings = [
"%DB_TYPE" => $DB_TYPE == 'pgsql' ? 'pgsql' : 'mysql',
"%DB_HOST" => addcslashes($DB_HOST, $escape_chars),
"%DB_USER" => addcslashes($DB_USER, $escape_chars),
"%DB_NAME" => addcslashes($DB_NAME, $escape_chars),
"%DB_PASS" => addcslashes($DB_PASS, $escape_chars),
"%DB_PORT" => $DB_PORT ? intval($DB_PORT) : '',
"%SELF_URL_PATH" => addcslashes($SELF_URL_PATH, $escape_chars)
];
$rv = str_replace(array_keys($settings), array_values($settings), $rv);
return $rv;
}
function is_server_https() {
return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https');
}
function make_self_url_path() {
$url_path = (is_server_https() ? 'https://' : 'http://') . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
return $url_path;
}
?>
<h1>Tiny Tiny RSS Installer</h1>
<div class='content'>
<?php
if (file_exists("../config.php")) {
require "../config.php";
if (!defined('_INSTALLER_IGNORE_CONFIG_CHECK')) {
print_error("Error: config.php already exists in tt-rss directory; aborting.");
print "<form method='GET' action='../index.php'>
<button type='submit' dojoType='dijit.form.Button' class='alt-primary'>Return to Tiny Tiny RSS</button>
</form>";
exit;
}
}
@$op = $_REQUEST['op'];
@$DB_HOST = strip_tags($_POST['DB_HOST']);
@$DB_TYPE = strip_tags($_POST['DB_TYPE']);
@$DB_USER = strip_tags($_POST['DB_USER']);
@$DB_NAME = strip_tags($_POST['DB_NAME']);
@$DB_PASS = strip_tags($_POST['DB_PASS']);
@$DB_PORT = strip_tags($_POST['DB_PORT']);
@$SELF_URL_PATH = strip_tags($_POST['SELF_URL_PATH']);
if (!$SELF_URL_PATH) {
$SELF_URL_PATH = preg_replace("/\/install\/$/", "/", make_self_url_path());
}
?>
<form action="" method="post">
<input type="hidden" name="op" value="testconfig">
<h2>Database settings</h2>
<?php
$issel_pgsql = $DB_TYPE == "pgsql" ? "selected='selected'" : "";
$issel_mysql = $DB_TYPE == "mysql" ? "selected='selected'" : "";
?>
<fieldset>
<label>Database type:</label>
<select name="DB_TYPE" dojoType="dijit.form.Select">
<option <?php echo $issel_pgsql ?> value="pgsql">PostgreSQL</option>
<option <?php echo $issel_mysql ?> value="mysql">MySQL</option>
</select>
</fieldset>
<fieldset>
<label>Username:</label>
<input dojoType="dijit.form.TextBox" required name="DB_USER" size="20" value="<?php echo htmlspecialchars($DB_USER) ?>"/>
</fieldset>
<fieldset>
<label>Password:</label>
<input dojoType="dijit.form.TextBox" name="DB_PASS" size="20" type="password" value="<?php echo htmlspecialchars($DB_PASS) ?>"/>
</fieldset>
<fieldset>
<label>Database name:</label>
<input dojoType="dijit.form.TextBox" required name="DB_NAME" size="20" value="<?php echo htmlspecialchars($DB_NAME) ?>"/>
</fieldset>
<fieldset>
<label>Host name:</label>
<input dojoType="dijit.form.TextBox" name="DB_HOST" size="20" value="<?php echo htmlspecialchars($DB_HOST) ?>"/>
<span class="hint">If needed</span>
</fieldset>
<fieldset>
<label>Port:</label>
<input dojoType="dijit.form.TextBox" name="DB_PORT" type="number" size="20" value="<?php echo htmlspecialchars($DB_PORT) ?>"/>
<span class="hint">Usually 3306 for MySQL or 5432 for PostgreSQL</span>
</fieldset>
<h2>Other settings</h2>
<p>This should be set to the location your Tiny Tiny RSS will be available on.</p>
<fieldset>
<label>Tiny Tiny RSS URL:</label>
<input dojoType="dijit.form.TextBox" type="url" name="SELF_URL_PATH" placeholder="<?php echo htmlspecialchars($SELF_URL_PATH); ?>" value="<?php echo htmlspecialchars($SELF_URL_PATH) ?>"/>
</fieldset>
<p><button type="submit" dojoType="dijit.form.Button" class="alt-primary">Test configuration</button></p>
</form>
<?php if ($op == 'testconfig') { ?>
<h2>Checking configuration</h2>
<?php
$errors = sanity_check($DB_TYPE);
if (count($errors) > 0) {
print "<p>Some configuration tests failed. Please correct them before continuing.</p>";
print "<ul>";
foreach ($errors as $error) {
print "<li style='color : red'>$error</li>";
}
print "</ul>";
exit;
}
$notices = array();
if (!function_exists("curl_init")) {
array_push($notices, "It is highly recommended to enable support for CURL in PHP.");
}
if (function_exists("curl_init") && ini_get("open_basedir")) {
array_push($notices, "CURL and open_basedir combination breaks support for HTTP redirects. See the FAQ for more information.");
}
if (!function_exists("idn_to_ascii")) {
array_push($notices, "PHP support for Internationalization Functions is required to handle Internationalized Domain Names.");
}
if ($DB_TYPE == "mysql" && !function_exists("mysqli_connect")) {
array_push($notices, "PHP extension for MySQL (mysqli) is missing. This may prevent legacy plugins from working.");
}
if ($DB_TYPE == "pgsql" && !function_exists("pg_connect")) {
array_push($notices, "PHP extension for PostgreSQL is missing. This may prevent legacy plugins from working.");
}
if (count($notices) > 0) {
print_notice("Configuration check succeeded with minor problems:");
print "<ul>";
foreach ($notices as $notice) {
print "<li>$notice</li>";
}
print "</ul>";
} else {
print_notice("Configuration check succeeded.");
}
?>
<h2>Checking database</h2>
<?php
$pdo = pdo_connect($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_TYPE, $DB_PORT);
if (!$pdo) {
print_error("Unable to connect to database using specified parameters (driver: " . htmlspecialchars($DB_TYPE) . ").");
exit;
}
print_notice("Database test succeeded.");
?>
<h2>Initialize database</h2>
<p>Before you can start using tt-rss, database needs to be initialized. Click on the button below to do that now.</p>
<?php
$res = $pdo->query("SELECT true FROM ttrss_feeds");
if ($res && $res->fetch()) {
print_error("Some tt-rss data already exists in this database. If you continue with database initialization your current data <b>WILL BE LOST</b>.");
$need_confirm = true;
} else {
$need_confirm = false;
}
?>
<table><tr><td>
<form method="post">
<input type="hidden" name="op" value="installschema">
<input type="hidden" name="DB_USER" value="<?php echo htmlspecialchars($DB_USER) ?>"/>
<input type="hidden" name="DB_PASS" value="<?php echo htmlspecialchars($DB_PASS) ?>"/>
<input type="hidden" name="DB_NAME" value="<?php echo htmlspecialchars($DB_NAME) ?>"/>
<input type="hidden" name="DB_HOST" value="<?php echo htmlspecialchars($DB_HOST) ?>"/>
<input type="hidden" name="DB_PORT" value="<?php echo htmlspecialchars($DB_PORT) ?>"/>
<input type="hidden" name="DB_TYPE" value="<?php echo htmlspecialchars($DB_TYPE) ?>"/>
<input type="hidden" name="SELF_URL_PATH" value="<?php echo htmlspecialchars($SELF_URL_PATH) ?>"/>
<p>
<?php if ($need_confirm) { ?>
<button onclick="return confirm('Please read the warning above. Continue?')" type="submit"
class="alt-danger" dojoType="dijit.form.Button">Initialize database</button>
<?php } else { ?>
<button type="submit" class="alt-danger" dojoType="dijit.form.Button">Initialize database</button>
<?php } ?>
</p>
</form>
</td><td>
<form method="post">
<input type="hidden" name="DB_USER" value="<?php echo htmlspecialchars($DB_USER) ?>"/>
<input type="hidden" name="DB_PASS" value="<?php echo htmlspecialchars($DB_PASS) ?>"/>
<input type="hidden" name="DB_NAME" value="<?php echo htmlspecialchars($DB_NAME) ?>"/>
<input type="hidden" name="DB_HOST" value="<?php echo htmlspecialchars($DB_HOST) ?>"/>
<input type="hidden" name="DB_PORT" value="<?php echo htmlspecialchars($DB_PORT) ?>"/>
<input type="hidden" name="DB_TYPE" value="<?php echo htmlspecialchars($DB_TYPE) ?>"/>
<input type="hidden" name="SELF_URL_PATH" value="<?php echo htmlspecialchars($SELF_URL_PATH) ?>"/>
<input type="hidden" name="op" value="skipschema">
<p><button type="submit" dojoType="dijit.form.Button">Skip initialization</button></p>
</form>
</td></tr></table>
<?php
} else if ($op == 'installschema' || $op == 'skipschema') {
$pdo = pdo_connect($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_TYPE, $DB_PORT);
if (!$pdo) {
print_error("Unable to connect to database using specified parameters.");
exit;
}
if ($op == 'installschema') {
print "<h2>Initializing database...</h2>";
$lines = explode(";", preg_replace("/[\r\n]/", "",
file_get_contents("../schema/ttrss_schema_".basename($DB_TYPE).".sql")));
foreach ($lines as $line) {
if (strpos($line, "--") !== 0 && $line) {
$res = $pdo->query($line);
if (!$res) {
print_notice("Query: $line");
print_error("Error: " . implode(", ", $pdo->errorInfo()));
}
}
}
print_notice("Database initialization completed.");
} else {
print_notice("Database initialization skipped.");
}
print "<h2>Generated configuration file</h2>";
print "<p>Copy following text and save as <code>config.php</code> in tt-rss main directory. It is suggested to read through the file to the end in case you need any options changed fom default values.</p>";
print "<p>After copying the file, you will be able to login with default username and password combination: <code>admin</code> and <code>password</code>. Don't forget to change the password immediately!</p>"; ?>
<form action="" method="post">
<input type="hidden" name="op" value="saveconfig">
<input type="hidden" name="DB_USER" value="<?php echo htmlspecialchars($DB_USER) ?>"/>
<input type="hidden" name="DB_PASS" value="<?php echo htmlspecialchars($DB_PASS) ?>"/>
<input type="hidden" name="DB_NAME" value="<?php echo htmlspecialchars($DB_NAME) ?>"/>
<input type="hidden" name="DB_HOST" value="<?php echo htmlspecialchars($DB_HOST) ?>"/>
<input type="hidden" name="DB_PORT" value="<?php echo htmlspecialchars($DB_PORT) ?>"/>
<input type="hidden" name="DB_TYPE" value="<?php echo htmlspecialchars($DB_TYPE) ?>"/>
<input type="hidden" name="SELF_URL_PATH" value="<?php echo htmlspecialchars($SELF_URL_PATH) ?>"/>
<?php print "<textarea rows='20' style='width : 100%'>";
echo htmlspecialchars(make_config($DB_TYPE, $DB_HOST, $DB_USER, $DB_NAME, $DB_PASS,
$DB_PORT, $SELF_URL_PATH));
print "</textarea>"; ?>
<hr/>
<?php if (is_writable("..")) { ?>
<p>We can also try saving the file automatically now.</p>
<p><button type="submit" dojoType='dijit.form.Button' class='alt-primary'>Save configuration</button></p>
</form>
<?php } else {
print_error("Unfortunately, parent directory is not writable, so we're unable to save config.php automatically.");
}
print_notice("You can generate the file again by changing the form above.");
} else if ($op == "saveconfig") {
print "<h2>Saving configuration file to parent directory...</h2>";
if (!file_exists("../config.php")) {
$fp = fopen("../config.php", "w");
if ($fp) {
$written = fwrite($fp, make_config($DB_TYPE, $DB_HOST,
$DB_USER, $DB_NAME, $DB_PASS,
$DB_PORT, $SELF_URL_PATH));
if ($written > 0) {
print_notice("Successfully saved config.php. You can try <a href=\"..\">loading tt-rss now</a>.");
} else {
print_notice("Unable to write into config.php in tt-rss directory.");
}
fclose($fp);
} else {
print_error("Unable to open config.php in tt-rss directory for writing.");
}
} else {
print_error("config.php already present in tt-rss directory, refusing to overwrite.");
}
}
?>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -1,6 +1,7 @@
'use strict'
/* global __, ngettext, App, Headlines, xhrPost, xhrJson, dojo, dijit, PluginHost, Notify, $$, Ajax */
/* eslint-disable no-new */
/* global __, ngettext, App, Headlines, xhr, dojo, dijit, PluginHost, Notify, fox */
const Article = {
_scroll_reset_timeout: false,
@ -35,19 +36,19 @@ const Article = {
const score = prompt(__("Please enter new score for selected articles:"));
if (!isNaN(parseInt(score))) {
ids.each((id) => {
const row = $("RROW-" + id);
ids.forEach((id) => {
const row = App.byId(`RROW-${id}`);
if (row) {
row.setAttribute("data-score", score);
const pic = row.select(".icon-score")[0];
const pic = row.querySelector(".icon-score");
pic.innerHTML = Article.getScorePic(score);
pic.setAttribute("title", score);
["score-low", "score-high", "score-half-low", "score-half-high", "score-neutral"]
.each(function(scl) {
.forEach(function(scl) {
if (row.hasClassName(scl))
row.removeClassName(scl);
});
@ -62,7 +63,7 @@ const Article = {
}
},
setScore: function (id, pic) {
const row = pic.up("div[id*=RROW]");
const row = pic.closest("div[id*=RROW]");
if (row) {
const score_old = row.getAttribute("data-score");
@ -71,13 +72,13 @@ const Article = {
if (!isNaN(parseInt(score))) {
row.setAttribute("data-score", score);
const pic = row.select(".icon-score")[0];
const pic = row.querySelector(".icon-score");
pic.innerHTML = Article.getScorePic(score);
pic.setAttribute("title", score);
["score-low", "score-high", "score-half-low", "score-half-high", "score-neutral"]
.each(function(scl) {
.forEach(function(scl) {
if (row.hasClassName(scl))
row.removeClassName(scl);
});
@ -92,18 +93,18 @@ const Article = {
w.opener = null;
w.location = url;
},
/* popupOpenArticle: function(id) {
const w = window.open("",
"ttrss_article_popup",
"height=900,width=900,resizable=yes,status=no,location=no,menubar=no,directories=no,scrollbars=yes,toolbar=no");
if (w) {
w.opener = null;
w.location = "backend.php?op=article&method=view&mode=raw&html=1&zoom=1&id=" + id + "&csrf_token=" + App.getInitParam("csrf_token");
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 = $("RROW-" + Article.getActive());
const row = App.byId(`RROW-${Article.getActive()}`);
if (row) {
row.removeClassName("active");
@ -122,11 +123,13 @@ const Article = {
Article.setActive(0);
},
displayUrl: function (id) {
const query = {op: "rpc", method: "getlinktitlebyid", id: id};
const query = {op: "article", method: "getmetadatabyid", id: id};
xhrJson("backend.php", query, (reply) => {
xhr.json("backend.php", query, (reply) => {
if (reply && reply.link) {
prompt(__("Article URL:"), reply.link);
} else {
alert(__("No URL could be displayed for this article."));
}
});
},
@ -137,6 +140,82 @@ const Article = {
Headlines.toggleUnread(id, 0);
},
renderNote: function (id, note) {
return `<div class="article-note" data-note-for="${id}" style="display : ${note ? "" : "none"}">
${App.FormFields.icon('note')} <div onclick class='body'>${note ? App.escapeHtml(note) : ""}</div>
</div>`;
},
renderTags: function (id, tags) {
const tags_short = tags.length > 5 ? tags.slice(0, 5) : tags;
return `<span class="tags" title="${tags.join(", ")}" data-tags-for="${id}">
${tags_short.length > 0 ? tags_short.map((tag) => `
<a href="#" onclick="Feeds.open({feed: '${tag.trim()}'})" class="tag">${tag}</a>`
).join(", ") : `${__("no tags")}`}</span>`;
},
renderLabels: function(id, labels) {
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 `
${enclosures.formatted}
${enclosures.can_inline ?
`<div class='attachments-inline'>
${enclosures.entries.map((enc) => {
if (!enclosures.inline_text_only) {
if (enc.content_type && enc.content_type.indexOf("image/") != -1) {
return `<p>
<img loading="lazy"
width="${enc.width ? enc.width : ''}"
height="${enc.height ? enc.height : ''}"
src="${App.escapeHtml(enc.content_url)}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"/>
</p>`
} else if (enc.content_type && enc.content_type.indexOf("audio/") != -1 && App.audioCanPlay(enc.content_type)) {
return `<p class='inline-player' title="${App.escapeHtml(enc.content_url)}">
<audio preload="none" controls="controls">
<source type="${App.escapeHtml(enc.content_type)}" src="${App.escapeHtml(enc.content_url)}"/>
</audio>
</p>
`;
} else {
return `<p>
<a target="_blank" href="${App.escapeHtml(enc.content_url)}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"
rel="noopener noreferrer">${App.escapeHtml(enc.content_url)}</a>
</p>`
}
} else {
return `<p>
<a target="_blank" href="${App.escapeHtml(enc.content_url)}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"
rel="noopener noreferrer">${App.escapeHtml(enc.content_url)}</a>
</p>`
}
}).join("")}
</div>` : ''}
${enclosures.entries.length > 0 ?
`<div class="attachments" dojoType="fox.form.DropDownButton">
<span>${__('Attachments')}</span>
<div dojoType="dijit.Menu" style="display: none">
${enclosures.entries.map((enc) => `
<div onclick='Article.popupOpenUrl("${App.escapeHtml(enc.content_url)}")'
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}" dojoType="dijit.MenuItem">
${enc.title ? enc.title : enc.filename}
</div>
`).join("")}
</div>
</div>` : ''}
`
},
render: function (article) {
App.cleanupMemory("content-insert");
@ -175,39 +254,41 @@ const Article = {
return comments;
},
formatOriginallyFrom: function(hl) {
return hl.orig_feed ? `<span>
${__('Originally from:')} <a target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.orig_feed[1])}">${hl.orig_feed[0]}</a>
</span>` : "";
},
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);
// blank content element might screw up onclick selection and keyboard moving
if (container.textContent.length == 0)
container.innerHTML += "&nbsp;";
// in expandable mode, save content for later, so that we can pack unfocused rows back
if (App.isCombinedMode() && $("main").hasClassName("expandable"))
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");
row.querySelector(".content-inner").innerHTML = "&nbsp;";
const content_inner = row.querySelector(".content-inner");
// 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) {
@ -220,7 +301,6 @@ const Article = {
if (hl) {
const comments = this.formatComments(hl);
const originally_from = this.formatOriginallyFrom(hl);
const article = `<div class="post post-${hl.id}" data-article-id="${hl.id}">
<div class="header">
@ -235,17 +315,16 @@ const Article = {
<div class="comments">${comments}</div>
<div class="author">${hl.author}</div>
<i class="material-icons">label_outline</i>
<span id="ATSTR-${hl.id}">${hl.tags_str}</span>
${Article.renderTags(hl.id, hl.tags)}
&nbsp;<a title="${__("Edit tags for this article")}" href="#"
onclick="Article.editTags(${hl.id})">(+)</a>
<div class="buttons right">${hl.buttons}</div>
</div>
</div>
<div id="POSTNOTE-${hl.id}">${hl.note}</div>
${Article.renderNote(hl.id, hl.note)}
<div class="content" lang="${hl.lang ? hl.lang : 'en'}">
${originally_from}
${hl.content}
${hl.enclosures}
${Article.renderEnclosures(hl.enclosures)}
</div>
</div>`;
@ -257,81 +336,94 @@ const Article = {
return false;
},
editTags: function (id) {
if (dijit.byId("editTagsDlg"))
dijit.byId("editTagsDlg").destroyRecursive();
xhrPost("backend.php", {op: "article", method: "editarticletags", param: id}, (transport) => {
const dialog = new dijit.Dialog({
id: "editTagsDlg",
title: __("Edit article Tags"),
style: "width: 600px",
content: transport.responseText,
execute: function () {
if (this.validate()) {
Notify.progress("Saving article tags...", true);
xhrPost("backend.php", this.attr('value'), (transport) => {
try {
Notify.close();
dialog.hide();
const data = JSON.parse(transport.responseText);
if (data) {
const id = data.id;
const dialog = new fox.SingleUseDialog({
title: __("Article tags"),
content: `
${App.FormFields.hidden_tag("id", id.toString())}
${App.FormFields.hidden_tag("op", "article")}
${App.FormFields.hidden_tag("method", "setArticleTags")}
<header class='horizontal'>
${__("Tags for this article (separated by commas):")}
</header>
<section>
<textarea dojoType='dijit.form.SimpleTextarea' rows='4' disabled='true'
id='tags_str' name='tags_str'>${__("Loading, please wait...")}</textarea>
<div class='autocomplete' id='tags_choices' style='display:none'></div>
</section>
<footer>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
`,
execute: function () {
if (this.validate()) {
Notify.progress("Saving article tags...", true);
xhr.json("backend.php", this.attr('value'), (data) => {
try {
Notify.close();
dialog.hide();
Headlines.onTagsUpdated(data);
} catch (e) {
App.Error.report(e);
}
});
}
},
});
const tags = $("ATSTR-" + id);
const tooltip = dijit.byId("ATSTRTIP-" + id);
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
if (tags) tags.innerHTML = data.content;
if (tooltip) tooltip.attr('label', data.content_full);
}
} catch (e) {
App.Error.report(e);
}
});
}
},
});
xhr.json("backend.php", {op: "article", method: "printArticleTags", id: id}, (reply) => {
const tmph = dojo.connect(dialog, 'onLoad', function () {
dojo.disconnect(tmph);
dijit.getEnclosingWidget(App.byId("tags_str"))
.attr('value', reply.tags.join(", "))
.attr('disabled', false);
new Ajax.Autocompleter('tags_str', 'tags_choices',
/* new Ajax.Autocompleter("tags_str", "tags_choices",
"backend.php?op=article&method=completeTags",
{tokens: ',', paramName: "search"});
{tokens: ',', paramName: "search"}); */
});
dialog.show();
});
},
cdmMoveToId: function (id, params) {
params = params || {};
dialog.show();
},
cdmMoveToId: function (id, params = {}) {
const force_to_top = params.force_to_top || false;
const ctr = $("headlines-frame");
const row = $("RROW-" + id);
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;
if (force_to_top || !App.Scrollable.fitsInContainer(row, ctr)) {
ctr.scrollTop = row.offsetTop - grid_gap;
}
}
},
setActive: function (id) {
if (id != Article.getActive()) {
console.log("setActive", id, "was", Article.getActive());
$$("div[id*=RROW][class*=active]").each((row) => {
App.findAll("div[id*=RROW][class*=active]").forEach((row) => {
row.removeClassName("active");
Article.pack(row);
if (App.isCombinedMode() && !App.getInitParam("cdm_expanded"))
Article.pack(row);
});
const row = $("RROW-" + id);
const row = App.byId(`RROW-${id}`);
if (row) {
Article.unpack(row);
@ -352,10 +444,10 @@ const Article = {
return 0;
},
scrollByPages: function (page_offset) {
App.Scrollable.scrollByPages($("content-insert"), page_offset);
App.Scrollable.scrollByPages(App.byId("content-insert"), page_offset);
},
scroll: function (offset) {
App.Scrollable.scroll($("content-insert"), offset);
App.Scrollable.scroll(App.byId("content-insert"), offset);
},
mouseIn: function (id) {
this.post_under_pointer = id;

@ -1,6 +1,9 @@
'use strict'
/* global __, ngettext, dojo, dijit, Notify, App, Feeds, $$, xhrPost, xhrJson, Tables, Effect */
/* eslint-disable new-cap */
/* eslint-disable no-new */
/* global __, dojo, dijit, Notify, App, Feeds, xhr, Tables, fox */
/* exported CommonDialogs */
const CommonDialogs = {
@ -8,95 +11,99 @@ const CommonDialogs = {
const dialog = dijit.byId("infoBox");
if (dialog) dialog.hide();
},
removeFeedIcon: function(id) {
if (confirm(__("Remove stored feed icon?"))) {
Notify.progress("Removing feed icon...", true);
const query = {op: "pref-feeds", method: "removeicon", feed_id: id};
xhrPost("backend.php", query, () => {
Notify.info("Feed icon removed.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = $$(".feed-editor-icon")[0];
if (icon)
icon.src = icon.src.replace(/\?[0-9]+$/, "?" + new Date().getTime());
});
}
return false;
},
uploadFeedIcon: function() {
const file = $("icon_file");
if (file.value.length == 0) {
alert(__("Please select an image file to upload."));
} else if (confirm(__("Upload new icon for this feed?"))) {
Notify.progress("Uploading, please wait...", true);
const xhr = new XMLHttpRequest();
xhr.open( 'POST', 'backend.php', true );
xhr.onload = function () {
switch (parseInt(this.responseText)) {
case 0:
{
Notify.info("Upload complete.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = $$(".feed-editor-icon")[0];
if (icon)
icon.src = icon.src.replace(/\?[0-9]+$/, "?" + new Date().getTime());
}
break;
case 1:
Notify.error("Upload failed: icon is too big.");
break;
case 2:
Notify.error("Upload failed.");
break;
}
};
xhr.send(new FormData($("feed_icon_upload_form")));
}
return false;
},
quickAddFeed: function() {
// overlapping widgets
if (dijit.byId("batchSubDlg")) dijit.byId("batchSubDlg").destroyRecursive();
if (dijit.byId("feedAddDlg")) dijit.byId("feedAddDlg").destroyRecursive();
xhrPost("backend.php",
{op: "feeds", method: "quickAddFeed"},
(transport) => {
const dialog = new dijit.Dialog({
id: "feedAddDlg",
title: __("Subscribe to Feed"),
style: "width: 600px",
content: transport.responseText,
subscribeToFeed: function() {
xhr.json("backend.php",
{op: "feeds", method: "subscribeToFeed"},
(reply) => {
const dialog = new fox.SingleUseDialog({
title: __("Subscribe to feed"),
content: `
<form onsubmit='return false'>
${App.FormFields.hidden_tag("op", "feeds")}
${App.FormFields.hidden_tag("method", "add")}
<div id='fadd_error_message' style='display : none' class='alert alert-danger'></div>
<div id='fadd_multiple_notify' style='display : none'>
<div class='alert alert-info'>
${__("Provided URL is a HTML page referencing multiple feeds, please select required feed from the dropdown menu below.")}
</div>
</div>
<section>
<fieldset>
<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'
required='1' name='feed' id='feedDlg_feedUrl'>
</fieldset>
${App.getInitParam('enable_feed_cats') ?
`
<fieldset>
<label class='inline'>${__('Place in category:')}</label>
${reply.cat_select}
</fieldset>
` : ''}
</section>
<div id="feedDlg_feedsContainer" style="display : none">
<header>${__('Available feeds')}</header>
<section>
<fieldset>
<select id="feedDlg_feedContainerSelect"
dojoType="fox.form.Select" size="3">
<script type="dojo/method" event="onChange" args="value">
dijit.byId("feedDlg_feedUrl").attr("value", value);
</script>
</select>
</fieldset>
</section>
</div>
<div id='feedDlg_loginContainer' style='display : none'>
<section>
<fieldset>
<input dojoType="dijit.form.TextBox" name='login'"
placeHolder="${__("Login")}"
autocomplete="new-password"
style="width : 10em;">
<input
placeHolder="${__("Password")}"
dojoType="dijit.form.TextBox" type='password'
autocomplete="new-password"
style="width : 10em;" name='pass'">
</fieldset>
</section>
</div>
<section>
<label class='checkbox'>
<input type='checkbox' name='need_auth' dojoType='dijit.form.CheckBox' id='feedDlg_loginCheck'
onclick='App.displayIfChecked(this, "feedDlg_loginContainer")'>
${__('This feed requires authentication.')}
</label>
</section>
<footer>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'
onclick='App.dialogOf(this).execute()'>
${__('Subscribe')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
</form>
`,
show_error: function (msg) {
const elem = $("fadd_error_message");
const elem = App.byId("fadd_error_message");
elem.innerHTML = msg;
if (!Element.visible(elem))
new Effect.Appear(elem);
Element.show(elem);
},
execute: function () {
if (this.validate()) {
@ -107,17 +114,12 @@ const CommonDialogs = {
Element.show("feed_add_spinner");
Element.hide("fadd_error_message");
xhrPost("backend.php", this.attr('value'), (transport) => {
xhr.json("backend.php", this.attr('value'), (reply) => {
try {
let reply;
try {
reply = JSON.parse(transport.responseText);
} catch (e) {
if (!reply) {
Element.hide("feed_add_spinner");
alert(__("Failed to parse output. This can indicate server timeout and/or network issues. Backend output was logged to browser console."));
console.log('quickAddFeed, backend returned:' + transport.responseText);
return;
}
@ -164,7 +166,7 @@ const CommonDialogs = {
}
}
Effect.Appear('feedDlg_feedsContainer', {duration: 0.5});
Element.show('feedDlg_feedsContainer');
}
break;
case 5:
@ -179,7 +181,7 @@ const CommonDialogs = {
}
} catch (e) {
console.error(transport.responseText);
console.error(reply);
App.Error.report(e);
}
});
@ -191,197 +193,103 @@ const CommonDialogs = {
});
},
showFeedsWithErrors: function() {
const query = {op: "pref-feeds", method: "feedsWithErrors"};
if (dijit.byId("errorFeedsDlg"))
dijit.byId("errorFeedsDlg").destroyRecursive();
const dialog = new dijit.Dialog({
id: "errorFeedsDlg",
title: __("Feeds with update errors"),
style: "width: 600px",
getSelectedFeeds: function () {
return Tables.getSelected("error-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
Notify.close();
dialog.hide();
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
});
}
} else {
alert(__("No feeds selected."));
}
},
execute: function () {
if (this.validate()) {
//
}
},
href: "backend.php?" + dojo.objectToQuery(query)
});
dialog.show();
},
feedBrowser: function() {
const query = {op: "feeds", method: "feedBrowser"};
if (dijit.byId("feedAddDlg"))
dijit.byId("feedAddDlg").hide();
if (dijit.byId("feedBrowserDlg"))
dijit.byId("feedBrowserDlg").destroyRecursive();
// noinspection JSUnusedGlobalSymbols
const dialog = new dijit.Dialog({
id: "feedBrowserDlg",
title: __("More Feeds"),
style: "width: 600px",
getSelectedFeedIds: function () {
const list = $$("#browseFeedList li[id*=FBROW]");
const selected = [];
list.each(function (child) {
const id = child.id.replace("FBROW-", "");
if (child.hasClassName('Selected')) {
selected.push(id);
}
});
return selected;
},
getSelectedFeeds: function () {
const list = $$("#browseFeedList li.Selected");
const selected = [];
list.each(function (child) {
const title = child.getElementsBySelector("span.fb_feedTitle")[0].innerHTML;
const url = child.getElementsBySelector("a.fb_feedUrl")[0].href;
selected.push([title, url]);
});
return selected;
},
subscribe: function () {
const mode = this.attr('value').mode;
let selected = [];
if (mode == "1")
selected = this.getSelectedFeeds();
else
selected = this.getSelectedFeedIds();
if (selected.length > 0) {
dijit.byId("feedBrowserDlg").hide();
Notify.progress("Loading, please wait...", true);
const query = {
op: "rpc", method: "massSubscribe",
payload: JSON.stringify(selected), mode: mode
};
xhrPost("backend.php", query, () => {
Notify.close();
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
});
xhr.json("backend.php", {op: "pref-feeds", method: "feedsWithErrors"}, (reply) => {
} else {
alert(__("No feeds selected."));
}
const dialog = new fox.SingleUseDialog({
id: "errorFeedsDlg",
title: __("Feeds with update errors"),
getSelectedFeeds: function () {
return Tables.getSelected("error-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
},
update: function () {
Element.show('feed_browser_spinner');
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
xhrPost("backend.php", dialog.attr("value"), (transport) => {
Notify.close();
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
Element.hide('feed_browser_spinner');
xhr.post("backend.php", query, () => {
Notify.close();
dialog.hide();
const reply = JSON.parse(transport.responseText);
const mode = reply['mode'];
if ($("browseFeedList") && reply['content']) {
$("browseFeedList").innerHTML = reply['content'];
}
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
dojo.parser.parse("browseFeedList");
});
}
if (mode == 2) {
Element.show(dijit.byId('feed_archive_remove').domNode);
} else {
Element.hide(dijit.byId('feed_archive_remove').domNode);
}
});
},
removeFromArchive: function () {
const selected = this.getSelectedFeedIds();
if (selected.length > 0) {
if (confirm(__("Remove selected feeds from the archive? Feeds with stored articles will not be removed."))) {
Element.show('feed_browser_spinner');
const query = {op: "rpc", method: "remarchive", ids: selected.toString()};
xhrPost("backend.php", query, () => {
dialog.update();
});
alert(__("No feeds selected."));
}
}
},
execute: function () {
if (this.validate()) {
this.subscribe();
}
},
href: "backend.php?" + dojo.objectToQuery(query)
});
},
content: `
<div dojoType="fox.Toolbar">
<div dojoType="fox.form.DropDownButton">
<span>${__('Select')}</span>
<div dojoType="dijit.Menu" style="display: none">
<div onclick="Tables.select('error-feeds-list', true)"
dojoType="dijit.MenuItem">${__('All')}</div>
<div onclick="Tables.select('error-feeds-list', false)"
dojoType="dijit.MenuItem">${__('None')}</div>
</div>
</div>
</div>
<div class='panel panel-scrollable'>
<table width='100%' id='error-feeds-list'>
${reply.map((row) => `
<tr data-row-id='${row.id}'>
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this)' dojoType="dijit.form.CheckBox"
type="checkbox">
</td>
<td>
<a href="#" title="${__("Click to edit feed")}" onclick="CommonDialogs.editFeed(${row.id})">
${App.escapeHtml(row.title)}
</a>
</td>
<td class='text-muted small' align='right' width='50%'>
${App.escapeHtml(row.last_error)}
</td>
</tr>
`).join("")}
</table>
</div>
<footer>
<button style='float : left' class='alt-danger' dojoType='dijit.form.Button' onclick='App.dialogOf(this).removeSelected()'>
${__('Unsubscribe from selected feeds')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
${__('Close this window')}
</button>
</footer>
`
});
dialog.show();
dialog.show();
})
},
addLabel: function(select, callback) {
addLabel: function() {
const caption = prompt(__("Please enter label caption:"), "");
if (caption != undefined && caption.trim().length > 0) {
const query = {op: "pref-labels", method: "add", caption: caption.trim()};
if (select)
Object.extend(query, {output: "select"});
Notify.progress("Loading, please wait...", true);
xhrPost("backend.php", query, (transport) => {
if (callback) {
callback(transport);
} else if (App.isPrefs()) {
xhr.post("backend.php", query, () => {
if (dijit.byId("labelTree")) {
dijit.byId("labelTree").reload();
} else {
Feeds.reload();
@ -398,15 +306,13 @@ const CommonDialogs = {
const query = {op: "pref-feeds", quiet: 1, method: "remove", ids: feed_id};
xhrPost("backend.php", query, () => {
if (dijit.byId("feedEditDlg")) dijit.byId("feedEditDlg").hide();
xhr.post("backend.php", query, () => {
if (App.isPrefs()) {
dijit.byId("feedTree").reload();
} else {
if (feed_id == Feeds.getActive())
setTimeout(() => {
Feeds.open({feed: -5})
Feeds.openDefaultFeed();
},
100);
@ -417,29 +323,113 @@ const CommonDialogs = {
return false;
},
editFeed: function (feed) {
if (feed <= 0)
editFeed: function (feed_id) {
if (feed_id <= 0)
return alert(__("You can't edit this kind of feed."));
const query = {op: "pref-feeds", method: "editfeed", id: feed};
const query = {op: "pref-feeds", method: "editfeed", id: feed_id};
console.log("editFeed", query);
if (dijit.byId("filterEditDlg"))
dijit.byId("filterEditDlg").destroyRecursive();
const dialog = new fox.SingleUseDialog({
id: "feedEditDlg",
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();
CommonDialogs.unsubscribeFeed(feed_id);
}
},
uploadIcon: function(input) {
if (input.files.length != 0) {
const icon_file = input.files[0];
if (dijit.byId("feedEditDlg"))
dijit.byId("feedEditDlg").destroyRecursive();
if (icon_file.type.indexOf("image/") == -1) {
alert(__("Please select an image file."));
return;
}
const dialog = new dijit.Dialog({
id: "feedEditDlg",
title: __("Edit Feed"),
style: "width: 600px",
const fd = new FormData();
fd.append('icon_file', icon_file)
fd.append('feed_id', feed_id);
fd.append('op', 'pref-feeds');
fd.append('method', 'uploadIcon');
fd.append('csrf_token', App.getInitParam("csrf_token"));
const xhr = new XMLHttpRequest();
xhr.open( 'POST', 'backend.php', true );
xhr.onload = function () {
const ret = JSON.parse(this.responseText);
// TODO: make a notice box within panel content
switch (ret.rc) {
case dialog.E_ICON_FILE_TOO_LARGE:
alert(__("Icon file is too large."));
break;
case dialog.E_ICON_UPLOAD_FAILED:
alert(__("Upload failed."));
break;
case dialog.E_ICON_UPLOAD_SUCCESS:
{
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = dialog.domNode.querySelector(".feedIcon");
if (icon) {
icon.src = ret.icon_url;
icon.show();
}
input.value = "";
}
break;
default:
alert(this.responseText);
break;
}
};
xhr.send(fd);
}
},
removeIcon: function(id) {
if (confirm(__("Remove stored feed icon?"))) {
Notify.progress("Removing feed icon...", true);
xhr.post("backend.php", {op: "pref-feeds", method: "removeicon", feed_id: id}, () => {
Notify.info("Feed icon removed.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = dialog.domNode.querySelector(".feedIcon");
if (icon) {
icon.src = "";
icon.hide();
}
});
}
return false;
},
execute: function () {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhrPost("backend.php", dialog.attr('value'), () => {
xhr.post("backend.php", dialog.attr('value'), () => {
dialog.hide();
Notify.close();
@ -449,40 +439,224 @@ const CommonDialogs = {
Feeds.reload();
});
return true;
}
return false;
},
href: "backend.php?" + dojo.objectToQuery(query)
content: __("Loading, please wait...")
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhr.json("backend.php", {op: "pref-feeds", method: "editfeed", id: feed_id}, (reply) => {
const feed = reply.feed;
// for unsub prompt
dialog.feed_title = feed.title;
// options tab
const options = {
include_in_digest: [ feed.include_in_digest, __('Include in e-mail digest') ],
always_display_enclosures: [ feed.always_display_enclosures, __('Always display image attachments') ],
hide_images: [ feed.hide_images, __('Do not embed media') ],
cache_images: [ feed.cache_images, __('Cache media') ],
mark_unread_on_update: [ feed.mark_unread_on_update, __('Mark updated articles as unread') ]
};
dialog.attr('content',
`
<form onsubmit="return false">
<div dojoType="dijit.layout.TabContainer" style="height : 450px">
<div dojoType="dijit.layout.ContentPane" title="${__('General')}">
${App.FormFields.hidden_tag("id", feed_id)}
${App.FormFields.hidden_tag("op", "pref-feeds")}
${App.FormFields.hidden_tag("method", "editSave")}
<section>
<fieldset>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Feed title")}"
style='font-size : 16px; width: 530px' name='title' value="${App.escapeHtml(feed.title)}">
</fieldset>
<fieldset>
<label>${__('URL:')}</label>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Feed URL")}"
regExp='^(http|https)://.*' style='width : 300px'
name='feed_url' value="${App.escapeHtml(feed.feed_url)}">
${feed.last_error ?
`<i class="material-icons"
title="${App.escapeHtml(feed.last_error)}">error</i>
` : ""}
</fieldset>
${reply.cats.enabled ?
`
<fieldset>
<label>${__('Place in category:')}</label>
${reply.cats.select}
</fieldset>
` : ""}
<fieldset>
<label>${__('Site URL:')}</label>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Site URL")}"
regExp='^(http|https)://.*' style='width : 300px'
name='site_url' value="${App.escapeHtml(feed.site_url)}">
</fieldset>
${reply.lang.enabled ?
`
<fieldset>
<label>${__('Language:')}</label>
${App.FormFields.select_tag("feed_language",
feed.feed_language ? feed.feed_language : reply.lang.default,
reply.lang.all)}
</fieldset>
` : ""}
<hr/>
<fieldset>
<label>${__("Update interval:")}</label>
${App.FormFields.select_hash("update_interval", feed.update_interval, reply.intervals.update)}
</fieldset>
<fieldset>
<label>${__('Article purging:')}</label>
${App.FormFields.select_hash("purge_interval",
feed.purge_interval,
reply.intervals.purge,
reply.force_purge ? {disabled: 1} : {})}
</fieldset>
</section>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Authentication')}">
<section>
<fieldset>
<label>${__("Login:")}</label>
<input dojoType='dijit.form.TextBox'
autocomplete='new-password'
name='auth_login' value="${App.escapeHtml(feed.auth_login)}">
</fieldset>
<fieldset>
<label>${__("Password:")}</label>
<input dojoType='dijit.form.TextBox' type='password' name='auth_pass'
autocomplete='new-password'
value="${App.escapeHtml(feed.auth_pass)}">
</fieldset>
</section>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Options')}">
<section class="narrow">
${Object.keys(options).map((name) =>
`
<fieldset class='narrow'>
<label class="checkbox">
${App.FormFields.checkbox_tag(name, options[name][0])}
${options[name][1]}
</label>
</fieldset>
`).join("")}
</section>
</div>
<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">
${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(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(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>
`);
})
});
dialog.show();
},
genUrlChangeKey: function(feed, is_cat) {
if (confirm(__("Generate new syndication address for this feed?"))) {
generatedFeed: function(feed, is_cat, search = "") {
Notify.progress("Trying to change address...", true);
Notify.progress("Loading, please wait...", true);
const query = {op: "pref-feeds", method: "regenFeedKey", id: feed, is_cat: is_cat};
xhr.json("backend.php", {op: "pref-feeds", method: "getsharedurl", id: feed, is_cat: is_cat, search: search}, (reply) => {
try {
const dialog = new fox.SingleUseDialog({
title: __("Show as feed"),
regenFeedKey: function(feed, is_cat) {
if (confirm(__("Generate new syndication address for this feed?"))) {
xhrJson("backend.php", query, (reply) => {
const new_link = reply.link;
const e = $('gen_feed_url');
Notify.progress("Trying to change address...", true);
if (new_link) {
e.innerHTML = e.innerHTML.replace(/&amp;key=.*$/,
"&amp;key=" + new_link);
const query = {op: "pref-feeds", method: "regenFeedKey", id: feed, is_cat: is_cat};
e.href = e.href.replace(/&key=.*$/,
"&key=" + new_link);
xhr.json("backend.php", query, (reply) => {
const new_link = reply.link;
const target = this.domNode.querySelector(".generated_url");
new Effect.Highlight(e);
if (new_link && target) {
target.innerHTML = target.innerHTML.replace(/&amp;key=.*$/,
"&amp;key=" + new_link);
Notify.close();
target.href = target.href.replace(/&key=.*$/,
"&key=" + new_link);
} else {
Notify.error("Could not change feed URL.");
}
});
}
return false;
}
Notify.close();
} else {
Notify.error("Could not change feed URL.");
}
});
}
return false;
},
content: `
<header>${__("%s can be accessed via the following secret URL:").replace("%s", App.escapeHtml(reply.title))}</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>
<button dojoType='dijit.form.Button' style='float : left' class='alt-info'
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'>
${__('Close this window')}
</button>
</footer>
`
});
dialog.show();
Notify.close();
} catch (e) {
App.Error.report(e);
}
});
},
};

@ -1,291 +1,340 @@
'use strict'
/* global __, App, Article, Lists, Effect */
/* global xhrPost, dojo, dijit, Notify, $$, Feeds */
/* eslint-disable no-new */
const Filters = {
filterDlgCheckAction: function(sender) {
const action = sender.value;
const action_param = $("filterDlg_paramBox");
if (!action_param) {
console.log("filterDlgCheckAction: can't find action param box!");
return;
}
// if selected action supports parameters, enable params field
if (action == 4 || action == 6 || action == 7 || action == 9) {
new Effect.Appear(action_param, {duration: 0.5});
Element.hide(dijit.byId("filterDlg_actionParam").domNode);
Element.hide(dijit.byId("filterDlg_actionParamLabel").domNode);
Element.hide(dijit.byId("filterDlg_actionParamPlugin").domNode);
if (action == 7) {
Element.show(dijit.byId("filterDlg_actionParamLabel").domNode);
} else if (action == 9) {
Element.show(dijit.byId("filterDlg_actionParamPlugin").domNode);
} else {
Element.show(dijit.byId("filterDlg_actionParam").domNode);
}
} else {
Element.hide(action_param);
}
},
createNewRuleElement: function(parentNode, replaceNode) {
const form = document.forms["filter_new_rule_form"];
const query = {op: "pref-filters", method: "printrulename", rule: dojo.formToJson(form)};
xhrPost("backend.php", query, (transport) => {
try {
const li = dojo.create("li");
const cb = dojo.create("input", {type: "checkbox"}, li);
new dijit.form.CheckBox({
onChange: function () {
Lists.onRowChecked(this);
},
}, cb);
dojo.create("input", {
type: "hidden",
name: "rule[]",
value: dojo.formToJson(form)
}, li);
dojo.create("span", {
onclick: function () {
dijit.byId('filterEditDlg').editRule(this);
},
innerHTML: transport.responseText
}, li);
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
parentNode.appendChild(li);
}
} catch (e) {
App.Error.report(e);
}
});
},
createNewActionElement: function(parentNode, replaceNode) {
const form = document.forms["filter_new_action_form"];
if (form.action_id.value == 7) {
form.action_param.value = form.action_param_label.value;
} else if (form.action_id.value == 9) {
form.action_param.value = form.action_param_plugin.value;
}
const query = {
op: "pref-filters", method: "printactionname",
action: dojo.formToJson(form)
};
xhrPost("backend.php", query, (transport) => {
try {
const li = dojo.create("li");
const cb = dojo.create("input", {type: "checkbox"}, li);
/* global __, App, Article, Lists, fox */
/* global xhr, dojo, dijit, Notify, Feeds */
new dijit.form.CheckBox({
onChange: function () {
Lists.onRowChecked(this);
},
}, cb);
dojo.create("input", {
type: "hidden",
name: "action[]",
value: dojo.formToJson(form)
}, li);
/* exported Filters */
const Filters = {
edit: function(filter_id = null) { // if no id, new filter dialog
dojo.create("span", {
onclick: function () {
dijit.byId('filterEditDlg').editAction(this);
const dialog = new fox.SingleUseDialog({
id: "filterEditDlg",
title: filter_id ? __("Edit filter") : __("Create new filter"),
ACTION_TAG: 4,
ACTION_SCORE: 6,
ACTION_LABEL: 7,
ACTION_PLUGIN: 9,
PARAM_ACTIONS: [4, 6, 7, 9],
filter_info: {},
test: function() {
const test_dialog = new fox.SingleUseDialog({
title: "Test Filter",
results: 0,
limit: 100,
max_offset: 10000,
getTestResults: function (params, offset) {
params.method = 'testFilterDo';
params.offset = offset;
params.limit = test_dialog.limit;
console.log("getTestResults:" + offset);
xhr.json("backend.php", params, (result) => {
try {
if (result && test_dialog && test_dialog.open) {
test_dialog.results += result.length;
console.log("got results:" + result.length);
const loading_message = test_dialog.domNode.querySelector(".loading-message");
const results_list = test_dialog.domNode.querySelector(".filter-results-list");
loading_message.innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dialog.results)
.replace("%d", offset);
console.log(offset + " " + test_dialog.max_offset);
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("div", { innerHTML: result[i]});
results_list.innerHTML += tmp.innerHTML;
}
if (test_dialog.results < 30 && offset < test_dialog.max_offset) {
// get the next batch
window.setTimeout(function () {
test_dialog.getTestResults(params, offset + test_dialog.limit);
}, 0);
} else {
// all done
test_dialog.domNode.querySelector(".loading-indicator").hide();
if (test_dialog.results == 0) {
results_list.innerHTML = `<li class="text-center text-muted">
${__('No recent articles matching this filter have been found.')}</li>`;
loading_message.innerHTML = __("Articles matching this filter:");
} else {
loading_message.innerHTML = __("Found %d articles matching this filter:")
.replace("%d", test_dialog.results);
}
}
} else if (!result) {
console.log("getTestResults: can't parse results object");
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.");
}
} catch (e) {
App.Error.report(e);
}
});
},
innerHTML: transport.responseText
}, li);
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
parentNode.appendChild(li);
}
} catch (e) {
App.Error.report(e);
}
});
},
addFilterRule: function(replaceNode, ruleStr) {
if (dijit.byId("filterNewRuleDlg"))
dijit.byId("filterNewRuleDlg").destroyRecursive();
const rule_dlg = new dijit.Dialog({
id: "filterNewRuleDlg",
title: ruleStr ? __("Edit rule") : __("Add rule"),
style: "width: 600px",
execute: function () {
if (this.validate()) {
Filters.createNewRuleElement($("filterDlg_Matches"), replaceNode);
this.hide();
}
},
content: __('Loading, please wait...'),
});
const tmph = dojo.connect(rule_dlg, "onShow", null, function (/* e */) {
dojo.disconnect(tmph);
xhrPost("backend.php", {op: 'pref-filters', method: 'newrule', rule: ruleStr}, (transport) => {
rule_dlg.attr('content', transport.responseText);
});
});
content: `
<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 filter-results-list'></ul>
<footer class='text-center'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>${__('Close this window')}</button>
</footer>
`
});
rule_dlg.show();
},
addFilterAction: function(replaceNode, actionStr) {
if (dijit.byId("filterNewActionDlg"))
dijit.byId("filterNewActionDlg").destroyRecursive();
const tmph = dojo.connect(test_dialog, "onShow", null, function (/* e */) {
dojo.disconnect(tmph);
const query = "backend.php?op=pref-filters&method=newaction&action=" +
encodeURIComponent(actionStr);
test_dialog.getTestResults(dialog.attr('value'), 0);
});
const rule_dlg = new dijit.Dialog({
id: "filterNewActionDlg",
title: actionStr ? __("Edit action") : __("Add action"),
style: "width: 600px",
execute: function () {
if (this.validate()) {
Filters.createNewActionElement($("filterDlg_Actions"), replaceNode);
this.hide();
}
test_dialog.show();
},
href: query
});
insertRule: function(parentNode, replaceNode) {
const rule = dojo.formToJson("filter_new_rule_form");
rule_dlg.show();
},
editFilterTest: function(params) {
if (dijit.byId("filterTestDlg"))
dijit.byId("filterTestDlg").destroyRecursive();
const test_dlg = new dijit.Dialog({
id: "filterTestDlg",
title: "Test Filter",
style: "width: 600px",
results: 0,
limit: 100,
max_offset: 10000,
getTestResults: function (params, offset) {
params.method = 'testFilterDo';
params.offset = offset;
params.limit = test_dlg.limit;
console.log("getTestResults:" + offset);
xhrPost("backend.php", params, (transport) => {
xhr.post("backend.php", {op: "pref-filters", method: "printrulename", rule: rule}, (reply) => {
try {
const result = JSON.parse(transport.responseText);
if (result && dijit.byId("filterTestDlg") && dijit.byId("filterTestDlg").open) {
test_dlg.results += result.length;
const li = document.createElement('li');
li.addClassName("rule");
console.log("got results:" + result.length);
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>`;
$("prefFilterProgressMsg").innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dlg.results)
.replace("%d", offset);
dojo.parser.parse(li);
console.log(offset + " " + test_dlg.max_offset);
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("table", { innerHTML: result[i]});
$("prefFilterTestResultList").innerHTML += tmp.innerHTML;
}
if (test_dlg.results < 30 && offset < test_dlg.max_offset) {
// get the next batch
window.setTimeout(function () {
test_dlg.getTestResults(params, offset + test_dlg.limit);
}, 0);
} else {
// all done
Element.hide("prefFilterLoadingIndicator");
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
parentNode.appendChild(li);
}
} catch (e) {
App.Error.report(e);
}
});
},
insertAction: function(parentNode, replaceNode) {
const form = document.forms["filter_new_action_form"];
if (test_dlg.results == 0) {
$("prefFilterTestResultList").innerHTML = `<tr><td align='center'>
${__('No recent articles matching this filter have been found.')}</td></tr>`;
$("prefFilterProgressMsg").innerHTML = "Articles matching this filter:";
} else {
$("prefFilterProgressMsg").innerHTML = __("Found %d articles matching this filter:")
.replace("%d", test_dlg.results);
}
if (form.action_id.value == 7) {
form.action_param.value = form.action_param_label.value;
} else if (form.action_id.value == 9) {
form.action_param.value = form.action_param_plugin.value;
}
}
const action = dojo.formToJson(form);
} else if (!result) {
console.log("getTestResults: can't parse results object");
xhr.post("backend.php", { op: "pref-filters", method: "printactionname", action: action }, (reply) => {
try {
const li = document.createElement('li');
li.addClassName("action");
Element.hide("prefFilterLoadingIndicator");
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>`;
Notify.error("Error while trying to get filter test results.");
dojo.parser.parse(li);
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
console.log("getTestResults: dialog closed, bailing out.");
parentNode.appendChild(li);
}
} catch (e) {
App.Error.report(e);
}
});
},
href: "backend.php?op=pref-filters&method=testFilterDlg"
});
editRule: function(replaceNode, ruleStr = null) {
const edit_rule_dialog = new fox.SingleUseDialog({
id: "filterNewRuleDlg",
title: ruleStr ? __("Edit rule") : __("Add rule"),
execute: function () {
if (this.validate()) {
dialog.insertRule(App.byId("filterDlg_Matches"), replaceNode);
this.hide();
}
},
content: __('Loading, please wait...'),
});
dojo.connect(test_dlg, "onLoad", null, function (/* e */) {
test_dlg.getTestResults(params, 0);
});
const tmph = dojo.connect(edit_rule_dialog, "onShow", null, function () {
dojo.disconnect(tmph);
test_dlg.show();
},
quickAddFilter: function() {
let query;
let rule;
if (!App.isPrefs()) {
query = {
op: "pref-filters", method: "newfilter",
feed: Feeds.getActive(), is_cat: Feeds.activeIsCat()
};
} else {
query = {op: "pref-filters", method: "newfilter"};
}
if (ruleStr) {
rule = JSON.parse(ruleStr);
} else {
rule = {
reg_exp: "",
filter_type: 1,
feed_id: ["0"],
inverse: false,
};
}
console.log('quickAddFilter', query);
console.log(rule, dialog.filter_info);
xhr.json("backend.php", {op: "pref-filters", method: "editrule", ids: rule.feed_id.join(",")}, function (editrule) {
edit_rule_dialog.attr('content',
`
<form name="filter_new_rule_form" id="filter_new_rule_form" onsubmit="return false">
<section>
<textarea dojoType="fox.form.ValidationTextArea"
required="true" id="filterDlg_regExp" ValidRegExp="true"
rows="4" style="font-size : 14px; width : 530px; word-break: break-all"
name="reg_exp">${rule.reg_exp}</textarea>
<div dojoType="dijit.Tooltip" id="filterDlg_regExp_tip" connectId="filterDlg_regExp" position="below"></div>
<fieldset>
<label class="checkbox">
${App.FormFields.checkbox_tag("inverse", rule.inverse)}
${__("Inverse regular expression matching")}
</label>
</fieldset>
<fieldset>
<label style="display : inline">${__("on")}</label>
${App.FormFields.select_hash("filter_type", rule.filter_type, dialog.filter_info.filter_types)}
<label style="padding-left : 10px; display : inline">${__("in")}</label>
</fieldset>
<fieldset>
<span id="filterDlg_feeds">
${editrule.multiselect}
</span>
</fieldset>
</section>
<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(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
`);
});
if (dijit.byId("feedEditDlg"))
dijit.byId("feedEditDlg").destroyRecursive();
});
if (dijit.byId("filterEditDlg"))
dijit.byId("filterEditDlg").destroyRecursive();
edit_rule_dialog.show();
},
editAction: function(replaceNode, actionStr) {
const edit_action_dialog = new fox.SingleUseDialog({
title: actionStr ? __("Edit action") : __("Add action"),
select_labels: function(name, value, labels, attributes = {}, id = "") {
const values = Object.values(labels).map((label) => label.caption);
return App.FormFields.select_tag(name, value, values, attributes, id);
},
toggleParam: function(sender) {
const action = parseInt(sender.value);
dijit.byId("filterDlg_actionParam").domNode.hide();
dijit.byId("filterDlg_actionParamLabel").domNode.hide();
dijit.byId("filterDlg_actionParamPlugin").domNode.hide();
// if selected action supports parameters, enable params field
if (action == dialog.ACTION_LABEL) {
dijit.byId("filterDlg_actionParamLabel").domNode.show();
} else if (action == dialog.ACTION_PLUGIN) {
dijit.byId("filterDlg_actionParamPlugin").domNode.show();
} else if (dialog.PARAM_ACTIONS.indexOf(action) != -1) {
dijit.byId("filterDlg_actionParam").domNode.show();
}
},
execute: function () {
if (this.validate()) {
dialog.insertAction(App.byId("filterDlg_Actions"), replaceNode);
this.hide();
}
},
content: __("Loading, please wait...")
});
const dialog = new dijit.Dialog({
id: "filterEditDlg",
title: __("Create Filter"),
style: "width: 600px",
test: function () {
Filters.editFilterTest(dojo.formToObject("filter_new_form"));
const tmph = dojo.connect(edit_action_dialog, "onShow", null, function () {
dojo.disconnect(tmph);
let action;
if (actionStr) {
action = JSON.parse(actionStr);
} else {
action = {
action_id: 2,
action_param: ""
};
}
console.log(action);
edit_action_dialog.attr('content',
`
<form name="filter_new_action_form" id="filter_new_action_form" onsubmit="return false;">
<section>
${App.FormFields.select_hash("action_id", -1,
dialog.filter_info.action_types,
{onchange: "App.dialogOf(this).toggleParam(this)"},
"filterDlg_actionSelect")}
<input dojoType="dijit.form.TextBox"
id="filterDlg_actionParam" style="$param_hidden"
name="action_param" value="${App.escapeHtml(action.action_param)}">
${edit_action_dialog.select_labels("action_param_label", action.action_param,
dialog.filter_info.labels,
{},
"filterDlg_actionParamLabel")}
${App.FormFields.select_hash("action_param_plugin", action.action_param,
dialog.filter_info.plugin_actions,
{},
"filterDlg_actionParamPlugin")}
</section>
<footer>
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
`);
dijit.byId("filterDlg_actionSelect").attr('value', action.action_id);
/*xhr.post("backend.php", {op: 'pref-filters', method: 'newaction', action: actionStr}, (reply) => {
edit_action_dialog.attr('content', reply);
setTimeout(() => {
edit_action_dialog.hideOrShowActionParam(dijit.byId("filterDlg_actionSelect").attr('value'));
}, 250);
});*/
});
edit_action_dialog.show();
},
selectRules: function (select) {
Lists.select("filterDlg_Matches", select);
@ -293,91 +342,224 @@ const Filters = {
selectActions: function (select) {
Lists.select("filterDlg_Actions", select);
},
editRule: function (e) {
const li = e.parentNode;
const rule = li.getElementsByTagName("INPUT")[1].value;
Filters.addFilterRule(li, rule);
onRuleClicked: function (elem) {
const li = elem.closest('li');
const rule = li.querySelector('input[name="rule[]"]').value;
this.editRule(li, rule);
},
editAction: function (e) {
const li = e.parentNode;
const action = li.getElementsByTagName("INPUT")[1].value;
Filters.addFilterAction(li, action);
onActionClicked: function (elem) {
const li = elem.closest('li');
const action = li.querySelector('input[name="action[]"]').value;
this.editAction(li, action);
},
removeFilter: function () {
const msg = __("Remove filter?");
if (confirm(msg)) {
this.hide();
Notify.progress("Removing filter...");
const query = {op: "pref-filters", method: "remove", ids: this.attr('value').id};
xhr.post("backend.php", query, () => {
const tree = dijit.byId("filterTree");
if (tree) tree.reload();
});
}
},
addAction: function () {
Filters.addFilterAction();
this.editAction();
},
addRule: function () {
Filters.addFilterRule();
this.editRule();
},
deleteAction: function () {
$$("#filterDlg_Actions li[class*=Selected]").each(function (e) {
App.findAll("#filterDlg_Actions li[class*=Selected]").forEach(function (e) {
e.parentNode.removeChild(e)
});
},
deleteRule: function () {
$$("#filterDlg_Matches li[class*=Selected]").each(function (e) {
App.findAll("#filterDlg_Matches li[class*=Selected]").forEach(function (e) {
e.parentNode.removeChild(e)
});
},
execute: function () {
if (this.validate()) {
const query = dojo.formToQuery("filter_new_form");
xhrPost("backend.php", query, () => {
if (App.isPrefs()) {
dijit.byId("filterTree").reload();
}
Notify.progress("Saving data...", true);
xhr.post("backend.php", this.attr('value'), () => {
dialog.hide();
const tree = dijit.byId("filterTree");
if (tree) tree.reload();
});
}
},
href: "backend.php?" + dojo.objectToQuery(query)
content: __("Loading, please wait...")
});
if (!App.isPrefs()) {
/* global getSelectionText */
const selectedText = getSelectionText();
const lh = dojo.connect(dialog, "onLoad", function () {
dojo.disconnect(lh);
if (selectedText != "") {
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
const rule = {reg_exp: selectedText, feed_id: [feed_id], filter_type: 1};
Filters.addFilterRule(null, dojo.toJson(rule));
} else {
const query = {op: "rpc", method: "getlinktitlebyid", id: Article.getActive()};
xhrPost("backend.php", query, (transport) => {
const reply = JSON.parse(transport.responseText);
let title = false;
if (reply && reply.title) title = reply.title;
if (title || Feeds.getActive() || Feeds.activeIsCat()) {
console.log(title + " " + Feeds.getActive());
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
const rule = {reg_exp: title, feed_id: [feed_id], filter_type: 1};
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
Filters.addFilterRule(null, dojo.toJson(rule));
}
});
xhr.json("backend.php", {op: "pref-filters", method: "edit", id: filter_id}, function (filter) {
dialog.filter_info = filter;
const options = {
enabled: [ filter.enabled, __('Enabled') ],
match_any_rule: [ filter.match_any_rule, __('Match any rule') ],
inverse: [ filter.inverse, __('Inverse matching') ],
};
dialog.attr('content',
`
<form onsubmit='return false'>
${App.FormFields.hidden_tag("op", "pref-filters")}
${App.FormFields.hidden_tag("id", filter_id)}
${App.FormFields.hidden_tag("method", filter_id ? "editSave" : "add")}
${App.FormFields.hidden_tag("csrf_token", App.getInitParam('csrf_token'))}
<section class="horizontal">
<input required="true" dojoType="dijit.form.ValidationTextBox" style="width : 100%"
placeholder="${__("Title")}" name="title" value="${App.escapeHtml(filter.title)}">
</section>
<div dojoType="dijit.layout.TabContainer" style="height : 300px">
<div dojoType="dijit.layout.ContentPane" title="${__('Match')}">
<div style="padding : 0" dojoType="dijit.layout.BorderContainer" gutters="false">
<div dojoType="fox.Toolbar" region="top">
<div dojoType="fox.form.DropDownButton">
<span>${__("Select")}</span>
<div dojoType="dijit.Menu" style="display: none;">
<!-- can"t use App.dialogOf() here because DropDownButton is not a child of the Dialog -->
<div onclick="dijit.byId('filterEditDlg').selectRules(true)"
dojoType="dijit.MenuItem">${__("All")}</div>
<div onclick="dijit.byId('filterEditDlg').selectRules(false)"
dojoType="dijit.MenuItem">${__("None")}</div>
</div>
</div>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).addRule()">
${__("Add")}
</button>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).deleteRule()">
${__("Delete")}
</button>
</div>
<div dojoType="dijit.layout.ContentPane" region="center">
<ul id="filterDlg_Matches">
${filter.rules.map((rule) => `
<li class='rule'>
${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class='name' onclick='App.dialogOf(this).onRuleClicked(this)'>${rule.name}</span>
<span class='payload'>${App.FormFields.hidden_tag("rule[]", JSON.stringify(rule))}</span>
</li>
`).join("")}
</ul>
</div>
</div>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Apply actions')}">
<div style="padding : 0" dojoType="dijit.layout.BorderContainer" gutters="false">
<div dojoType="fox.Toolbar" region="top">
<div dojoType="fox.form.DropDownButton">
<span>${__("Select")}</span>
<div dojoType="dijit.Menu" style="display: none">
<div onclick="dijit.byId('filterEditDlg').selectActions(true)"
dojoType="dijit.MenuItem">${__("All")}</div>
<div onclick="dijit.byId('filterEditDlg').selectActions(false)"
dojoType="dijit.MenuItem">${__("None")}</div>
</div>
</div>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).addAction()">
${__("Add")}
</button>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).deleteAction()">
${__("Delete")}
</button>
</div>
<div dojoType="dijit.layout.ContentPane" region="center">
<ul id="filterDlg_Actions">
${filter.actions.map((action) => `
<li class='rule'>
${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class='name' onclick='App.dialogOf(this).onActionClicked(this)'>${App.escapeHtml(action.name)}</span>
<span class='payload'>${App.FormFields.hidden_tag("action[]", JSON.stringify(action))}</span>
</li>
`).join("")}
</ul>
</div>
</div>
</div>
</div>
<section class="horizontal">
${Object.keys(options).map((name) =>
`
<fieldset class='narrow'>
<label class="checkbox">
${App.FormFields.checkbox_tag(name, options[name][0])}
${options[name][1]}
</label>
</fieldset>
`).join("")}
</section>
<footer>
${filter_id ?
`
${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(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>
</form>
`);
if (!App.isPrefs()) {
const selectedText = App.getSelectedText();
if (selectedText != "") {
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
const rule = {reg_exp: selectedText, feed_id: [feed_id], filter_type: 1};
dialog.editRule(null, dojo.toJson(rule));
} else {
const query = {op: "article", method: "getmetadatabyid", id: Article.getActive()};
xhr.json("backend.php", query, (reply) => {
let title;
if (reply && reply.title) title = reply.title;
if (title || Feeds.getActive() || Feeds.activeIsCat()) {
console.log(title + " " + Feeds.getActive());
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
const rule = {reg_exp: title, feed_id: [feed_id], filter_type: 1};
dialog.editRule(null, dojo.toJson(rule));
}
});
}
}
});
}
});
dialog.show();
},
};

@ -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;

@ -7,9 +7,9 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
// save state in localStorage instead of cookies
// reference: https://stackoverflow.com/a/27968996
_saveExpandedNodes: function(){
if(this.persist && this.cookieName){
var ary = [];
for(var id in this._openedNodes){
if (this.persist && this.cookieName){
const ary = [];
for (const id in this._openedNodes){
ary.push(id);
}
// Was:
@ -21,16 +21,16 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
// summary:
// Load in which nodes should be opened automatically
this._openedNodes = {};
if(this.persist && this.cookieName){
if (this.persist && this.cookieName){
// Was:
// var oreo = cookie(this.cookieName);
var oreo = localStorage.getItem(this.cookieName);
let oreo = localStorage.getItem(this.cookieName);
// migrate old data if nothing in localStorage
if(oreo == null || oreo === '') {
if (oreo == null || oreo === '') {
oreo = cookie(this.cookieName);
cookie(this.cookieName, null, { expires: -1 });
}
if(oreo){
if (oreo){
array.forEach(oreo.split(','), function(item){
this._openedNodes[item] = true;
}, this);
@ -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,11 +101,20 @@ 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() {
/* global __csrf_token */
App.postOpenWindow("backend.php", {op: "feeds", method: "update_debugger",
App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger",
feed_id: this.getParent().row_id, csrf_token: __csrf_token});
}}));
}
@ -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";
@ -286,7 +311,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
// focus headlines to route key events there
setTimeout(() => {
$("headlines-frame").focus();
App.byId("headlines-frame").focus();
if (treeNode) {
const node = treeNode.rowNode;
@ -295,7 +320,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (node && tree) {
// scroll tree to selection if needed
if (node.offsetTop < tree.scrollTop || node.offsetTop > tree.scrollTop + tree.clientHeight) {
$("feedTree").scrollTop = node.offsetTop;
App.byId("feedTree").scrollTop = node.offsetTop;
}
}
}
@ -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');
return true;
// 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,7 +394,28 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
},
getNextFeed: function (feed, is_cat) {
getNextUnread: function(feed, is_cat) {
return this.getNextFeed(feed, is_cat, true);
},
_nextTreeItemFromIndex: function (start, unread_only) {
const items = this.model.store._arrayOfAllItems;
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 && (!unread_only || unread > 0)) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
if (Element.visible(cat) && Element.visible(row)) {
return items[i];
}
}
}
},
getNextFeed: function (feed, is_cat, unread_only = false) {
let treeItem;
if (is_cat) {
@ -370,37 +425,43 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
const items = this.model.store._arrayOfAllItems;
let item = items[0];
const start = items.indexOf(treeItem);
if (start != -1) {
let item = this._nextTreeItemFromIndex(start, unread_only);
// let's try again from the top
// 0 (instead of -1) to skip Special category
if (!item) {
item = this._nextTreeItemFromIndex(0, unread_only);
}
for (let i = 0; i < items.length; i++) {
if (items[i] == treeItem) {
if (item)
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
}
for (let j = i+1; j < items.length; j++) {
const id = String(items[j].id);
const box = this._itemNodesMap[id];
return [false, false];
},
_prevTreeItemFromIndex: function (start, unread_only) {
const items = this.model.store._arrayOfAllItems;
if (box) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
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 (Element.visible(cat) && Element.visible(row)) {
item = items[j];
break;
}
}
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)) {
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) {
getPreviousFeed: function (feed, is_cat, unread_only = false) {
let treeItem;
if (is_cat) {
@ -410,37 +471,22 @@ 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._prevTreeItemFromIndex(start, unread_only);
for (let j = i-1; j > 0; j--) {
const id = String(items[j].id);
const box = this._itemNodesMap[id];
if (box) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
if (Element.visible(cat) && Element.visible(row)) {
item = items[j];
break;
}
}
}
break;
// wrap from the bottom
if (!item) {
item = this._prevTreeItemFromIndex(items.length, unread_only);
}
}
if (item) {
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
} else {
return false;
if (item)
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
}
return [false, false];
},
getFeedCategory: function(feed) {
try {

@ -1,8 +1,9 @@
'use strict'
/* global __, App, Headlines, xhrPost, dojo, dijit, Form, fox, PluginHost, Notify, $$ */
/* global __, App, Headlines, xhr, dojo, dijit, fox, PluginHost, Notify, fox */
const Feeds = {
_default_feed_id: -3,
counters_last_request: 0,
_active_feed_id: undefined,
_active_feed_is_cat: false,
@ -12,6 +13,19 @@ const Feeds = {
_search_query: false,
last_search_query: [],
_viewfeed_wait_timeout: false,
_feeds_holder_observer: new IntersectionObserver(
(entries/*, observer*/) => {
entries.forEach((entry) => {
//console.log('feeds',entry.target, entry.intersectionRatio);
if (entry.intersectionRatio == 0)
Feeds.onHide(entry);
else
Feeds.onShow(entry);
});
},
{threshold: [0, 1], root: document.querySelector("body")}
),
_counters_prev: [],
// NOTE: this implementation is incomplete
// for general objects but good enough for counters
@ -99,40 +113,47 @@ 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");
const splitter = $("feeds-holder_splitter");
Element.visible("feeds-holder") ? splitter.show() : splitter.hide();
dijit.byId("main").resize();
Headlines.updateCurrentUnread();
},
cancelSearch: function() {
this._search_query = "";
this.reloadCurrent();
},
requestCounters: function() {
xhrPost("backend.php", {op: "rpc", method: "getAllCounters", seq: App.next_seq()}, (transport) => {
App.handleRpcJson(transport);
});
// null = get all data, [] would give empty response for specific type
requestCounters: function(feed_ids = null, label_ids = null) {
xhr.json("backend.php", {op: "rpc",
method: "getAllCounters",
"feed_ids[]": feed_ids,
"feed_id_count": feed_ids ? feed_ids.length : -1,
"label_ids[]": label_ids,
"label_id_count": label_ids ? label_ids.length : -1,
seq: App.next_seq()});
},
reload: function() {
try {
@ -180,7 +201,7 @@ const Feeds = {
dojo.disconnect(tmph);
});
$("feeds-holder").appendChild(tree.domNode);
App.byId("feeds-holder").appendChild(tree.domNode);
const tmph2 = dojo.connect(tree, 'onLoad', function () {
dojo.disconnect(tmph2);
@ -199,23 +220,37 @@ const Feeds = {
App.Error.report(e);
}
},
onHide: function() {
App.byId("feeds-holder_splitter").hide();
dijit.byId("main").resize();
Headlines.updateCurrentUnread();
},
onShow: function() {
App.byId("feeds-holder_splitter").show();
dijit.byId("main").resize();
Headlines.updateCurrentUnread();
},
init: function() {
console.log("in feedlist init");
this._feeds_holder_observer.observe(App.byId("feeds-holder"));
App.setLoadingProgress(50);
//document.onkeydown = (event) => { return App.hotkeyHandler(event) };
//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();
console.log('got hash', hash);
if (hash_feed_id != undefined) {
this.open({feed: hash_feed_id, is_cat: hash_feed_is_cat});
if (hash.f != undefined) {
this.open({feed: parseInt(hash.f), is_cat: parseInt(hash.c)});
} else {
this.open({feed: -3});
this.openDefaultFeed();
}
this.hideOrShowFeeds(App.getInitParam("hide_read_feeds"));
@ -223,36 +258,47 @@ const Feeds = {
if (App.getInitParam("is_default_pw")) {
console.warn("user password is at default value");
if (dijit.byId("defaultPasswordDlg"))
dijit.byId("defaultPasswordDlg").destroyRecursive();
xhrPost("backend.php", {op: 'dlg', method: 'defaultpasswordwarning'}, (transport) => {
const dialog = new dijit.Dialog({
title: __("Your password is at default value"),
content: transport.responseText,
id: 'defaultPasswordDlg',
style: "width: 600px",
onCancel: function () {
return true;
},
onExecute: function () {
return true;
},
onClose: function () {
return true;
}
});
const dialog = new fox.SingleUseDialog({
title: __("Your password is at default value"),
content: `<div class='alert alert-error'>
${__("You are using default tt-rss password. Please change it in the Preferences (Personal data / Authentication).")}
</div>
<footer class='text-center'>
<button dojoType='dijit.form.Button' class='alt-primary' onclick="document.location.href = 'prefs.php'">
${__('Open Preferences')}
</button>
<button dojoType='dijit.form.Button' onclick="App.dialogOf(this).hide()">
${__('Close this window')}
</button>
</footer>`
});
dialog.show();
}
dialog.show();
if (App.getInitParam("safe_mode")) {
const dialog = new fox.SingleUseDialog({
title: __("Safe mode"),
content: `<div class='alert alert-info'>
${__('Tiny Tiny RSS is running in safe mode. All themes and plugins are disabled. You will need to log out and back in to disable it.')}
</div>
<footer class='text-center'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
${__('Close this window')}
</button>
</footer>`
});
dialog.show();
}
// bw_limit disables timeout() so we request initial counters separately
if (App.getInitParam("bw_limit")) {
this.requestCounters(true);
this.requestCounters();
} else {
setTimeout(() => {
this.requestCounters(true);
this.requestCounters();
setInterval(() => { this.requestCounters(); }, 60 * 1000)
}, 250);
}
@ -266,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;
$("headlines-frame").setAttribute("feed-id", id);
$("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);
@ -288,7 +341,7 @@ const Feeds = {
toggleUnread: function() {
const hide = !App.getInitParam("hide_read_feeds");
xhrPost("backend.php", {op: "rpc", method: "setpref", key: "HIDE_READ_FEEDS", value: hide}, () => {
xhr.post("backend.php", {op: "rpc", method: "setpref", key: "HIDE_READ_FEEDS", value: hide}, () => {
this.hideOrShowFeeds(hide);
App.setInitParam("hide_read_feeds", hide);
});
@ -299,14 +352,13 @@ const Feeds = {
if (tree)
return tree.hideRead(hide, App.getInitParam("hide_read_shows_special"));*/
$$("body")[0].setAttribute("hide-read-feeds", !!hide);
$$("body")[0].setAttribute("hide-read-shows-special", !!App.getInitParam("hide_read_shows_special"));
App.findAll("body")[0].setAttribute("hide-read-feeds", !!hide);
App.findAll("body")[0].setAttribute("hide-read-shows-special", !!App.getInitParam("hide_read_shows_special"));
},
open: function(params) {
const feed = params.feed;
const is_cat = !!params.is_cat || false;
const offset = params.offset || 0;
const viewfeed_debug = params.viewfeed_debug;
const append = params.append || false;
const method = params.method;
// this is used to quickly switch between feeds, sets active but xhr is on a timeout
@ -328,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;
@ -351,31 +400,21 @@ const Feeds = {
query.m = "ForceUpdate";
}
Form.enable("toolbar-main");
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);
if (viewfeed_debug) {
window.open("backend.php?" +
dojo.objectToQuery(
Object.assign({csrf_token: App.getInitParam("csrf_token")}, query)
));
}
window.clearTimeout(this._viewfeed_wait_timeout);
this._viewfeed_wait_timeout = window.setTimeout(() => {
xhrPost("backend.php", query, (transport) => {
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');
Headlines.onLoaded(transport, offset, append);
this.showLoading(feed, is_cat, false);
Headlines.onLoaded(reply, offset, append);
PluginHost.run(PluginHost.HOOK_FEED_LOADED, [feed, is_cat]);
} catch (e) {
App.Error.report(e);
@ -390,8 +429,7 @@ const Feeds = {
Notify.progress("Marking all feeds as read...");
xhrPost("backend.php", {op: "feeds", method: "catchupAll"}, () => {
this.requestCounters(true);
xhr.json("backend.php", {op: "feeds", method: "catchupAll"}, () => {
this.reloadCurrent();
});
@ -436,17 +474,15 @@ const Feeds = {
Notify.progress("Loading, please wait...", true);
xhrPost("backend.php", catchup_query, (transport) => {
App.handleRpcJson(transport);
xhr.json("backend.php", catchup_query, () => {
const show_next_feed = App.getInitParam("on_catchup_show_next_feed");
// 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();
@ -465,9 +501,9 @@ const Feeds = {
if (App.getInitParam("confirm_feed_catchup") != 1 || confirm(str)) {
const rows = $$("#headlines-frame > div[id*=RROW][class*=Unread][data-orig-feed-id='" + id + "']");
const rows = App.findAll("#headlines-frame > div[id*=RROW][class*=Unread][data-orig-feed-id='" + id + "']");
rows.each((row) => {
rows.forEach((row) => {
row.removeClassName("Unread");
})
}
@ -538,73 +574,119 @@ 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;
},
getNextUnread: function(feed, is_cat) {
getNextFeed: function(feed, is_cat, unread_only = false) {
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.getNextFeed(feed, is_cat, unread_only);
return [false, false];
},
search: function() {
if (dijit.byId("searchDlg"))
dijit.byId("searchDlg").destroyRecursive();
xhrPost("backend.php",
{op: "feeds", method: "search",
param: Feeds.getActive() + ":" + Feeds.activeIsCat()},
(transport) => {
const dialog = new dijit.Dialog({
id: "searchDlg",
content: transport.responseText,
title: __("Search"),
style: "width: 600px",
execute: function () {
if (this.validate()) {
Feeds._search_query = this.attr('value');
// disallow empty queries
if (!Feeds._search_query.query)
Feeds._search_query = false;
this.hide();
Feeds.reloadCurrent();
}
},
});
getPreviousFeed: function(feed, is_cat, unread_only = false) {
const tree = dijit.byId("feedTree");
const tmph = dojo.connect(dialog, 'onLoad', function () {
dojo.disconnect(tmph);
if (tree) return tree.getPreviousFeed(feed, is_cat, unread_only);
if (Feeds._search_query) {
if (Feeds._search_query.query)
dijit.byId('search_query')
.attr('value', Feeds._search_query.query);
return [false, false];
},
getNextUnread: function(feed, is_cat) {
const tree = dijit.byId("feedTree");
if (Feeds._search_query.search_language)
dijit.byId('search_language')
.attr('value', Feeds._search_query.search_language);
}
if (tree) return tree.getNextUnread(feed, is_cat);
});
return [false, false];
},
search: function() {
xhr.json("backend.php",
{op: "feeds", method: "search"},
(reply) => {
try {
const dialog = new fox.SingleUseDialog({
content: `
<form onsubmit='return false'>
<section>
<fieldset>
<input dojoType='dijit.form.ValidationTextBox' id='search_query'
style='font-size : 16px; width : 540px;'
placeHolder="${__("Search %s...").replace("%s", Feeds.getName(Feeds.getActive(), Feeds.activeIsCat()))}"
name='query' type='search' value=''>
</fieldset>
${reply.show_language ?
`
<fieldset>
<label class='inline'>${__("Language:")}</label>
${App.FormFields.select_tag("search_language", reply.default_language, reply.all_languages,
{title: __('Used for word stemming')}, "search_language")}
</fieldset>
` : ''}
</section>
<footer>
${reply.show_syntax_help ?
`${App.FormFields.button_tag(App.FormFields.icon("help") + " " + __("Search syntax"), "",
{class: 'alt-info pull-left', onclick: "window.open('https://tt-rss.org/wiki/SearchSyntax')"})}
` : ''}
${App.FormFields.submit_tag(App.FormFields.icon("search") + " " + __('Search'), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__('Cancel'))}
</footer>
</form>
`,
title: __("Search"),
execute: function () {
if (this.validate()) {
Feeds._search_query = this.attr('value');
// disallow empty queries
if (!Feeds._search_query.query)
Feeds._search_query = false;
this.hide();
Feeds.reloadCurrent();
}
},
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
if (Feeds._search_query) {
if (Feeds._search_query.query)
dijit.byId('search_query')
.attr('value', Feeds._search_query.query);
if (Feeds._search_query.search_language)
dijit.byId('search_language')
.attr('value', Feeds._search_query.search_language);
}
});
dialog.show();
dialog.show();
} catch (e) {
App.Error.report(e);
}
});
},
updateRandom: function() {
console.log("in update_random_feed");
xhrPost("backend.php", {op: "rpc", method: "updaterandomfeed"}, (transport) => {
App.handleRpcJson(transport, true);
xhr.json("backend.php", {op: "rpc", method: "updaterandomfeed"}, () => {
//
});
},
renderIcon: function(feed_id, exists) {
return feed_id && exists ?
`<img class="icon" src="${App.escapeHtml(App.getInitParam("icons_url"))}/${feed_id}.ico">` :
`<i class='icon-no-feed material-icons'>rss_feed</i>`;
}
};

File diff suppressed because it is too large Load Diff

@ -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++) {

@ -1,8 +1,44 @@
/* global __, lib, dijit, define, dojo, CommonDialogs, Notify, Tables, xhrPost */
/* eslint-disable prefer-rest-params */
/* global __, lib, dijit, define, dojo, CommonDialogs, Notify, Tables, xhrPost, xhr, fox, App */
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], function (declare, domConstruct) {
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_base/array", "dojo/cookie"],
function (declare, domConstruct, checkBoxTree, array, cookie) {
return declare("fox.PrefFeedTree", lib.CheckBoxTree, {
// save state in localStorage instead of cookies
// reference: https://stackoverflow.com/a/27968996
_saveExpandedNodes: function(){
if (this.persist && this.cookieName){
const ary = [];
for (const id in this._openedNodes){
ary.push(id);
}
// Was:
// cookie(this.cookieName, ary.join(","), {expires: 365});
localStorage.setItem(this.cookieName, ary.join(","));
}
},
_initState: function(){
this.cookieName = 'prefs:' + this.cookieName;
// summary:
// Load in which nodes should be opened automatically
this._openedNodes = {};
if (this.persist && this.cookieName){
// Was:
// var oreo = cookie(this.cookieName);
let oreo = localStorage.getItem(this.cookieName);
// migrate old data if nothing in localStorage
if (oreo == null || oreo === '') {
oreo = cookie(this.cookieName);
cookie(this.cookieName, null, { expires: -1 });
}
if (oreo){
array.forEach(oreo.split(','), function(item){
this._openedNodes[item] = true;
}, this);
}
}
},
_createTreeNode: function(args) {
const tnode = this.inherited(arguments);
@ -29,7 +65,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const bare_id = parseInt(id.substr(id.indexOf(':')+1));
if (id.match("CAT:") && bare_id > 0) {
var menu = new dijit.Menu();
const menu = new dijit.Menu();
menu.row_id = bare_id;
menu.item = args.item;
@ -49,7 +85,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
menu.bindDomNode(tnode.domNode);
tnode._menu = menu;
} else if (id.match("FEED:")) {
var menu = new dijit.Menu();
const menu = new dijit.Menu();
menu.row_id = bare_id;
menu.item = args.item;
@ -76,6 +112,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
this.inherited(arguments);
this.tree.model.store.save();
},
// eslint-disable-next-line no-unused-vars
getRowClass: function (item, opened) {
let rc = (!item.error || item.error == '') ? "dijitTreeRow" :
"dijitTreeRow Error";
@ -85,14 +122,15 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
return rc;
},
getIconClass: function (item, opened) {
// eslint-disable-next-line no-nested-ternary
return (!item || this.model.store.getValue(item, 'type') == 'category') ? (opened ? "dijitFolderOpened" : "dijitFolderClosed") : "feed-icon";
},
reload: function() {
const searchElem = $("feed_search");
let search = (searchElem) ? searchElem.value : "";
const searchElem = App.byId("feed_search");
const search = (searchElem) ? searchElem.value : "";
xhrPost("backend.php", { op: "pref-feeds", search: search }, (transport) => {
dijit.byId('feedConfigTab').attr('content', transport.responseText);
xhr.post("backend.php", { op: "pref-feeds", search: search }, (reply) => {
dijit.byId('feedsTab').attr('content', reply);
Notify.close();
});
},
@ -126,14 +164,14 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
resetFeedOrder: function() {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-feeds", method: "feedsortreset"}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "feedsortreset"}, () => {
this.reload();
});
},
resetCatOrder: function() {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-feeds", method: "catsortreset"}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "catsortreset"}, () => {
this.reload();
});
},
@ -141,7 +179,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
if (confirm(__("Remove category %s? Any nested feeds would be placed into Uncategorized.").replace("%s", item.name))) {
Notify.progress("Removing category...");
xhrPost("backend.php", {op: "pref-feeds", method: "removeCat", ids: id}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "removeCat", ids: id}, () => {
Notify.close();
this.reload();
});
@ -160,7 +198,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -171,9 +209,16 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
return false;
},
checkErrorFeeds: function() {
xhr.json("backend.php", {op: "pref-feeds", method: "feedsWithErrors"}, (reply) => {
if (reply.length > 0) {
Element.show(dijit.byId("pref_feeds_errors_btn").domNode);
}
});
},
checkInactiveFeeds: function() {
xhrPost("backend.php", {op: "pref-feeds", method: "getinactivefeeds"}, (transport) => {
if (parseInt(transport.responseText) > 0) {
xhr.json("backend.php", {op: "pref-feeds", method: "inactivefeeds"}, (reply) => {
if (reply.length > 0) {
Element.show(dijit.byId("pref_feeds_inactive_btn").domNode);
}
});
@ -183,7 +228,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function (item) {
items.forEach(function (item) {
if (item.id[0].match("CAT:"))
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
@ -202,7 +247,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -217,7 +262,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function (item) {
items.forEach(function (item) {
if (item.id[0].match("FEED:"))
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
@ -250,62 +295,70 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
Notify.progress("Loading, please wait...");
if (dijit.byId("feedEditDlg"))
dijit.byId("feedEditDlg").destroyRecursive();
xhrPost("backend.php", {op: "pref-feeds", method: "editfeeds", ids: rows.toString()}, (transport) => {
xhr.post("backend.php", {op: "pref-feeds", method: "editfeeds", ids: rows.toString()}, (reply) => {
Notify.close();
const dialog = new dijit.Dialog({
id: "feedEditDlg",
title: __("Edit Multiple Feeds"),
style: "width: 600px",
getChildByName: function (name) {
let rv = null;
this.getChildren().each(
function (child) {
if (child.name == name) {
rv = child;
return;
}
});
return rv;
},
toggleField: function (checkbox, elem, label) {
this.getChildByName(elem).attr('disabled', !checkbox.checked);
if ($(label))
if (checkbox.checked)
$(label).removeClassName('text-muted');
else
$(label).addClassName('text-muted');
},
execute: function () {
if (this.validate() && confirm(__("Save changes to selected feeds?"))) {
const query = this.attr('value');
/* normalize unchecked checkboxes because [] is not serialized */
Object.keys(query).each((key) => {
let val = query[key];
if (typeof val == "object" && val.length == 0)
query[key] = ["off"];
});
Notify.progress("Saving data...", true);
xhrPost("backend.php", query, () => {
dialog.hide();
dijit.byId("feedTree").reload();
});
}
},
content: transport.responseText
});
try {
const dialog = new fox.SingleUseDialog({
title: __("Edit multiple feeds"),
/*getChildByName: function (name) {
let rv = null;
this.getChildren().forEach(
function (child) {
if (child.name == name) {
rv = child;
return;
}
});
return rv;
},*/
toggleField: function (checkbox) {
const name = checkbox.attr("data-control-for");
const target = dijit.getEnclosingWidget(dialog.domNode.querySelector(`input[name="${name}"]`));
target.attr('disabled', !checkbox.attr('checked'));
console.log(target, target.attr('type'));
if (target.attr('type') == "checkbox") {
const label = checkbox.domNode.closest("label");
if (checkbox.attr('checked'))
label.removeClassName('text-muted');
else
label.addClassName('text-muted');
}
},
execute: function () {
if (this.validate() && confirm(__("Save changes to selected feeds?"))) {
const query = this.attr('value');
/* normalize unchecked checkboxes because [] is not serialized */
Object.keys(query).forEach((key) => {
const val = query[key];
if (typeof val == "object" && val.length == 0)
query[key] = ["off"];
});
Notify.progress("Saving data...", true);
xhr.post("backend.php", query, () => {
dialog.hide();
const tree = dijit.byId("feedTree");
if (tree) tree.reload();
});
}
},
content: reply
});
dialog.show();
dialog.show();
} catch (e) {
App.Error.report(e);
}
});
},
editCategory: function(id, item) {
@ -319,7 +372,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
Notify.progress("Loading, please wait...");
xhrPost("backend.php", { op: 'pref-feeds', method: 'renamecat', id: id, title: new_name }, () => {
xhr.post("backend.php", { op: 'pref-feeds', method: 'renamecat', id: id, title: new_name }, () => {
this.reload();
});
}
@ -330,83 +383,167 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
if (title) {
Notify.progress("Creating category...");
xhrPost("backend.php", {op: "pref-feeds", method: "addCat", cat: title}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "addCat", cat: title}, () => {
Notify.close();
this.reload();
});
}
},
batchSubscribe: function() {
const query = "backend.php?op=pref-feeds&method=batchSubscribe";
// overlapping widgets
if (dijit.byId("batchSubDlg")) dijit.byId("batchSubDlg").destroyRecursive();
if (dijit.byId("feedAddDlg")) dijit.byId("feedAddDlg").destroyRecursive();
const dialog = new dijit.Dialog({
id: "batchSubDlg",
title: __("Batch subscribe"),
style: "width: 600px",
execute: function () {
if (this.validate()) {
Notify.progress(__("Subscribing to feeds..."), true);
xhrPost("backend.php", this.attr('value'), () => {
Notify.close();
dijit.byId("feedTree").reload();
dialog.hide();
});
}
},
href: query
});
xhr.json("backend.php", {op: 'pref-feeds', method: 'batchSubscribe'}, (reply) => {
const dialog = new fox.SingleUseDialog({
id: "batchSubDlg",
title: __("Batch subscribe"),
execute: function () {
if (this.validate()) {
Notify.progress(__("Subscribing to feeds..."), true);
dialog.show();
},
showInactiveFeeds: function() {
const query = "backend.php?op=pref-feeds&method=inactiveFeeds";
if (dijit.byId("inactiveFeedsDlg"))
dijit.byId("inactiveFeedsDlg").destroyRecursive();
const dialog = new dijit.Dialog({
id: "inactiveFeedsDlg",
title: __("Feeds without recent updates"),
style: "width: 600px",
getSelectedFeeds: function () {
return Tables.getSelected("inactive-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", this.attr('value'), () => {
Notify.close();
dijit.byId("feedTree").reload();
const tree = dijit.byId("feedTree");
if (tree) tree.reload();
dialog.hide();
});
}
},
content: `
<form onsubmit='return false'>
${App.FormFields.hidden_tag("op", "pref-feeds")}
${App.FormFields.hidden_tag("method", "batchaddfeeds")}
<header class='horizontal'>
${__("One valid feed per line (no detection is done)")}
</header>
<section>
<textarea style='font-size : 12px; width : 98%; height: 200px;'
dojoType='fox.form.ValidationTextArea' required='1' name='feeds'></textarea>
${reply.enable_cats ?
`<fieldset>
<label>${__('Place in category:')}</label>
${reply.cat_select}
</fieldset>
` : ''
}
</section>
<div id='feedDlg_loginContainer' style='display : none'>
<header>${__("Authentication")}</header>
<section>
<input dojoType='dijit.form.TextBox' name='login' placeHolder="${__("Login")}">
<input placeHolder="${__("Password")}" dojoType="dijit.form.TextBox" type='password'
autocomplete='new-password' name='pass'></div>
</section>
</div>
<fieldset class='narrow'>
<label class='checkbox'><input type='checkbox' name='need_auth' dojoType='dijit.form.CheckBox'
onclick='App.displayIfChecked(this, "feedDlg_loginContainer")'>
${__('Feeds require authentication.')}
</label>
</fieldset>
<footer>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).execute()' type='submit' class='alt-primary'>
${__('Subscribe')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
</form>
`
});
dialog.show();
});
},
showInactiveFeeds: function() {
xhr.json("backend.php", {op: 'pref-feeds', method: 'inactivefeeds'}, function (reply) {
const dialog = new fox.SingleUseDialog({
id: "inactiveFeedsDlg",
title: __("Feeds without recent updates"),
getSelectedFeeds: function () {
return Tables.getSelected("inactive-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
xhr.post("backend.php", query, () => {
Notify.close();
const tree = dijit.byId("feedTree");
if (tree) tree.reload();
dialog.hide();
});
}
} else {
alert(__("No feeds selected."));
}
},
content: `
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span>${__('Select')}</span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="Tables.select('inactive-feeds-list', true)"
dojoType='dijit.MenuItem'>${__('All')}</div>
<div onclick="Tables.select('inactive-feeds-list', false)"
dojoType='dijit.MenuItem'>${__('None')}</div>
</div>
</div>
</div>
<div class='panel panel-scrollable'>
<table width='100%' id='inactive-feeds-list'>
${reply.map((row) => `<tr data-row-id='${row.id}'>
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this)' dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td>
<a href='#' "title="${__("Click to edit feed")}" onclick="CommonDialogs.editFeed(${row.id})">
${App.escapeHtml(row.title)}
</a>
</td>
<td class='text-muted' align='right'>
${row.last_article}
</td>
</tr>
`).join("")}
</table>
</div>
<footer>
<button style='float : left' class='alt-danger' dojoType='dijit.form.Button' onclick='App.dialogOf(this).removeSelected()'>
${__('Unsubscribe from selected feeds')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
${__('Close this window')}
</button>
</footer>
`
});
dialog.show();
} else {
alert(__("No feeds selected."));
}
},
execute: function () {
if (this.validate()) {
}
},
href: query
});
dialog.show();
}
});
});

@ -1,4 +1,5 @@
/* global __, $$, define, lib, dijit, dojo, xhrPost, Notify, Filters, Lists */
/* eslint-disable prefer-rest-params */
/* global __, define, lib, dijit, dojo, xhr, App, Notify */
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], function (declare, domConstruct) {
@ -54,6 +55,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
return label;
},
getIconClass: function (item, opened) {
// eslint-disable-next-line no-nested-ternary
return (!item || this.model.mayHaveChildren(item)) ? (opened ? "dijitFolderOpened" : "dijitFolderClosed") : "invisible";
},
getRowClass: function (item, opened) {
@ -62,7 +64,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
return enabled ? "dijitTreeRow" : "dijitTreeRow filterDisabled";
},
checkItemAcceptance: function(target, source, position) {
const item = dijit.getEnclosingWidget(target).item;
//const item = dijit.getEnclosingWidget(target).item;
// disable copying items
source.copyState = function() { return false; };
@ -78,26 +80,26 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function (item) {
items.forEach(function (item) {
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
return rv;
},
reload: function() {
const user_search = $("filter_search");
const user_search = App.byId("filter_search");
let search = "";
if (user_search) { search = user_search.value; }
xhrPost("backend.php", { op: "pref-filters", search: search }, (transport) => {
dijit.byId('filterConfigTab').attr('content', transport.responseText);
xhr.post("backend.php", { op: "pref-filters", search: search }, (reply) => {
dijit.byId('filtersTab').attr('content', reply);
Notify.close();
});
},
resetFilterOrder: function() {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-filters", method: "filtersortreset"}, () => {
xhr.post("backend.php", {op: "pref-filters", method: "filtersortreset"}, () => {
this.reload();
});
},
@ -112,109 +114,11 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
if (confirm(__("Combine selected filters?"))) {
Notify.progress("Joining filters...");
xhrPost("backend.php", {op: "pref-filters", method: "join", ids: rows.toString()}, () => {
xhr.post("backend.php", {op: "pref-filters", method: "join", ids: rows.toString()}, () => {
this.reload();
});
}
},
editSelectedFilter: function() {
const rows = this.getSelectedFilters();
if (rows.length == 0) {
alert(__("No filters selected."));
return;
}
if (rows.length > 1) {
alert(__("Please select only one filter."));
return;
}
Notify.close();
this.editFilter(rows[0]);
},
editFilter: function(id) {
const query = "backend.php?op=pref-filters&method=edit&id=" + encodeURIComponent(id);
if (dijit.byId("feedEditDlg"))
dijit.byId("feedEditDlg").destroyRecursive();
if (dijit.byId("filterEditDlg"))
dijit.byId("filterEditDlg").destroyRecursive();
const dialog = new dijit.Dialog({
id: "filterEditDlg",
title: __("Edit Filter"),
style: "width: 600px",
test: function () {
Filters.editFilterTest(dojo.formToObject("filter_edit_form"));
},
selectRules: function (select) {
Lists.select("filterDlg_Matches", select);
},
selectActions: function (select) {
Lists.select("filterDlg_Actions", select);
},
editRule: function (e) {
const li = e.parentNode;
const rule = li.getElementsByTagName("INPUT")[1].value;
Filters.addFilterRule(li, rule);
},
editAction: function (e) {
const li = e.parentNode;
const action = li.getElementsByTagName("INPUT")[1].value;
Filters.addFilterAction(li, action);
},
removeFilter: function () {
const msg = __("Remove filter?");
if (confirm(msg)) {
this.hide();
Notify.progress("Removing filter...");
const query = {op: "pref-filters", method: "remove", ids: this.attr('value').id};
xhrPost("backend.php", query, () => {
dijit.byId("filterTree").reload();
});
}
},
addAction: function () {
Filters.addFilterAction();
},
addRule: function () {
Filters.addFilterRule();
},
deleteAction: function () {
$$("#filterDlg_Actions li[class*=Selected]").each(function (e) {
e.parentNode.removeChild(e)
});
},
deleteRule: function () {
$$("#filterDlg_Matches li[class*=Selected]").each(function (e) {
e.parentNode.removeChild(e)
});
},
execute: function () {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhrPost("backend.php", dojo.formToObject("filter_edit_form"), () => {
dialog.hide();
dijit.byId("filterTree").reload();
});
}
},
href: query
});
dialog.show();
},
removeSelectedFilters: function() {
const sel_rows = this.getSelectedFilters();
@ -227,7 +131,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
/* global __, define, lib, dijit, dojo, xhrPost, Notify */
/* eslint-disable prefer-rest-params */
/* global __, define, lib, dijit, dojo, xhr, Notify, fox, App */
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/form/DropDownButton"], function (declare, domConstruct) {
@ -13,10 +14,10 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
_createTreeNode: function(args) {
const tnode = this.inherited(arguments);
const fg_color = this.model.store.getValue(args.item, 'fg_color');
const bg_color = this.model.store.getValue(args.item, 'bg_color');
//const fg_color = this.model.store.getValue(args.item, 'fg_color');
//const bg_color = this.model.store.getValue(args.item, 'bg_color');
const type = this.model.store.getValue(args.item, 'type');
const bare_id = this.model.store.getValue(args.item, 'bare_id');
//const bare_id = this.model.store.getValue(args.item, 'bare_id');
if (type == 'label') {
const label = dojo.doc.createElement('i');
@ -39,6 +40,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
return tnode;
},
getIconClass: function (item, opened) {
// eslint-disable-next-line no-nested-ternary
return (!item || this.model.mayHaveChildren(item)) ? (opened ? "dijitFolderOpened" : "dijitFolderClosed") : "invisible";
},
getSelectedLabels: function() {
@ -46,79 +48,139 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function(item) {
items.forEach(function(item) {
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
return rv;
},
reload: function() {
xhrPost("backend.php", { op: "pref-labels" }, (transport) => {
dijit.byId('labelConfigTab').attr('content', transport.responseText);
xhr.post("backend.php", { op: "pref-labels" }, (reply) => {
dijit.byId('labelsTab').attr('content', reply);
Notify.close();
});
},
editLabel: function(id) {
const query = "backend.php?op=pref-labels&method=edit&id=" +
encodeURIComponent(id);
if (dijit.byId("labelEditDlg"))
dijit.byId("labelEditDlg").destroyRecursive();
const dialog = new dijit.Dialog({
id: "labelEditDlg",
title: __("Label Editor"),
style: "width: 650px",
setLabelColor: function (id, fg, bg) {
let kind = '';
let color = '';
if (fg && bg) {
kind = 'both';
} else if (fg) {
kind = 'fg';
color = fg;
} else if (bg) {
kind = 'bg';
color = bg;
}
const e = $("icon-label-" + id);
if (e) {
if (bg) e.style.color = bg;
}
const query = {
op: "pref-labels", method: "colorset", kind: kind,
ids: id, fg: fg, bg: bg, color: color
};
xhrPost("backend.php", query, () => {
dijit.byId("filterTree").reload(); // maybe there's labels in there
});
xhr.json("backend.php", {op: "pref-labels", method: "edit", id: id}, (reply) => {
const fg_color = reply['fg_color'];
const bg_color = reply['bg_color'] ? reply['bg_color'] : '#fff7d5';
const dialog = new fox.SingleUseDialog({
id: "labelEditDlg",
title: __("Edit label"),
setLabelColor: function (id, fg, bg) {
let kind = '';
let color = '';
if (fg && bg) {
kind = 'both';
} else if (fg) {
kind = 'fg';
color = fg;
} else if (bg) {
kind = 'bg';
color = bg;
}
const e = App.byId(`icon-label-${id}`);
if (e) {
if (bg) e.style.color = bg;
}
const query = {
op: "pref-labels", method: "colorset", kind: kind,
ids: id, fg: fg, bg: bg, color: color
};
xhr.post("backend.php", query, () => {
const tree = dijit.byId("filterTree");
if (tree) tree.reload(); // maybe there's labels in there
});
},
execute: function () {
if (this.validate()) {
const caption = this.attr('value').caption;
const fg_color = this.attr('value').fg_color;
const bg_color = this.attr('value').bg_color;
},
execute: function () {
if (this.validate()) {
const caption = this.attr('value').caption;
const fg_color = this.attr('value').fg_color;
const bg_color = this.attr('value').bg_color;
dijit.byId('labelTree').setNameById(id, caption);
this.setLabelColor(id, fg_color, bg_color);
this.hide();
xhr.post("backend.php", this.attr('value'), () => {
const tree = dijit.byId("filterTree");
if (tree) tree.reload(); // maybe there's labels in there
});
}
},
content: `
<form onsubmit='return false'>
<section>
<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'
value="${App.escapeHtml(reply.caption)}">
</section>
${App.FormFields.hidden_tag('id', id)}
${App.FormFields.hidden_tag('op', 'pref-labels')}
${App.FormFields.hidden_tag('method', 'save')}
${App.FormFields.hidden_tag('fg_color', fg_color, {}, 'labelEdit_fgColor')}
${App.FormFields.hidden_tag('bg_color', bg_color, {}, 'labelEdit_bgColor')}
<section>
<table width='100%'>
<tr>
<th>${__("Foreground:")}</th>
<th>${__("Background:")}</th>
</tr>
<tr>
<td class='text-center'>
<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='fg_color'>
dijit.byId('labelEdit_fgColor').attr('value', fg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({color: fg_color});
</script>
</div>
</td>
<td class='text-center'>
<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='bg_color'>
dijit.byId('labelEdit_bgColor').attr('value', bg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({backgroundColor: bg_color});
</script>
</div>
</td>
</tr>
</table>
</section>
<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()'>
${__('Cancel')}
</button>
</footer>
</form>
`
});
dijit.byId('labelTree').setNameById(id, caption);
this.setLabelColor(id, fg_color, bg_color);
this.hide();
dialog.show();
xhrPost("backend.php", this.attr('value'), () => {
dijit.byId("filterTree").reload(); // maybe there's labels in there
});
}
},
href: query
});
dialog.show();
},
resetColors: function() {
const labels = this.getSelectedLabels();
@ -131,7 +193,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
ids: labels.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -152,7 +214,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}

@ -1,16 +1,18 @@
'use strict'
/* global __ */
/* global xhrPost, dojo, dijit, Notify, Tables */
/* global __, xhr, dijit, Notify, Tables, App, fox */
const Users = {
reload: function(sort) {
const user_search = $("user_search");
const search = user_search ? user_search.value : "";
return new Promise((resolve, reject) => {
const user_search = App.byId("user_search");
const search = user_search ? user_search.value : "";
xhrPost("backend.php", { op: "pref-users", sort: sort, search: search }, (transport) => {
dijit.byId('userConfigTab').attr('content', transport.responseText);
Notify.close();
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() {
@ -19,38 +21,113 @@ const Users = {
if (login) {
Notify.progress("Adding user...");
xhrPost("backend.php", {op: "pref-users", method: "add", login: login}, (transport) => {
alert(transport.responseText);
Users.reload();
xhr.post("backend.php", {op: "pref-users", method: "add", login: login}, (reply) => {
Users.reload().then(() => {
Notify.info(reply);
})
});
}
},
edit: function(id) {
const query = "backend.php?op=pref-users&method=edit&id=" +
encodeURIComponent(id);
if (dijit.byId("userEditDlg"))
dijit.byId("userEditDlg").destroyRecursive();
const dialog = new dijit.Dialog({
id: "userEditDlg",
title: __("User Editor"),
style: "width: 600px",
execute: function () {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhrPost("backend.php", dojo.formToObject("user_edit_form"), (/* transport */) => {
dialog.hide();
Users.reload();
});
}
},
href: query
});
xhr.json('backend.php', {op: 'pref-users', method: 'edit', id: id}, (reply) => {
const user = reply.user;
const admin_disabled = (user.id == 1);
const dialog = new fox.SingleUseDialog({
id: "userEditDlg",
title: __("Edit user"),
execute: function () {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhr.post("backend.php", this.attr('value'), (reply) => {
dialog.hide();
Users.reload().then(() => {
Notify.info(reply);
});
});
}
},
content: `
<form onsubmit='return false'>
${App.FormFields.hidden_tag('id', user.id.toString())}
${App.FormFields.hidden_tag('op', 'pref-users')}
${App.FormFields.hidden_tag('method', 'editSave')}
<div dojoType="dijit.layout.TabContainer" style="height : 400px">
<div dojoType="dijit.layout.ContentPane" title="${__('Edit user')}">
<section>
<fieldset>
<label>${__("Login:")}</label>
<input style='font-size : 16px'
${admin_disabled ? "disabled='1'" : ''}
dojoType='dijit.form.ValidationTextBox' required='1'
name='login' value="${App.escapeHtml(user.login)}">
${admin_disabled ? App.FormFields.hidden_tag("login", user.login) : ''}
</fieldset>
<hr/>
<fieldset>
<label>${__('Access level: ')}</label>
${App.FormFields.select_hash("access_level",
user.access_level, reply.access_level_names, {disabled: admin_disabled.toString()})}
${admin_disabled ? App.FormFields.hidden_tag("access_level",
user.access_level.toString()) : ''}
</fieldset>
<fieldset>
<label>${__("New password:")}</label>
<input dojoType='dijit.form.TextBox' type='password' size='20'
placeholder='${__("Change password")}' name='password'>
</fieldset>
<fieldset>
<label></label>
<label class="checkbox">
${App.FormFields.checkbox_tag("otp_enabled", user.otp_enabled)}
${__('OTP enabled')}
</fieldset>
<hr/>
<fieldset>
<label>${__("E-mail:")}</label>
<input dojoType='dijit.form.TextBox' size='30' name='email'
value="${App.escapeHtml(user.email)}">
</fieldset>
</section>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('User details')}">
<script type='dojo/method' event='onShow' args='evt'>
if (this.domNode.querySelector('.loading')) {
xhr.post("backend.php", {op: 'pref-users', method: 'userdetails', id: ${user.id}}, (reply) => {
this.attr('content', reply);
});
}
</script>
<span class='loading'>${__("Loading, please wait...")}</span>
</div>
</div>
<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()'>
${__('Cancel')}
</button>
</footer>
</form>
`
});
dialog.show();
dialog.show();
});
},
resetSelected: function() {
const rows = this.getSelection();
@ -70,9 +147,9 @@ const Users = {
const id = rows[0];
xhrPost("backend.php", {op: "pref-users", method: "resetPass", id: id}, (transport) => {
xhr.post("backend.php", {op: "pref-users", method: "resetPass", id: id}, (reply) => {
Notify.close();
Notify.info(transport.responseText, true);
Notify.info(reply, true);
});
}
@ -89,7 +166,7 @@ const Users = {
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -98,23 +175,8 @@ const Users = {
alert(__("No users selected."));
}
},
editSelected: function() {
const rows = this.getSelection();
if (rows.length == 0) {
alert(__("No users selected."));
return;
}
if (rows.length > 1) {
alert(__("Please select one user."));
return;
}
this.edit(rows[0]);
},
getSelection :function() {
return Tables.getSelected("prefUserList");
return Tables.getSelected("users-list");
}
}

@ -0,0 +1,19 @@
/* eslint-disable prefer-rest-params */
/* global dijit, define */
define(["dojo/_base/declare", "dijit/Dialog"], function (declare) {
return declare("fox.SingleUseDialog", dijit.Dialog, {
create: function(params) {
const extant = dijit.byId(params.id);
if (extant) {
console.warn('SingleUseDialog: destroying existing widget:', params.id, '=', extant)
extant.destroyRecursive();
}
return this.inherited(arguments);
},
onHide: function() {
this.destroyRecursive();
}
});
});

@ -1,60 +1,253 @@
'use strict';
/* global dijit, __, App, Ajax */
/* global dijit, App, dojo, __csrf_token */
/* eslint-disable no-new */
/* exported __ */
function __(msg) {
if (typeof App != "undefined") {
return App.l10n.__(msg);
} else {
return msg;
}
}
/* error reporting shim */
// TODO: deprecated; remove
/* function exception_error(e, e_compat, filename, lineno, colno) {
if (typeof e == "string")
e = e_compat;
/* exported ngettext */
function ngettext(msg1, msg2, n) {
return __((parseInt(n) > 1) ? msg2 : msg1);
}
App.Error.report(e, {filename: filename, lineno: lineno, colno: colno});
} */
/* exported $ */
function $(id) {
console.warn("FIXME: please use App.byId() or document.getElementById() instead of $():", id);
return document.getElementById(id);
}
/* xhr shorthand helpers */
/* exported $$ */
function $$(query) {
console.warn("FIXME: please use App.findAll() or document.querySelectorAll() instead of $$():", query);
return document.querySelectorAll(query);
}
/* exported xhrPost */
function xhrPost(url, params, complete) {
console.log("xhrPost:", params);
return new Promise((resolve, reject) => {
new Ajax.Request(url, {
parameters: params,
onComplete: function(reply) {
if (complete != undefined) complete(reply);
Element.prototype.hasClassName = function(className) {
return this.classList.contains(className);
};
resolve(reply);
}
});
Element.prototype.addClassName = function(className) {
return this.classList.add(className);
};
Element.prototype.removeClassName = function(className) {
return this.classList.remove(className);
};
Element.prototype.toggleClassName = function(className) {
if (this.hasClassName(className))
return this.removeClassName(className);
else
return this.addClassName(className);
};
Element.prototype.setStyle = function(args) {
Object.keys(args).forEach((k) => {
this.style[k] = args[k];
});
};
Element.prototype.show = function() {
this.style.display = "";
};
Element.prototype.hide = function() {
this.style.display = "none";
};
Element.prototype.toggle = function() {
if (this.visible())
this.hide();
else
this.show();
};
// https://gist.github.com/alirezas/c4f9f43e9fe1abba9a4824dd6fc60a55
Element.prototype.fadeOut = function() {
this.style.opacity = 1;
const self = this;
(function fade() {
if ((self.style.opacity -= 0.1) < 0) {
self.style.display = "none";
} else {
requestAnimationFrame(fade);
}
}());
};
Element.prototype.fadeIn = function(display = undefined){
this.style.opacity = 0;
this.style.display = display == undefined ? "block" : display;
const self = this;
(function fade() {
let val = parseFloat(self.style.opacity);
if (!((val += 0.1) > 1)) {
self.style.opacity = val;
requestAnimationFrame(fade);
}
}());
};
Element.prototype.visible = function() {
return window.getComputedStyle(this).display != "none"; //&& this.offsetHeight != 0 && this.offsetWidth != 0;
}
/* exported xhrJson */
function xhrJson(url, params, complete) {
return new Promise((resolve, reject) => {
return xhrPost(url, params).then((reply) => {
let obj = null;
Element.visible = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
try {
obj = JSON.parse(reply.responseText);
} catch (e) {
console.error("xhrJson", e, reply);
}
return elem.visible();
}
if (complete != undefined) complete(obj);
Element.show = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
resolve(obj);
});
});
return elem.show();
}
Element.hide = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
return elem.hide();
}
Element.toggle = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
return elem.toggle();
}
Element.hasClassName = function (elem, className) {
if (typeof elem == "string")
elem = document.getElementById(elem);
return elem.hasClassName(className);
}
/* add method to remove element from array */
Array.prototype.remove = function(s) {
for (let i=0; i < this.length; i++) {
if (s == this[i]) this.splice(i, 1);
}
};
Array.prototype.uniq = function() {
return this.filter((v, i, a) => a.indexOf(v) === i);
};
String.prototype.stripTags = function() {
return this.replace(/<\w+(\s+("[^"]*"|'[^']*'|[^>])+)?(\/)?>|<\/\w+>/gi, '');
}
/* exported xhr */
const xhr = {
_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) => {
if (typeof __csrf_token != "undefined")
params = {...params, ...{csrf_token: __csrf_token}};
dojo.xhrPost({url: url,
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, (new Date().getTime() - xhr._ts) + " ms");
if (complete != undefined)
complete(data, ioargs.xhr);
resolve(data)
}}
);
});
},
json: function(url, params = {}, complete = undefined, failed = undefined) {
return new Promise((resolve, reject) =>
this.post(url, params).then((data) => {
let obj = null;
try {
obj = JSON.parse(data);
} catch (e) {
console.error("xhr.json", e, xhr);
if (failed != undefined)
failed(e);
reject(e);
}
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;
}
if (complete != undefined) complete(obj);
resolve(obj);
}
));
}
};
/* exported xhrPost */
function xhrPost(url, params = {}, complete = undefined) {
console.log("xhrPost:", params);
return new Promise((resolve, reject) => {
if (typeof __csrf_token != "undefined")
params = {...params, ...{csrf_token: __csrf_token}};
dojo.xhrPost({url: url,
postData: dojo.objectToQuery(params),
handleAs: "text",
error: function(error) {
reject(error);
},
load: function(data, ioargs) {
if (complete != undefined)
complete(ioargs.xhr);
resolve(ioargs.xhr)
}});
});
}
/* exported xhrJson */
function xhrJson(url, params = {}, complete = undefined) {
return xhr.json(url, params, complete);
}
/* common helpers not worthy of separate Dojo modules */
/* exported Lists */
@ -64,14 +257,17 @@ const Lists = {
// account for dojo checkboxes
elem = elem.domNode || elem;
const row = elem.up("li");
const row = elem.closest("li");
if (row)
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).select("li").each((row) => {
const checkNode = row.select(".dijitCheckBox,input[type=checkbox]")[0];
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);
@ -85,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 */
@ -94,15 +314,18 @@ const Tables = {
const checked = elem.domNode ? elem.attr("checked") : elem.checked;
elem = elem.domNode || elem;
const row = elem.up("tr");
const row = elem.closest("tr");
if (row)
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).select("tr").each((row) => {
const checkNode = row.select(".dijitCheckBox,input[type=checkbox]")[0];
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);
@ -116,16 +339,25 @@ const Tables = {
}
});
},
getSelected: function(elemId) {
getSelected: function(elem) {
const rv = [];
$(elemId).select("tr").each((row) => {
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("tr").forEach((row) => {
if (row.hasClassName("Selected")) {
// either older prefix-XXX notation or separate attribute
const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, "");
const rowVal = row.getAttribute("data-row-value");
if (!isNaN(rowId))
rv.push(parseInt(rowId));
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));
}
}
});
@ -173,7 +405,7 @@ const Notify = {
kind = kind || this.KIND_GENERIC;
keep = keep || false;
const notify = $("notify");
const notify = App.byId("notify");
window.clearTimeout(this.timeout);
@ -200,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";
@ -238,25 +470,3 @@ const Notify = {
}
};
// http://stackoverflow.com/questions/6251937/how-to-get-selecteduser-highlighted-text-in-contenteditable-element-and-replac
/* exported getSelectionText */
function getSelectionText() {
let text = "";
if (typeof window.getSelection != "undefined") {
const sel = window.getSelection();
if (sel.rangeCount) {
const container = document.createElement("div");
for (let i = 0, len = sel.rangeCount; i < len; ++i) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
text = container.innerHTML;
}
} else if (typeof document.selection != "undefined") {
if (document.selection.type == "Text") {
text = document.selection.createRange().textText;
}
}
return text.stripTags();
}

@ -1,4 +1,5 @@
/* global dijit */
/* eslint-disable prefer-rest-params */
/* global dijit, define */
define(["dojo/_base/declare", "dijit/form/ComboButton"], function (declare) {
return declare("fox.form.ComboButton", dijit.form.ComboButton, {
startup: function() {

@ -1,4 +1,5 @@
/* global dijit */
/* eslint-disable prefer-rest-params */
/* global dijit, define */
define(["dojo/_base/declare", "dijit/form/DropDownButton"], function (declare) {
return declare("fox.form.DropDownButton", dijit.form.DropDownButton, {
startup: function() {

@ -1,4 +1,4 @@
/* global dijit */
/* global dijit, define */
define(["dojo/_base/declare", "dijit/form/Select"], function (declare) {
return declare("fox.form.Select", dijit.form.Select, {
focus: function() {

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

Loading…
Cancel
Save