Compare commits

...

266 Commits

Author SHA1 Message Date
J. King 8834a65e4c Address case where user is already arsse 3 months ago
J. King b1154359e4 Prepare release 3 months ago
J. King 3838cdc0af Merge branch 'arch2' 3 months ago
J. King 1f9beb85a5 Fix socket path 3 months ago
J. King 2670ed4ab8 Typo 3 months ago
J. King 4df97eefcb Oops 3 months ago
J. King 82f94d1dae Remove obsolete systemd patching 3 months ago
J. King 7f6b36d4da Remove references to Pandoc 3 months ago
J. King 06ec67816a Update arsse-git PKGBUILD 3 months ago
J. King e647d79e1e Update dependencies 3 months ago
J. King f853851568 Run with the correct executable name visible to PHP 3 months ago
J. King 4131531bff Tweaks 3 months ago
J. King 9086a5d9b1 Add interpreter adaptation for service, with documentation 3 months ago
J. King cce48878e7 Add pool for php-legacy 3 months ago
J. King 185ae88ba3 Correct outdated documentation 3 months ago
J. King 29fb134633 First steps in supporting php-legacy in Arc 3 months ago
J. King 9951b44932 Update changelog 5 months ago
J. King 692af39768 Use mdoc manual directly without any preprocessing 5 months ago
J. King 73446887f5 Editorial tweak 5 months ago
J. King e20937f98f The rest of the mdoc manual 5 months ago
J. King 2ff3286aa9 More work on mdoc manual 5 months ago
J. King 174ed544b2 Start on an mdoc-format manual 5 months ago
J. King 5579144fee Complete native groff manual 5 months ago
J. King 7613142221 Start on a native groff manual 5 months ago
J. King d9b90390e7 Update style rules 10 months ago
J. King 59a9329032 Upgrade dependencies where possible with PHP 7.3 1 year ago
J. King be3adf7026 Document RoboFile better 1 year ago
J. King eb371b75fe Fix documentation errors 1 year ago
J. King 1b80ad37bc Merge branch 'csfixer3' 1 year ago
J. King 3c83fc9139 Update php-cs-fixer rules 1 year ago
J. King 711f87aad8 Housekeeping 1 year ago
J. King 0a8d19d37d Require PHP 7.3 1 year ago
J. King fe06ffc176 Avoid dynamic property creation with PicoFeed 1 year ago
J. King 0d6f8d2921 Avoid most deprecation warnings 1 year ago
J. King 92b1a840a1 Support PHP 8.2 properly 1 year ago
J. King a25e777ec6 Version bump 2 years ago
J. King 44e2c9c13e Update documentation 2 years ago
J. King 866800dcc5 Finish last Guzzle-related tests 2 years ago
J. King 136d3782e3 Update changelog 2 years ago
J. King 3be3f43bab Start on tests for response wrappers 2 years ago
J. King d2f3f19128 Fix failures 2 years ago
J. King 459e44e041 Address remaining errors 2 years ago
J. King 56f015bfb9 More Guzzle conversion 2 years ago
J. King 64ec3f6ae4 Use unused variable 2 years ago
J. King 4d18bf27e2 Adjust most uses of Diactoros to Guzzle PSR-7 2 years ago
J. King e588a52e88 Replace ServerRequestFactory 2 years ago
J. King 6c0183faea Replace instances of Diactoros' EmptyResponse 2 years ago
J. King 560d4db139 Remove Diactoros in favour of Guzzle PSR-7 2 years ago
J. King 2557c22410 Update dependencies 2 years ago
J. King 4ca7b65a65 Update dependencies 2 years ago
J. King 4d37ae30ae Update dependencies 2 years ago
J. King d1da6fbe5e Use cases rather than casting bools to int in SQL 2 years ago
J. King d54733ad98 Update link to Nextcloud News documentation again 2 years ago
J. King a0c31fac5d Merge branch 'reader' 2 years ago
J. King 59358ec35b More PHP 7 fixes 2 years ago
J. King 90b66241b3 Fixes for PHP 7 2 years ago
J. King 761b3d5333 Return removed articles correctly in Miniflux 2 years ago
J. King d64dc751f9 Tests for query filters 2 years ago
J. King f51acb4264 Build exceptions correctly in Miniflux for clarity 2 years ago
J. King 300225439c Fix trivial error in Miniflux 2 years ago
J. King c6cc2a1a42 Restore coverage for Query class 2 years ago
J. King a44fe103d8 Prototype for nesting query filters 2 years ago
J. King 630536d789 Tests for union context 2 years ago
J. King 206c5c0012 Fill in union context 2 years ago
J. King 0c8f33c37c Remove setCTE and pushCTE from query builder 2 years ago
J. King 26e431b1a5 Simplify more queries 2 years ago
J. King 336207741d Add missing API documentation 2 years ago
J. King 6863c182d7 Update reference to the "Reeder" client 2 years ago
J. King f2aad7188c Update links to TT-RSS documentation 2 years ago
J. King 65b1bb4fcd Allow multiple dates in TT-RSS searches 2 years ago
J. King 2c5b9a6768 Fix missing TTRSS coverage 2 years ago
J. King 17832ac63e Allow timezone in TT-RSS search queries 2 years ago
J. King e65069885b Clean up obsolete FIXMEs 2 years ago
J. King 7e5d8494c4 Tests for selecting arrays of ranges 2 years ago
J. King e6505a5fda Work around possible MySQL bug 2 years ago
J. King 2acacd2647 Implement handling for arrays of ranges 2 years ago
J. King f6799e2ab1 Tests for date ranges in contexts 2 years ago
J. King 33a3478a58 Avoid use of PHP 7.4 feature 2 years ago
J. King 2489743d0f Further simplifications 2 years ago
J. King 0bd01849bb Remove unnecessary in() clause 2 years ago
J. King 895c045c9b Simplify folder selection in article queries 2 years ago
J. King fe02613214 Fix coverage 2 years ago
J. King 427bddd3b7 Allow multiple date ranges 2 years ago
J. King 53ba591720 Finish up article selection refactor 2 years ago
J. King 97dfef3267 Fix typos 2 years ago
J. King 396ca86482 Start on removal of conditional CTEs 2 years ago
J. King 4a87926dd5 Fix up context tests 2 years ago
J. King 6f1332c559 Start to shore up testing 2 years ago
J. King 308b592b18 Clean up coontext classes 2 years ago
J. King 983fa58ec8 Convert article and edition ranges to atomic 2 years ago
J. King 2c2bb4a856 Retrofits dates to use ranges 2 years ago
J. King c993168002 Update URL of Nextcloud News documentation 2 years ago
J. King 73497688fc Break contexts up into traits 2 years ago
J. King 1b0256d6ce Abandon automation of binary packaging for now 2 years ago
J. King 144a41e061 Prepare new version 2 years ago
J. King 60b4002329 Revert "Document that we actually emulate Miniflux 2.0.29" 2 years ago
J. King f24ec8b00b Address security vulnerability in Guzzle's PSR-7 2 years ago
J. King d379aa2253 Document that we actually emulate Miniflux 2.0.29 2 years ago
J. King b707ecc942 Tag new version 2 years ago
J. King afe26fb8e1 Style fixes 2 years ago
J. King 3a219a591d Update dependencies 2 years ago
J. King b5579d6e43 Support PHP 8.1 2 years ago
J. King b660508009 Improve MySQL test performance 2 years ago
J. King 3c884f521b Update dependencies 2 years ago
J. King 70b063e028 Make parts of generic packaging conditional 3 years ago
J. King cf3d270077 Merge branch 'deb' 3 years ago
J. King 1fa75aba4a Generate Debian source package without deb tooling 3 years ago
J. King 317d23c1bb Fix copy-paste error in manual 3 years ago
J. King 75dbe380ba Add Pandoc to AUR arsse-git build dependencies 3 years ago
J. King 08250841a0 Don't sign packages 3 years ago
J. King 2452264893 Date release 3 years ago
J. King c1e57eb01f Add manual installation instructions 3 years ago
J. King e75e0dfd2f Clarify exactly what "older Debians" is 3 years ago
J. King 6c11c9e485 Fix Debian bugs 3 years ago
J. King 40a2856ae8 Use correct PHP_FPM socket paths for Debian 3 years ago
J. King c7dcc36ba1 Update version 3 years ago
J. King a31fb896d9 Update changelog 3 years ago
J. King def4a3bc77 Move Debian adaptations to Debian packaging rules 3 years ago
J. King cd89472575 Fix up Debian documentation 3 years ago
J. King beea98040c Initial documentation for Debian packages 3 years ago
J. King 59ff88f3b6 Add all-in-one packaging task 3 years ago
J. King 4070870421 Enforce external tooling requirements 3 years ago
J. King 5ab9dc3840 Add missing pbuilder script 3 years ago
J. King 20ffb2484a Simply Robo input for Arch and Deb packaging 3 years ago
J. King f91b3c0120 Output packages to a "release" directory 3 years ago
J. King 4121fc3e21 Database server ports must be integers 3 years ago
J. King 7ad3611a84 Set up configuration file for Debian properly 3 years ago
J. King 5412eb348f Clean up maintainer scripts 3 years ago
J. King c936ecc1af Fix another typo 3 years ago
J. King 99c923b1b1 Fix typos 3 years ago
J. King c2237532eb Add glue for dbconfig-common configuration 3 years ago
J. King 9687ce026e Add MySQL back to Debian depeendencies 3 years ago
J. King 30bed8a9d5 Typo 3 years ago
J. King cf9059c2b0 Update tooling 3 years ago
J. King 93bcf93685 Prototype Debian maintainer scripts 3 years ago
J. King 46e20be983 Test for service reloading 3 years ago
J. King ad32bf3340 Style fixes 3 years ago
J. King b8ac646d22 Fix up hangup signal handling 3 years ago
J. King 37c58e186a Handle hangup signal 3 years ago
J. King 88fe3e76cb Fix up missing-extension message 3 years ago
J. King 3c8ee42666 Basic tests for exception checking 3 years ago
J. King 04adc3b997 Document forking in the manpage 3 years ago
J. King c49cb72528 Fail gracefully when extensions are missing 3 years ago
J. King c9a2393a4e Note requirement for filter extension 3 years ago
J. King 75e87f31a0 Prototype code to check for missing extensions 3 years ago
J. King 3b51d4daea Fix license for Debian package 3 years ago
J. King ce9dfc3f30 Add init script to Debian files 3 years ago
J. King cbc7cd8ea7 Add an explicit path to init script 3 years ago
J. King 2e29f3f76e Correct typo 3 years ago
J. King 8a1a1eee42 Prototype init script 3 years ago
J. King e160189224 Handle exceptions from child processes 3 years ago
J. King e9394e8599 More forking tweaks 3 years ago
J. King 577356cd3d Fork error test 3 years ago
J. King 514cb0a351 Ow 3 years ago
J. King 0bb5e916d2 Test PID writing 3 years ago
J. King 2767ab755e Use D modifier in pattern 3 years ago
J. King a4036afbf8 Partial tests for PID file reading 3 years ago
J. King 32c9d761c3 Clean up more exceptions 3 years ago
J. King 5b3e8fbef0 Refine some exceptions 3 years ago
J. King b9fd9ac32e Tweaks 3 years ago
J. King 23749b51aa Tests for path resolution 3 years ago
J. King dfaf44ac68 Basic path resolution tests 3 years ago
J. King bab64add9b Separate PID conflict checking from PID claiming 3 years ago
J. King 822158d1bd Update dependencies 3 years ago
J. King f1c29c99c7 Finish testing PID file path checking 3 years ago
J. King 59cf27089a More daemon cleanup 3 years ago
J. King 4e1193bab2 Move forking daemon support code to own class 3 years ago
J. King 32e04e3938 Move forking and related to Service class 3 years ago
J. King 55acb87577 Start on PI(D file resolution tests 3 years ago
J. King e8cab78bd6 Handle last possible PID failures 3 years ago
J. King 9595c4f019 Start filling out PID file exceptions 3 years ago
J. King 372bf9f630 Exclude code from coverage 3 years ago
J. King 4ffc29781d Remove references to oldpass param 3 years ago
J. King 47af739e47 Catch more PID path failures 3 years ago
J. King 2c7b16ed27 Respond to termination signals and delete PID file 3 years ago
J. King 410310282f Load configuration after forking 3 years ago
J. King 29b83b4453 Prototype forking daemon 3 years ago
J. King fc2abc1203 Use D modifier for all patterns with $ anchors 3 years ago
J. King 59c5c2eb14 Oops 3 years ago
J. King 3cd3ac4a51 Correct filename conflict 3 years ago
J. King 837895fd6a Adapt dist files for Debian 3 years ago
J. King b4c9413130 Update README 3 years ago
J. King bafb788b02 Correct errors in manual 3 years ago
J. King 68e3cd82ca Don't include section number in title 3 years ago
J. King c3fa4788d6 Use proper metadata block for manpage 3 years ago
J. King 3567f294a6 Merge branch 'manpage' 3 years ago
J. King 8c0f047747 Update HTML manual to mention man page 3 years ago
J. King fd76b1b611 Add examples to manual page 3 years ago
J. King 4317a96db1 Work around double spacing 3 years ago
J. King 62d49e0d3c Fill out most of the manual page 3 years ago
J. King 88487d27a2 Expand manual page 3 years ago
J. King 46c88f584f Fix copying of man page in PKGBUILDs 3 years ago
J. King 92823d5bc2 Create directories before executing Pandoc 3 years ago
J. King 3e55ab3849 Move man pages to their own directory 3 years ago
J. King 2ec7acc50b Turn off "smart" character substitution in Pandoc 3 years ago
J. King d3a983e7f0 Move the markdown manpage 3 years ago
J. King 176aac0ad7 Fix stupid typo properly 3 years ago
J. King e439dd8277 Fix manpage in Arch PKGBUILD 3 years ago
J. King 6cc9f96728 Prototype manual page 3 years ago
J. King d4569c77a9 Add database location to tmpfiles 3 years ago
J. King add1acc87a Fix more lintian complaints 3 years ago
J. King 14d3cdfe58 Hopefully fix some Debian problems 3 years ago
J. King 281760be71 Address some lintian complaints 3 years ago
J. King 758a02d667 Move generic configuration file 3 years ago
J. King 18846c19cb Add install list for Debian package 3 years ago
J. King b5bbdc2bc6 Date release 3 years ago
J. King 3be6c9984d Update Apache documentation in manual 3 years ago
J. King 6c84b2199e More Apache fixes 3 years ago
J. King f9cbac2c31 Hopefully fix Apache configuration 3 years ago
J. King 32ca0c3fe4 Appease GitHub once and for all 3 years ago
J. King a81bd0e45c Add whitespace 3 years ago
J. King 86d82a2586 Use global flag when replacing with sed 3 years ago
J. King f0bf55f9cf Add ExecStart to parent systemd unit 3 years ago
J. King 1055611940 Add version constraints to Arch dependencies 3 years ago
J. King 2ccfb1fd33 Fix packaging process 3 years ago
J. King 9eabfd0f27 Fix up sed usage in PKGBUILD 3 years ago
J. King 0236b42052 Use tmpfiles to create link to config file 3 years ago
J. King 8aa9d81fd1 Update changelog 3 years ago
J. King 11fc83da60 Significant edits to the manual 3 years ago
J. King de55290746 Fix build dependencies for Deb package 3 years ago
J. King f844c17a94 More Debian fixes 3 years ago
J. King 0de9647809 Add compat file 3 years ago
J. King e653fb3f73 Enhancements to Debian files 3 years ago
J. King b7909d7cd3 Downgrade tool dependencies for Ubuntu 3 years ago
J. King 3c9f4dd66f Prototype Debian rules file 3 years ago
J. King 3537e74d49 Update dependencies 3 years ago
J. King d031d931a5 Tidy up the Robo file further 3 years ago
J. King 38cb1059b2 Shorten output of packaging task 3 years ago
J. King 16174f11b6 Add changelog parsing to packaging task 3 years ago
J. King 073f6b3c39 Prototype Debian control file and other changes 3 years ago
J. King 3f3f449da1 Re-organize manual 3 years ago
J. King 2260b7cc50 Back up all Web server configuration 3 years ago
J. King 61eb4a252e Fix doc URLs 3 years ago
J. King 3f401f1cfa Fix typo 3 years ago
J. King 6c750d2dc0 Documentation for installing on Arch 3 years ago
J. King 6d790c5efd Add prototype for new Apache configuration 3 years ago
J. King 3a3b9231df Use generic configuration where possible 3 years ago
J. King fbe03a2534 Use chmod instead of touch 3 years ago
J. King e75f8cebfb Add Arch packaging to Robo file 3 years ago
J. King 79391446cd Start moving Arch build responsibility to Robo 3 years ago
J. King 19ab9df063 Fix more bugs 3 years ago
J. King 568b12600b Drop privileges when executing CLI 3 years ago
J. King 488af80a85 Update changelog 3 years ago
J. King 44612cfe8f Add tmpfiles 3 years ago
J. King d1fd6e9653 Correct permissions 3 years ago
J. King a97ca23631 Don't try to enable extensions 3 years ago
J. King e2b182ebe6 Fix errors in Arch config file 3 years ago
J. King 3eab5aad5d Fix adding users to a blank database 3 years ago
J. King 805a508ea6 Use correct state path 3 years ago
J. King 3ebc23ab13 Tweaks 3 years ago
J. King 7abdf05b7f Make package from local files for now 3 years ago
J. King 971c12ff9f Rename sample to example 3 years ago
J. King febc7c7ca4 Add configuration for Nginx 3 years ago
J. King edb146b826 Use PHP-FPM instead of uWSGI 3 years ago
J. King 7ba4cabdde Prototype Arch PKGBUILD and supporting files 3 years ago
J. King 4080b2d09d Apply new rules 3 years ago
J. King 73731fa9db Fix up CS config file 3 years ago
J. King 18d296dcd6 Clean up CS fixer rules 3 years ago
J. King 114dcc568f Update dependencies 3 years ago
Dustin Wilson 1331b14a04 Reverting for now 3 years ago
Dustin Wilson 035feae0ce Removed postcss in favor of sass for building manual theme 3 years ago
J. King 8e063bea2f Appease GitHub again 3 years ago
J. King 4a9e66d872 Fix inconsistent grammar 3 years ago
J. King fa4ab3218a Version bump 3 years ago
J. King c4260323bc Answer 201 to PUTs like Miniflux 3 years ago
J. King abc291460c Update Web server configuration in manual 3 years ago
  1. 20
      .gitignore
  2. 33
      .php-cs-fixer.dist.php
  3. 76
      CHANGELOG
  4. 46
      README.md
  5. 602
      RoboFile.php
  6. 16
      UPGRADING
  7. 6
      arsse.php
  8. 33
      composer.json
  9. 830
      composer.lock
  10. 23
      dist/apache.conf
  11. 5
      dist/apache/arsse-fcgi.conf
  12. 34
      dist/apache/arsse-loc.conf
  13. 6
      dist/apache/arsse.conf
  14. 9
      dist/apache/example.conf
  15. 64
      dist/arch/PKGBUILD
  16. 79
      dist/arch/PKGBUILD-git
  17. 6
      dist/arch/apache-arsse-fcgi.conf
  18. 28
      dist/arch/arsse
  19. 37
      dist/arch/arsse-fetch.service
  20. 13
      dist/arch/arsse.service
  21. 16
      dist/arch/nginx-arsse-fcgi.conf
  22. 1
      dist/arch/systemd-environment
  23. 10
      dist/arsse
  24. 15
      dist/arsse.service
  25. 8
      dist/config.php
  26. 16
      dist/debian/arsse.config
  27. 1
      dist/debian/arsse.dirs
  28. 18
      dist/debian/arsse.install
  29. 1
      dist/debian/arsse.links
  30. 29
      dist/debian/arsse.postinst
  31. 20
      dist/debian/arsse.postrm
  32. 16
      dist/debian/arsse.prerm
  33. 1
      dist/debian/compat
  34. 15
      dist/debian/config.php
  35. 32
      dist/debian/control
  36. 34
      dist/debian/copyright
  37. 44
      dist/debian/dbconfig-common.php
  38. 6
      dist/debian/lintian-overrides
  39. 40
      dist/debian/pbuilder.sh
  40. 26
      dist/debian/rules
  41. 1
      dist/debian/source/format
  42. 2
      dist/debian/source/lintian-overrides
  43. 78
      dist/init.sh
  44. 342
      dist/man/man1/arsse.1
  45. 75
      dist/nginx.conf
  46. 15
      dist/nginx/arsse-fcgi.conf
  47. 49
      dist/nginx/arsse-loc.conf
  48. 13
      dist/nginx/arsse.conf
  49. 13
      dist/nginx/example.conf
  50. 11
      dist/php-fpm.conf
  51. 36
      dist/systemd/arsse-fetch.service
  52. 14
      dist/systemd/arsse.service
  53. 1
      dist/sysuser.conf
  54. 5
      dist/tmpfiles.conf
  55. 16
      docs/en/020_Getting_Started/010_Requirements.md
  56. 41
      docs/en/020_Getting_Started/020_Download_and_Installation.md
  57. 71
      docs/en/020_Getting_Started/020_Download_and_Installation/010_On_Arch_Linux.md
  58. 72
      docs/en/020_Getting_Started/020_Download_and_Installation/020_On_Debian_and_Derivatives.md
  59. 53
      docs/en/020_Getting_Started/020_Download_and_Installation/999_ On_Other_Systems.md
  60. 16
      docs/en/020_Getting_Started/020_Download_and_Installation/index.md
  61. 107
      docs/en/020_Getting_Started/030_Web_Server_Configuration.md
  62. 14
      docs/en/020_Getting_Started/040_Database_Setup/000_SQLite.md
  63. 2
      docs/en/020_Getting_Started/040_Database_Setup/010_PostgreSQL.md
  64. 2
      docs/en/020_Getting_Started/040_Database_Setup/020_MySQL.md
  65. 18
      docs/en/020_Getting_Started/050_Configuration.md
  66. 3
      docs/en/020_Getting_Started/index.md
  67. 22
      docs/en/025_Using_The_Arsse/010_Managing_Users.md
  68. 8
      docs/en/025_Using_The_Arsse/020_Importing_and_Exporting.md
  69. 49
      docs/en/025_Using_The_Arsse/030_Keeping_Newsfeeds_Up_to_Date.md
  70. 34
      docs/en/025_Using_The_Arsse/030_Other_Topics.md
  71. 12
      docs/en/025_Using_The_Arsse/040_Upgrading_to_a_New_Version.md
  72. 15
      docs/en/025_Using_The_Arsse/index.md
  73. 2
      docs/en/030_Supported_Protocols/005_Miniflux.md
  74. 2
      docs/en/030_Supported_Protocols/010_Nextcloud_News.md
  75. 4
      docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md
  76. 4
      docs/en/040_Compatible_Clients.md
  77. 14
      lib/AbstractException.php
  78. 33
      lib/Arsse.php
  79. 252
      lib/CLI.php
  80. 5
      lib/Conf.php
  81. 1
      lib/Conf/Exception.php
  82. 28
      lib/Context/AbstractContext.php
  83. 36
      lib/Context/BooleanMembers.php
  84. 41
      lib/Context/Context.php
  85. 251
      lib/Context/ExclusionContext.php
  86. 262
      lib/Context/ExclusionMembers.php
  87. 21
      lib/Context/RootContext.php
  88. 51
      lib/Context/UnionContext.php
  89. 621
      lib/Database.php
  90. 1
      lib/Db/AbstractDriver.php
  91. 1
      lib/Db/AbstractResult.php
  92. 1
      lib/Db/AbstractStatement.php
  93. 1
      lib/Db/Driver.php
  94. 1
      lib/Db/Exception.php
  95. 1
      lib/Db/ExceptionInput.php
  96. 1
      lib/Db/ExceptionRetry.php
  97. 1
      lib/Db/ExceptionTimeout.php
  98. 9
      lib/Db/MySQL/Driver.php
  99. 5
      lib/Db/MySQL/ExceptionBuilder.php
  100. 4
      lib/Db/MySQL/PDODriver.php

20
.gitignore

@ -1,17 +1,26 @@
# Temporary files and dependencies
# Temporary files
/vendor/
/vendor-bin/*/vendor
/node_modules
/release/
/documentation/
/manual/
/tests/coverage/
/dist/arch/arsse/
/dist/arch/src/
/dist/arch/pkg/
/arsse.db*
/config.php
/.php_cs.cache
/yarn-error.log
/.php-cs-fixer.cache
/tests/.phpunit.result.cache
# Dependencies
/vendor/
/vendor-bin/*/vendor
/node_modules
/yarn.lock
/yarn-error.log
# Windows files
@ -36,6 +45,7 @@ $RECYCLE.BIN/
*.zip
*.7z
*.tar.gz
*.tar.xz
*.tgz
*.deb
*.rpm

33
.php_cs.dist → .php-cs-fixer.dist.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse;
const BASE = __DIR__.DIRECTORY_SEPARATOR;
@ -13,7 +14,10 @@ $paths = [
BASE."arsse.php",
BASE."RoboFile.php",
BASE."lib",
BASE."tests",
BASE."tests/cases",
BASE."tests/lib",
BASE."tests/bootstrap.php",
BASE."tests/server.php",
];
$rules = [
// house rules where PSR series is silent
@ -35,6 +39,7 @@ $rules = [
'no_blank_lines_after_phpdoc' => true,
'no_empty_comment' => true,
'no_empty_phpdoc' => true,
'no_empty_statement' => true,
'no_extra_blank_lines' => true, // this could probably use more configuration
'no_mixed_echo_print' => ['use' => "echo"],
'no_short_bool_cast' => true,
@ -48,28 +53,16 @@ $rules = [
'pow_to_exponentiation' => true,
'set_type_to_cast' => true,
'standardize_not_equals' => true,
'trailing_comma_in_multiline_array' => true,
'trailing_comma_in_multiline' => ['elements' => ["arrays"]],
'unary_operator_spaces' => true,
'yoda_style' => false,
// PSR standard to apply
'@PSR2' => true,
// PSR-12 rules; php-cs-fixer does not yet support PSR-12 natively
'compact_nullable_typehint' => true,
'declare_equal_normalize' => ['space' => "none"],
'function_typehint_space' => true,
'lowercase_cast' => true,
'lowercase_static_reference' => true,
'no_alternative_syntax' => true,
'no_empty_statement' => true,
'no_leading_import_slash' => true,
'no_leading_namespace_whitespace' => true,
'no_whitespace_in_blank_line' => true,
'return_type_declaration' => ['space_before' => "none"],
'single_trait_insert_per_statement' => true,
'short_scalar_cast' => true,
'visibility_required' => ['elements' => ["const", "property", "method"]],
'@PSR12' => true,
// house exceptions to PSR rules
'braces' => ['position_after_functions_and_oop_constructs' => "same"],
'curly_braces_position' => [
'functions_opening_brace' => "same_line",
'classes_opening_brace' => "same_line",
],
'function_declaration' => ['closure_function_spacing' => "none"],
'new_with_braces' => false, // no option to specify absence of braces
];
@ -82,4 +75,4 @@ foreach ($paths as $path) {
$finder = $finder->in($path);
}
}
return \PhpCsFixer\Config::create()->setRiskyAllowed(true)->setRules($rules)->setFinder($finder);
return (new \PhpCsFixer\Config)->setRiskyAllowed(true)->setRules($rules)->setFinder($finder);

76
CHANGELOG

@ -1,3 +1,72 @@
Version 0.10.5 (2024-01-10)
===========================
Changes:
- Require PHP 7.3
- Adapt the Arch package to make using alternative PHP interpreters easier
(see manual for details)
- Multiple editorial and stylistic changes to the UNIX manual page
Version 0.10.4 (2023-01-24)
===========================
Changes:
- Support PHP 8.2
Version 0.10.3 (2022-09-14)
===========================
Bug fixes:
- Return all removed articles when multiple statuses are requested in Miniflux
- Allow multiple date ranges in search strings in Tiny Tiny RSS
- Honour user time zone when interpreting search strings in Tiny Tiny RSS
- Perform MySQL table maintenance more reliably
- Address CVE-2022-31090, CVE-2022-31091, CVE-2022-29248, and CVE-2022-31109
Version 0.10.2 (2022-04-04)
===========================
Changes:
- Update Guzzle PSR-7 due to CVE-2022-24775
Version 0.10.1 (2022-01-17)
===========================
Changes:
- Support PHP 8.1
Version 0.10.0 (2021-07-11)
===========================
New features:
- Complete Unix manual page
- Support for running service as a forking daemon
- Respond to TERM and HUP signals when possible
Changes:
- Packages for Debian and related are now available (see manual for details)
Version 0.9.2 (2021-05-25)
==========================
Bug fixes:
- Do not fail adding users to an empty database (regression since 0.9.0)
- Cleanly ignore unknown configuration properties
- Set access mode to rw-r---- when creating SQLite databases
Changes:
- Packages for Arch Linux are now available (see manual for details)
- Numerous improvements to the manual
Version 0.9.1 (2021-03-18)
==========================
Bug fixes:
- Respond to PUT requests with 201 rather than 200 in Miniflux
Changes:
- Correct Web server configuration in manual
Version 0.9.0 (2021-03-06)
==========================
@ -12,7 +81,7 @@ Bug fixes:
- Further relax Fever HTTP correctness, to fix more clients
- Use icons specified in Atom feeds when available
- Do not return null as subscription unread count
- Explicitly forbid U+003A COLON and control characters in usernames, for
- Explicitly forbid U+003A COLON and control characters in usernames, for
compatibility with RFC 7617
- Never return 401 in response to an OPTIONS request
- Accept "t" and "f" as booleans in Tiny Tiny RSS
@ -109,7 +178,7 @@ Bug fixes:
Version 0.6.1 (2019-01-23)
==========================
Bug Fixes:
Bug fixes:
- Unify SQL timeout settings
- Correctly escape shell command in subprocess service driver
- Correctly allow null time intervals in configuration when appropriate
@ -229,4 +298,5 @@ Bug fixes:
Version 0.1.0 (2017-08-29)
==========================
Initial release
New features:
- Initial release

46
README.md

@ -8,7 +8,7 @@ Information on how to install and use the software can be found in [the manual](
The main repository for The Arsse can be found at [code.mensbeam.com](https://code.mensbeam.com/MensBeam/arsse/), with a mirror also available [at GitHub](https://github.com/mensbeam/arsse/). The GitHub mirror does not accept bug reports, but the two should otherwise be equivalent.
[Composer](https://getcomposer.org/) is required to manage PHP dependencies. After cloning the repository or downloading a source code tarball, running `composer install` will download all the required dependencies, and will advise if any PHP extensions need to be installed. If not installing as a programming environment, running `composer install --no-dev` is recommended.
[Composer](https://getcomposer.org/) is required to manage PHP dependencies. After cloning the repository or downloading a source code tarball, running `composer install` will download all the required dependencies, and will advise if any PHP extensions need to be installed. If not installing as a programming environment, running `composer install -o --no-dev --no-scripts` is recommended.
# Repository structure
@ -34,13 +34,15 @@ Also necessary to the functioning of the application is the `/vendor/` directory
The `/locale/` and `/sql/` directories contain human-language files and database schemata, both of which are occasionally used by the application in the course of execution. The `/www/` directory serves as a document root for a few static files to be made available to users by a Web server.
The `/dist/` directory, on the other hand, contains samples of configuration for Web servers and init systems. These are not used by The Arsse itself, but are merely distributed with it for reference.
The `/dist/` directory, on the other hand, contains general and system-specific build files, manual pages, and samples of configuration for Web servers and other system integration. These are not used by The Arsse itself, but are used during the process of preparing new releases for supported operating systems.
## Documentation
The source text for The Arsse's manual can be found in `/docs/`, with pages written in [Markdown](https://spec.commonmark.org/current/) and converted to HTML [with Daux](#building-the-manual). If a static manual is generated its files will appear under `/manual/`.
In addition to the manual the files `/README.md` (this file), `/CHANGELOG`, `/UPGRADING`, `/LICENSE`, and `/AUTHORS` also document various things about the software, rather than the software itself.
The Arsse also has a UNIX manual page written in [mdoc](https://man.archlinux.org/man/extra/mandoc/mandoc_mdoc.7.en) format, which can be found under `/dist/man/`.
In addition to the manuals the files `/README.md` (this file), `/CHANGELOG`, `/UPGRADING`, `/LICENSE`, and `/AUTHORS` also document various things about the software, rather than the software itself.
## Tests
@ -50,7 +52,7 @@ The `/tests/` directory contains everything related to automated testing. It is
|--------------------|------------------------------------------------------------------------------------|
| `cases/` | The test cases themselves, organized in roughly the same structure as the code |
| `coverage/` | (optional) Generated code coverage reports |
| `docroot/` | Sample documents used in some tests, to be returned by the PHP's basic HTTP server |
| `docroot/` | Sample documents used in some tests, to be returned by PHP's basic HTTP server |
| `lib/` | Supporting classes which do not contain test cases |
| `bootstrap.php` | Bootstrap script, equivalent to `/arsse.php`, but for tests |
| `phpunit.dist.xml` | PHPUnit configuration file |
@ -62,19 +64,19 @@ PHPUnit's configuration can be customized by copying its configuration file to `
The `/vendor-bin/` directory houses the files needed for the tools used in The Arsse's programming environment. These are managed by the Composer ["bin" plugin](https://github.com/bamarni/composer-bin-plugin) and are not used by The Arsse itself. The following files are also related to various programming tools:
| Path | Description |
|-------------------|----------------------------------------------------------|
| `/.gitattributes` | Git settings for handling files |
| `/.gitignore` | Git file exclusion patterns |
| `/.php_cs.dist` | Configuration for [php-cs-fixer](https://cs.symfony.com) |
| `/.php_cs.cache` | Cache for php-cs-fixer |
| `/composer.json` | Configuration for Composer |
| `/composer.lock` | Version synchronization data for Composer |
| `/RoboFile.php` | Task definitions for [Robo](https://robo.li/) |
| `/robo` | Simple wrapper for executing Robo on POSIX systems |
| `/robo.bat` | Simple wrapper for executing Robo on Windows |
| Path | Description |
|---------------------------|----------------------------------------------------------|
| `/.gitattributes` | Git settings for handling files |
| `/.gitignore` | Git file exclusion patterns |
| `/.php-cs-fixer.dist.php` | Configuration for [php-cs-fixer](https://cs.symfony.com) |
| `/.php-cs-fixer.cache` | Cache for php-cs-fixer |
| `/composer.json` | Configuration for Composer |
| `/composer.lock` | Version synchronization data for Composer |
| `/RoboFile.php` | Task definitions for [Robo](https://robo.li/) |
| `/robo` | Simple wrapper for executing Robo on POSIX systems |
| `/robo.bat` | Simple wrapper for executing Robo on Windows |
In addition the files `/package.json`, `/yarn.lock`, and `/postcss.config.js` as well as the `/node_modules/` directory are used by [Yarn](https://yarnpkg.com/) and [PostCSS](https://postcss.org/) when modifying the stylesheet for The Arsse's manual.
In addition the files `/package.json` and `/postcss.config.js` as well as the `/node_modules/` directory are used by [Yarn](https://yarnpkg.com/) and [PostCSS](https://postcss.org/) when modifying the stylesheet for The Arsse's manual.
# Common tasks
@ -88,11 +90,11 @@ There is also a `test:quick` Robo task which excludes slower tests, and a `test:
### Test coverage
Computing the coverage of tests can be done by running `./robo coverage`, after which an HTML-format coverage report will be written to `/tests/coverage/`. Either [PCOV](https://github.com/krakjoe/pcov), [Xdebug](https://xdebug.org), or [phpdbg](https://php.net/manual/en/book.phpdbg.php) is required for this. PCOV is generally recommended as it is faster than Xdebug; phpdbg is faster still, but less accurate. If using either PCOV or Xdebug, the extension need not be enabled globally; PHPUnit will enable it when needed.
Computing the coverage of tests can be done by running `./robo coverage`, after which an HTML-format coverage report will be written to `/tests/coverage/`. Either [PCOV](https://github.com/krakjoe/pcov) or [Xdebug](https://xdebug.org) is required for this. PCOV is generally recommended as it is faster than Xdebug. Neither extension need be enabled globally; Robo will enable it when needed.
## Enforcing coding style
The [php-cs-fixer](https://cs.symfony.com) tool, executed via `./robo clean`, can be used to rewrite code to adhere to The Arsse's coding style. The style largely follows [PSR-2](https://www.php-fig.org/psr/psr-2/) with some exceptions:
The [php-cs-fixer](https://cs.symfony.com) tool, executed via `./robo clean`, can be used to rewrite code to adhere to The Arsse's coding style. The style largely follows [PSR-12](https://www.php-fig.org/psr/psr-12/) with some exceptions:
- Classes, methods, and functions should have their opening brace on the same line as the signature
- Anonymous functions should have no space before the parameter list
@ -107,13 +109,11 @@ The manual employs a custom theme derived from the standard Daux theme. If the s
## Packaging a release
Producing a release package is done by running `./robo package`. This performs the following operations:
Producing release packages is done by running `./robo package`. This performs the following operations:
- Duplicates a working tree with the commit (usually a release tag) to package
- Generates the manual
- Duplicates a [Git](https://git-scm.com/) working tree with the commit (usually a release tag) to package
- Generates the HTML manual
- Installs runtime Composer dependencies with an optimized autoloader
- Deletes numerous unneeded files
- Exports the default configuration of The Arsse to a file
- Compresses the remaining files into a tarball
Due to the first step, [Git](https://git-scm.com/) is required to package a release.

602
RoboFile.php

@ -6,6 +6,7 @@ const BASE = __DIR__.\DIRECTORY_SEPARATOR;
const BASE_TEST = BASE."tests".\DIRECTORY_SEPARATOR;
define("IS_WIN", defined("PHP_WINDOWS_VERSION_MAJOR"));
define("IS_MAC", php_uname("s") === "Darwin");
define("IS_LINUX", !IS_WIN && !IS_MAC);
error_reporting(0);
function norm(string $path): string {
@ -54,8 +55,8 @@ class RoboFile extends \Robo\Tasks {
* tests/coverage/. Additional reports may be produced by passing
* arguments to this task as one would to PHPUnit.
*
* Robo first tries to use pcov and will fall back first to xdebug then
* phpdbg. Neither pcov nor xdebug need to be enabled to be used; they
* Robo first tries to use pcov and will fall back to xdebug.
* Neither pcov nor xdebug need to be enabled to be used; they
* only need to be present in the extension load path to be used.
*/
public function coverage(array $args): Result {
@ -79,9 +80,9 @@ class RoboFile extends \Robo\Tasks {
return $this->runTests($exec, "typical", array_merge(["--coverage-html", BASE_TEST."coverage"], $args));
}
/** Runs the coding standards fixer */
/** Runs the coding-style fixer */
public function clean($opts = ['demo|d' => false]): Result {
$t = $this->taskExec(norm(BASE."vendor/bin/php-cs-fixer"));
$t = $this->taskExec(norm(BASE."vendor-bin/csfixer/vendor/bin/php-cs-fixer"));
$t->arg("fix");
if ($opts['demo']) {
$t->args("--dry-run", "--diff")->option("--diff-format", "udiff");
@ -89,9 +90,10 @@ class RoboFile extends \Robo\Tasks {
return $t->run();
}
/** Finds the first suitable means of computing code coverage, either pcov or xdebug. */
protected function findCoverageEngine(): string {
$dir = rtrim(ini_get("extension_dir"), "/").\DIRECTORY_SEPARATOR;
$ext = IS_WIN ? "dll" : (IS_MAC ? "dylib" : "so");
$ext = IS_WIN ? "dll" : "so";
$php = escapeshellarg(\PHP_BINARY);
$code = escapeshellarg(BASE."lib");
if (extension_loaded("pcov")) {
@ -103,25 +105,27 @@ class RoboFile extends \Robo\Tasks {
} elseif (file_exists($dir."xdebug.$ext")) {
return "$php -d zend_extension=xdebug.$ext -d xdebug.mode=coverage";
} else {
if (IS_WIN) {
$dbg = dirname(\PHP_BINARY)."\\phpdbg.exe";
$dbg = file_exists($dbg) ? $dbg : "";
} else {
$dbg = trim(`which phpdbg 2>/dev/null`);
}
if ($dbg) {
return escapeshellarg($dbg)." -qrr";
} else {
return $php;
}
return $php;
}
}
/** Returns the necessary shell arguments to print error output or all output to the bitbucket
*
* @param bool $all Whether all output (true) or only error output (false) should be suppressed
*/
protected function blackhole(bool $all = false): string {
$hole = IS_WIN ? "nul" : "/dev/null";
return $all ? ">$hole 2>&1" : "2>$hole";
}
/** Executes PHPUnit, used by the test and coverage tasks.
*
* This also executes the built-in PHP Web server, which is required to fetch some newsfeeds during tests
*
* @param string $executor The path to the PHP binary to execute with any required extra arguments. Normally this is either "php" or the result of findCoverageEngine()
* @param string $set The set of tests to run, either "typical" (excludes redundant tests), "quick" (excludes redundant and slow tests), "coverage" (excludes tests not needed for coverage), or "full" (all tests)
* @param array $args Extra arguments passed by Robo from the command line
*/
protected function runTests(string $executor, string $set, array $args): Result {
switch ($set) {
case "typical":
@ -145,71 +149,270 @@ class RoboFile extends \Robo\Tasks {
return $this->taskExec($executor)->option("-d", "zend.assertions=1")->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run();
}
/** Returns a Git version string for a given Git tree-ish ID
*
* Returns an array containing the tree-ish string and the version string.
*
* @param string|null $commit The tree-ish ID. If not supplied the user will be prompted
*/
protected function commitVersion(?string $commit): array {
$target = $commit ?? $this->askDefault("Reference commit:", "HEAD");
$base = escapeshellarg(BASE);
$blackhole = $this->blackhole();
// get useable version strings from Git
$version = trim(`git -C $base describe --tags $target $blackhole`);
if (!$version) {
throw new \Exception("Commit reference invalid");
}
return [$target, $version];
}
/** Checks whether all the supplied terminal commands are available in path */
protected function toolExists(string ...$binary): bool {
$blackhole = $this->blackhole(IS_WIN);
foreach ($binary as $bin) {
if (!exec(escapeshellarg($bin)." --help $blackhole", $junk, $status) || $status) {
return false;
}
}
return true;
}
/** Packages a given commit of the software into a release tarball
*
* The version to package may be any Git tree-ish identifier: a tag, a branch,
* The commit to package may be any Git tree-ish identifier: a tag, a branch,
* or any commit hash. If none is provided on the command line, Robo will prompt
* for a commit to package; the default is "head".
* for a commit to package; the default is "HEAD".
*
* Note that while it is possible to re-package old versions, the resultant tarball
* may not be equivalent due to subsequent changes in the exclude list, or because
* of new tooling.
*/
public function package(string $version = null): Result {
public function packageGeneric(string $commit = null): Result {
if (!$this->toolExists("git")) {
throw new \Exception("Git is required in PATH to produce generic release tarballs");
}
// establish which commit to package
$version = $version ?? $this->askDefault("Commit to package:", "HEAD");
$archive = BASE."arsse-$version.tar.gz";
[$commit, $version] = $this->commitVersion($commit);
preg_match('/^([^-]+)(?:-(\d+)-(\w+))?$/', $version, $m);
$archVersion = $m[1].($m[2] ? ".r$m[2].$m[3]" : "");
$baseVersion = $m[1];
$release = $m[2];
// name the generic release tarball
$tarball = BASE."release/$version/arsse-$version.tar.gz";
// start a collection
$t = $this->collectionBuilder();
// create a temporary directory
$dir = $t->tmpDir().\DIRECTORY_SEPARATOR;
// create a Git worktree for the selected commit in the temp location
$t->taskExec("git worktree add ".escapeshellarg($dir)." ".escapeshellarg($version));
// perform Composer installation in the temp location with dev dependencies
$t->taskComposerInstall()->dir($dir);
// generate the manual
$t->taskExec(escapeshellarg($dir."robo")." manual")->dir($dir);
// perform Composer installation in the temp location for final output
$t->taskComposerInstall()->dir($dir)->noDev()->optimizeAutoloader()->arg("--no-scripts");
// delete unwanted files
$t->taskFilesystemStack()->remove([
$dir.".git",
$dir.".gitignore",
$dir.".gitattributes",
$dir."composer.json",
$dir."composer.lock",
$dir.".php_cs.dist",
$dir."phpdoc.dist.xml",
$dir."build.xml",
$dir."RoboFile.php",
$dir."CONTRIBUTING.md",
$dir."docs",
$dir."tests",
$dir."vendor-bin",
$dir."vendor/bin",
$dir."robo",
$dir."robo.bat",
$dir."package.json",
$dir."yarn.lock",
$dir."postcss.config.js",
]);
// generate a sample configuration file
$t->taskExec(escapeshellarg(\PHP_BINARY)." arsse.php conf save-defaults config.defaults.php")->dir($dir);
// package it all up
$t->taskPack($archive)->addDir("arsse", $dir);
// execute the collection
$out = $t->run();
// clean the Git worktree list
$this->_exec("git worktree prune");
return $out;
$result = $this->taskExec("git worktree add ".escapeshellarg($dir)." ".escapeshellarg($version))->dir(BASE)->run();
if ($result->getExitCode() > 0) {
return $result;
}
try {
// Perform Arch-specific tasks
if (file_exists($dir."dist/arch")) {
// patch the Arch PKGBUILD file with the correct version string
$t->addTask($this->taskReplaceInFile($dir."dist/arch/PKGBUILD")->regex('/^pkgver=.*$/m')->to("pkgver=$archVersion"));
// patch the Arch PKGBUILD file with the correct source file
$t->addTask($this->taskReplaceInFile($dir."dist/arch/PKGBUILD")->regex('/^source=\("arsse-[^"]+"\)$/m')->to('source=("'.basename($tarball).'")'));
}
// perform Debian-specific tasks
if (file_exists($dir."dist/debian")) {
// generate the Debian changelog; this also validates our original changelog
$changelog = $this->changelogParse(file_get_contents($dir."CHANGELOG"), $version);
$debianChangelog = $this->changelogDebian($changelog, $version);
// save the Debian-format changelog
$t->addTask($this->taskWriteToFile($dir."dist/debian/changelog")->text($debianChangelog));
}
// perform RPM-specific tasks
if (file_exists($dir."dist/rpm")) {
// patch the spec file with the correct version and release
$t->addTask($this->taskReplaceInFile($dir."dist/rpm/arsse.spec")->regex('/^Version: .*$/m')->to("Version: $baseVersion"));
$t->addTask($this->taskReplaceInFile($dir."dist/rpm/arsse.spec")->regex('/^Release: .*$/m')->to("Release: $release"));
// patch the spec file with the correct tarball name
$t->addTask($this->taskReplaceInFile($dir."dist/rpm/arsse.spec")->regex('/^Source0: .*$/m')->to("Source0: arsse-$version.tar.gz"));
// append the RPM changelog to the spec file
$t->addTask($this->taskWriteToFile($dir."dist/rpm/arsse.spec")->append(true)->text("\n\n%changelog\n".$this->changelogRPM($changelog, $version)));
}
// save commit description to VERSION file for reference
$t->addTask($this->taskWriteToFile($dir."VERSION")->text($version));
if (file_exists($dir."docs") || file_exists($dir."manpages/en.md")) {
// perform Composer installation in the temp location with dev dependencies to include Robo and Daux
$t->addTask($this->taskExec("composer install")->arg("-q")->dir($dir));
if (file_exists($dir."docs")) {
// generate the HTML manual
$t->addTask($this->taskExec("./robo manual -q")->dir($dir));
}
if (file_exists($dir."manpages/en.md")) {
// generate manpages (NOTE: obsolete process)
$t->addTask($this->taskExec("./robo manpage")->dir($dir));
}
}
// perform Composer installation in the temp location for final output
$t->addTask($this->taskExec("composer install")->dir($dir)->arg("--no-dev")->arg("-o")->arg("--no-scripts")->arg("-q"));
// delete unwanted files
$t->addTask($this->taskFilesystemStack()->remove([
$dir.".git",
$dir.".gitignore",
$dir.".gitattributes",
$dir."dist/debian/.gitignore",
$dir."composer.json",
$dir."composer.lock",
$dir.".php_cs.dist",
$dir."phpdoc.dist.xml",
$dir."build.xml",
$dir."RoboFile.php",
$dir."CONTRIBUTING.md",
$dir."docs",
$dir."manpages",
$dir."tests",
$dir."vendor-bin",
$dir."vendor/bin",
$dir."robo",
$dir."robo.bat",
$dir."package.json",
$dir."yarn.lock",
$dir."postcss.config.js",
]));
$t->addCode(function() use ($dir) {
// Remove files which lintian complains about; they're otherwise harmless
$files = [];
foreach (new \CallbackFilterIterator(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir."vendor", \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS)), function($v, $k, $i) {
return preg_match('/\/\.git(?:ignore|attributes|modules)$/D', $v);
}) as $f) {
$files[] = $f;
}
return $this->taskFilesystemStack()->remove($files)->run();
});
// generate a sample configuration file
$t->addTask($this->taskExec(escapeshellarg(\PHP_BINARY)." arsse.php conf save-defaults config.defaults.php")->dir($dir));
// remove any existing archive
$t->addTask($this->taskFilesystemStack()->remove($tarball));
// package it all up
$t->addTask($this->taskFilesystemStack()->mkdir(dirname($tarball)));
$t->addTask($this->taskPack($tarball)->addDir("arsse", $dir));
// execute the collection
$result = $t->run();
} finally {
// remove the Git worktree
$this->taskFilesystemStack()->remove($dir)->run();
$this->taskExec("git worktree prune")->dir(BASE)->run();
}
return $result;
}
/** Packages a release tarball into a Debian source package
*
* The commit to package may be any Git tree-ish identifier: a tag, a branch,
* or any commit hash. If none is provided on the command line, Robo will prompt
* for a commit to package; the default is "HEAD".
*/
public function packageDebsrc(string $commit = null): Result {
// establish which commit to package
[$commit, $version] = $this->commitVersion($commit);
$tarball = BASE."release/$version/arsse-$version.tar.gz";
// determine the base version (i.e. x.y.z) and the Debian version (i.e. x.y.z-a)
preg_match('/^(\d+(?:\.\d+)+)(?:-(\d+))?/', $version, $m);
$baseVersion = $m[1];
$debVersion = $m[1]."-".($version === $baseVersion ? "1" : $m[2]);
// start a task collection and create a temporary directory
$t = $this->collectionBuilder();
$dir = $t->tmpDir().\DIRECTORY_SEPARATOR;
// build the generic release tarball if it doesn't exist
if (!file_exists($tarball)) {
$t->addTask($this->taskExec(BASE."robo package:generic $commit"));
}
$base = $dir."arsse-$version".\DIRECTORY_SEPARATOR;
// start by extracting the tarball
$t->addCode(function() use ($tarball, $dir, $base) {
// Robo's extract task is broken, so we do it manually
(new \Archive_Tar($tarball))->extract($dir, false);
return $this->taskFilesystemStack()->rename($dir."arsse", $base)->run();
});
// re-pack the tarball using a specific name special to Debian
$t->addTask($this->taskPack($dir."arsse_$baseVersion.orig.tar.gz")->addDir("arsse-$baseVersion", $base));
// pack the debian tarball
$t->addTask($this->taskPack($dir."arsse_$debVersion.debian.tar.gz")->addDir("debian", $base."dist/debian"));
// generate the DSC file
$t->addCode(function() use ($t, $debVersion, $baseVersion, $dir, $base) {
try {
$dsc = $this->generateDebianSourceControl($base."dist/debian/", $debVersion, [$dir."arsse_$baseVersion.orig.tar.gz", $dir."arsse_$debVersion.debian.tar.gz"]);
} catch (\Exception $e) {
return new Result($t, 1, $e->getMessage());
}
// write the DSC file
return $this->taskWriteToFile($dir."arsse_$debVersion.dsc")->text($dsc)->run();
});
// delete any existing files
$t->AddTask($this->taskFilesystemStack()->remove([BASE."release/$version/arsse_$baseVersion.orig.tar.gz", BASE."release/$version/arsse_$debVersion.debian.tar.gz", BASE."release/$version/arsse_$debVersion.dsc"]));
// copy the new files over
$t->addTask($this->taskFilesystemStack()->copy($dir."arsse_$baseVersion.orig.tar.gz", BASE."release/$version/arsse_$baseVersion.orig.tar.gz")->copy($dir."arsse_$debVersion.debian.tar.gz", BASE."release/$version/arsse_$debVersion.debian.tar.gz")->copy($dir."arsse_$debVersion.dsc", BASE."release/$version/arsse_$debVersion.dsc"));
return $t->run();
}
/** Generates static manual pages in the "manual" directory
/** Packages a given commit of the software and produces all relevant release files
*
* The commit to package may be any Git tree-ish identifier: a tag, a branch,
* or any commit hash. If none is provided on the command line, Robo will prompt
* for a commit to package; the default is "HEAD".
*
* In addition to the release tarball, a Debian source package, Arch PKGBUILD,
* and RPM spec file are output as well. These are suitable for use with Open
* Build Service instances and with slight modification the Arch User Repository.
* Use for Launchpad PPAs has not been tested.
*/
public function package(string $commit = null): Result {
if (!$this->toolExists("git")) {
throw new \Exception("Git is required in PATH to produce packages");
}
[$commit, $version] = $this->commitVersion($commit);
$tarball = BASE."release/$version/arsse-$version.tar.gz";
// build the generic release tarball
$result = $this->taskExec(BASE."robo package:generic $commit")->run();
if (!$result->wasSuccessful()) {
return $result;
}
// if the generic tarball could be built, try to produce Arch, Debian, and RPM files; these might legitimately not exist in old releases
// start by getting the list of files from the tarball
$archive = new \Archive_Tar($tarball);
$filelist = array_flip(array_column($archive->listContent(), "filename"));
// start a collection
$t = $this->collectionBuilder();
// Produce an Arch PKGBUILD if appropriate
if (isset($filelist['arsse/dist/arch/PKGBUILD'])) {
$t->addCode(function() use ($tarball, $archive) {
$dir = dirname($tarball).\DIRECTORY_SEPARATOR;
$archive->extractList("arsse/dist/arch/PKGBUILD", $dir, "arsse/dist/arch/", false);
// update the tarball's checksum
$sums = [
'md5' => hash_file("md5", $tarball),
];
return $this->taskReplaceInFile($dir."PKGBUILD")->regex('/^md5sums=\("SKIP"\)$/m')->to('md5sums=("'.$sums['md5'].'")')->run();
});
}
// Produce a Debian source package if appropriate
if (isset($filelist['arsse/dist/debian/control']) && isset($filelist['arsse/dist/debian/source/format'])) {
$t->addTask($this->taskExec(BASE."robo package:debsrc $commit"));
}
// Produce an RPM spec file if appropriate
if (isset($filelist['arsse/dist/rpm/arsse.spec'])) {
$t->addCode(function() use ($tarball, $archive) {
$dir = dirname($tarball).\DIRECTORY_SEPARATOR;
$archive->extractList("arsse/dist/rpm/arsse.spec", $dir, "arsse/dist/rpm/", false);
// perform a do-nothing filesystem operation since we need a Robo task result
return $this->taskFilesystemStack()->chmod($dir."arsse.spec", 0644)->run();
});
}
return $t->run();
}
/** Generates static HTML manual pages in the "manual" directory
*
* The resultant files are suitable for offline viewing and inclusion into release builds
*/
public function manual(array $args): Result {
$execpath = escapeshellarg(norm(BASE."vendor/bin/daux"));
$execpath = escapeshellarg(norm(BASE."vendor-bin/daux/vendor/bin/daux"));
$t = $this->collectionBuilder();
$t->taskExec($execpath)->arg("generate")->option("-d", BASE."manual")->args($args);
$t->taskDeleteDir(BASE."manual/daux_libraries");
@ -220,7 +423,7 @@ class RoboFile extends \Robo\Tasks {
/** Serves a live view of the manual using the built-in Web server */
public function manualLive(array $args): Result {
$execpath = escapeshellarg(norm(BASE."vendor/bin/daux"));
$execpath = escapeshellarg(norm(BASE."vendor-bin/daux/vendor/bin/daux"));
return $this->taskExec($execpath)->arg("serve")->args($args)->run();
}
@ -230,6 +433,9 @@ class RoboFile extends \Robo\Tasks {
* Daux's theme changes
*/
public function manualTheme(array $args): Result {
if (!$this->toolExists("yarn")) {
throw new \Exception("Yarn is required in PATH to update the Daux theme");
}
$postcss = escapeshellarg(norm(BASE."node_modules/.bin/postcss"));
$themesrc = norm(BASE."docs/theme/src/").\DIRECTORY_SEPARATOR;
$themeout = norm(BASE."docs/theme/arsse/").\DIRECTORY_SEPARATOR;
@ -247,4 +453,276 @@ class RoboFile extends \Robo\Tasks {
// execute the collection
return $t->run();
}
/** Parses the contents of the CHANGELOG file into an array structure
*
* This is done line-by-line and tends to be quite strict.
* The parsed output can be used to generate changelogs in other formats,
* such as a Debian changelog or RPM changelog.
*
* @param string $text The text of the CHANGELOG file
* @param string $targetVersion The x.y.z version number of the latest release. This is used to check that version numbers and dates have been updated when preparing a release
*/
protected function changelogParse(string $text, string $targetVersion): array {
$lines = preg_split('/\r?\n/', $text);
$version = "";
$section = "";
$out = [];
$entry = [];
$expected = ["version"];
for ($a = 0; $a < sizeof($lines);) {
$l = rtrim($lines[$a++]);
if (in_array("version", $expected) && preg_match('/^Version ([\d\?]+(?:\.[\d\?]+)*) \(([\d\?]{4}-[\d\?]{2}-[\d\?]{2})\)\s*$/D', $l, $m)) {
$version = $m[1];
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $m[2])) {
// uncertain dates are allowed only for the top version, and only if it does not match the target version (otherwise we have forgotten to set the correct date before tagging)
if (!$out && $targetVersion !== $version) {
// use today's date; local time is fine
$date = date("Y-m-d");
} else {
throw new \Exception("CHANGELOG: Date at line $a is incomplete");
}
} else {
$date = $m[2];
}
if ($entry) {
$out[] = $entry;
}
$entry = ['version' => $version, 'date' => $date, 'features' => [], 'fixes' => [], 'changes' => []];
$expected = ["separator"];
} elseif (in_array("separator", $expected) && preg_match('/^=+/', $l)) {
$length = strlen($lines[$a - 2]);
if (strlen($l) !== $length) {
throw new \Exception("CHANGELOG: Separator at line $a is of incorrect length");
}
$expected = ["blank line"];
$section = "";
} elseif (in_array("blank line", $expected) && $l === "") {
$expected = [
'' => ["features section", "fixes section", "changes section"],
'features' => ["fixes section", "changes section", "version"],
'fixes' => ["changes section", "version"],
'changes' => ["version"],
][$section];
$expected[] = "end-of-file";
} elseif (in_array("features section", $expected) && $l === "New features:") {
$section = "features";
$expected = ["item"];
} elseif (in_array("fixes section", $expected) && $l === "Bug fixes:") {
$section = "fixes";
$expected = ["item"];
} elseif (in_array("changes section", $expected) && $l === "Changes:") {
$section = "changes";
$expected = ["item"];
} elseif (in_array("item", $expected) && preg_match('/^- (\w.*)$/D', $l, $m)) {
$entry[$section][] = $m[1];
$expected = ["item", "continuation", "blank line"];
} elseif (in_array("continuation", $expected) && preg_match('/^ (\S.*)$/D', $l, $m)) {
$last = sizeof($entry[$section]) - 1;
$entry[$section][$last] .= "\n".$m[1];
} else {
if (sizeof($expected) > 1) {
throw new \Exception("CHANGELOG: Expected one of [".implode(", ", $expected)."] at line $a");
} else {
throw new \Exception("CHANGELOG: Expected ".$expected[0]." at line $a");
}
}
}
if (!in_array("end-of-file", $expected)) {
if (sizeof($expected) > 1) {
throw new \Exception("CHANGELOG: Expected one of [".implode(", ", $expected)."] at end of file");
} else {
throw new \Exception("CHANGELOG: Expected ".$expected[0]." at end of file");
}
}
$out[] = $entry;
return $out;
}
/** Produce a Debian changelog from a parsed CHANGELOG file
*
* The Debian changelog format is extremely specific with certain tokens
* having special meaning and leading whitespace also being significant.
* Modifying this function should be done with extreme care.
*
* @param array $log The parsed chaneglog, output by changelogParse()
* @param string $targetVersion The second output of commitVersion()
*/
protected function changelogDebian(array $log, string $targetVersion): string {
$authorName = "J. King";
$authorMail = "jking@jkingweb.ca";
$latest = $log[0]['version'];
$baseVersion = preg_replace('/^(\d+(?:\.\d+)*).*/', "$1", $targetVersion);
if ($baseVersion !== $targetVersion && version_compare($latest, $baseVersion, ">")) {
// if the changelog contains an entry for a future version, change its version number to match the target version instead of using the future version
$log[0]['version'] = $targetVersion;
$log[0]['distribution'] = "UNRELEASED";
} elseif ($baseVersion !== $targetVersion) {
// otherwise synthesize a changelog entry for the changes since the last tag
array_unshift($log, ['version' => $targetVersion, 'date' => date("Y-m-d"), 'features' => [], 'fixes' => [], 'changes' => ["Unspecified changes"], 'distribution' => "UNRELEASED"]);
}
$out = "";
foreach ($log as $entry) {
// normalize the version string
preg_match('/^(\d+(?:\.\d+)*)(?:-(\d+)-.+)?$/D', $entry['version'], $m);
$version = $m[1]."-".($m[2] ?: "1");
// output the entry
$out .= "arsse ($version) ".($entry['distribution'] ?? "unstable")."; urgency=low\n";
if ($entry['features']) {
$out .= "\n";
foreach ($entry['features'] as $item) {
$out .= " * ".trim(preg_replace("/^/m", " ", $item))."\n";
}
}
if ($entry['fixes']) {
$out .= "\n";
foreach ($entry['fixes'] as $item) {
$out .= " * ".trim(preg_replace("/^/m", " ", $item))."\n";
}
}
if ($entry['changes']) {
$out .= "\n";
foreach ($entry['changes'] as $item) {
$out .= " * ".trim(preg_replace("/^/m", " ", $item))."\n";
}
}
$out .= "\n -- $authorName <$authorMail> ".\DateTimeImmutable::createFromFormat("Y-m-d", $entry['date'], new \DateTimeZone("UTC"))->format("D, d M Y")." 00:00:00 +0000\n\n";
}
return $out;
}
/** Produces a Debian "source control" file from various bits of data
*
* As with a Debian changelog, the output is of a very exacting format,
* and this function should be modified with care.
*
* @param string $dir The path to Debian-specific files, with trailing slash
* @param string $version The Debian version string, in the format x.y.z-a
* @param array $tarballs An array of paths to the "orig" and "debian" tarball files
*/
protected function generateDebianSourceControl(string $dir, string $version, array $tarballs): string {
// read in control file
if (!$control = @file_get_contents($dir."control")) {
throw new \Exception("Unable to read Debian control file");
}
// read the format
if (!$format = @file_get_contents($dir."source/format")) {
throw new \Exception("Unable to read source format in Debian files");
}
// read the binary packages from the control file
if (preg_match_all('/^Package:\s*(\S+)/m', $control, $m)) {
$binary = [];
foreach ($m[1] as $pkg) {
$binary[] = $pkg;
}
} else {
throw new \Exception("No packages defined in Debian control file");
}
// read the package architectures from the control file
if (preg_match_all('/^Architecture:\s*(\S+)/m', $control, $m) || sizeof($m[1]) != sizeof($binary)) {
$architecture = [];
foreach ($m[1] as $pkg) {
$architecture[] = preg_replace('/\s/', "", $pkg);
}
} else {
throw new \Exception("Number of architectures defined in Debian control file does not match number of packages");
}
// read the package sections from the control file
if (preg_match_all('/^Section:\s*(\S+)/m', $control, $m) || sizeof($m[1]) != sizeof($binary)) {
$section = [];
foreach ($m[1] as $pkg) {
$section[] = $pkg;
}
} else {
throw new \Exception("Number of sections defined in Debian control file does not match number of packages");
}
// read the package priorities from the control file
if (preg_match_all('/^Priority:\s*(\S+)/m', $control, $m) || sizeof($m[1]) != sizeof($binary)) {
$priority = [];
foreach ($m[1] as $pkg) {
$priority[] = $pkg;
}
} else {
throw new \Exception("Number of priorities defined in Debian control file does not match number of packages");
}
// read simple metadata from the control file
$metadata = [];
foreach (["Source", "Maintainer", "Homepage", "Standards-Version", "Vcs-Browser", "Vcs-Git"] as $meta) {
if (preg_match('/^'.$meta.':\s*(.+)/m', $control, $m)) {
$metadata[$meta] = $m[1];
} else {
throw new \Exception("$meta is not defined in Debian control file");
}
}
// read build dependencies from control file
if (preg_match('/(?:^|\n)Build-Depends:\s*((?:[^\n]|\n(?= ))+)/s', $control, $m)) {
$buildDepends = preg_replace('/\s/', "", $m[1]);
} else {
$buildDepends = "";
}
// trim format
$format = trim($format);
// consolidate binaries and package list
$packageList = [];
for ($a = 0; $a < sizeof($binary); $a++) {
$packageList[] = "$binary[$a] deb $section[$a] $priority[$a] arch=$architecture[$a]";
}
$packageList = implode("\n ", $packageList);
// consolidate package names
$binary = implode(",", $binary);
// consolidate architectures
$architecture = implode(",", array_unique($architecture));
// calculate checksums for files
$fMeta = [];
foreach ($tarballs as $f) {
$fMeta[$f] = [
'name' => basename($f),
'size' => filesize($f),
'sha1' => hash_file("sha1", $f),
'sha256' => hash_file("sha256", $f),
'md5' => hash_file("md5", $f),
];
}
// consolidate SHA-1 checksums
$sums = [];
foreach ($fMeta as $data) {
$sums[] = $data['sha1']." ".$data['size']." ".$data['name'];
}
$sumsSha1 = implode("\n ", $sums);
// consolidate SHA-256 checksums
$sums = [];
foreach ($fMeta as $data) {
$sums[] = $data['sha256']." ".$data['size']." ".$data['name'];
}
$sumsSha256 = implode("\n ", $sums);
// consolidate MD5 checksums
$sums = [];
foreach ($fMeta as $data) {
$sums[] = $data['md5']." ".$data['size']." ".$data['name'];
}
$sumsMd5 = implode("\n ", $sums);
// return complete file
return <<< DSC_FILE
Format: $format
Source: {$metadata['Source']}
Binary: $binary
Architecture: $architecture
Version: $version
Maintainer: {$metadata['Maintainer']}
Homepage: {$metadata['Homepage']}
Standards-Version: {$metadata['Standards-Version']}
Vcs-Browser: {$metadata['Vcs-Browser']}
Vcs-Git: {$metadata['Vcs-Git']}
Build-Depends: $buildDepends
Package-List:
$packageList
Checksums-Sha1:
$sumsSha1
Checksums-Sha256:
$sumsSha256
Files:
$sumsMd5
DSC_FILE;
}
}

16
UPGRADING

@ -9,6 +9,22 @@ usually prudent:
- Check for any changes to sample systemd unit or other init files
- If installing from source, update dependencies with:
`composer install -o --no-dev`
Upgrading from 0.10.4 to 0.10.5
=============================
- PHP 7.3 is now required
- Web server configuration in the Arch Linux package has been modified to ease
the use of alternative PHP interpreters; please review the sample
configuration files for changes
Upgrading from 0.10.2 to 0.10.3
=============================
- The following Composer dependencies have been removed:
- laminas/laminas-diactoros
Upgrading from 0.8.5 to 0.9.0

6
arsse.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse;
const BASE = __DIR__.DIRECTORY_SEPARATOR;
@ -13,7 +14,7 @@ require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";
ignore_user_abort(true);
ini_set("memory_limit", "-1");
ini_set("max_execution_time", "0");
// FIXME: This is required by a dependency of Picofeed
// NOTE: While running in the wild we don't want to spew deprecation warnings if users are ahead of us in PHP versions
error_reporting(\E_ALL & ~\E_DEPRECATED);
if (\PHP_SAPI === "cli") {
@ -25,8 +26,7 @@ if (\PHP_SAPI === "cli") {
exit($exitStatus);
} else {
// load configuration
$conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf;
Arsse::load($conf);
Arsse::bootstrap();
// handle Web requests
$emitter = new \Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
$response = (new REST)->dispatch();

33
composer.json

@ -18,29 +18,38 @@
],
"require": {
"php": "^7.1 || ^8.0",
"php": ">=7.3",
"ext-intl": "*",
"ext-json": "*",
"ext-hash": "*",
"ext-filter": "*",
"ext-dom": "*",
"nicolus/picofeed": "^0.1.43",
"nicolus/picofeed": "dev-patch-3",
"hosteurope/password-generator": "1.*",
"docopt/docopt": "1.*",
"jkingweb/druuid": "3.*",
"laminas/laminas-diactoros": "2.*",
"laminas/laminas-httphandlerrunner": "1.*"
"guzzlehttp/psr7": "2.*",
"laminas/laminas-httphandlerrunner": "2.*"
},
"require-dev": {
"bamarni/composer-bin-plugin": "*"
},
"suggest": {
"ext-pcntl": "To respond to signals, particularly to reload configuration via SIGHUP"
},
"config": {
"platform": {
"php": "7.1.33"
"php": "7.3.33"
},
"allow-plugins": {
"bamarni/composer-bin-plugin": true
}
},
"scripts": {
"post-install-cmd": ["@composer bin all install"],
"post-update-cmd": ["@composer bin all update"]
"extra": {
"bamarni-bin": {
"bin-links": false,
"forward-command": true
}
},
"autoload": {
"psr-4": {
@ -52,5 +61,11 @@
"JKingWeb\\Arsse\\Test\\": "tests/lib/",
"JKingWeb\\Arsse\\TestCase\\": "tests/cases/"
}
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/JKingweb/picoFeed-1/"
}
]
}

830
composer.lock

File diff suppressed because it is too large

23
dist/apache.conf

@ -1,23 +0,0 @@
# N.B. the unix:/var/run/php/php7.2-fpm.sock path used repeatedly below will
# vary from system to system and will be probably need to be changed
<VirtualHost *:80>
ServerName localhost
# adjust according to your installation path
DocumentRoot /usr/share/arsse/www
# adjust according to your installation path
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
ProxyPreserveHost On
# Nextcloud News v1.2, Tiny Tiny RSS API, TT-RSS newsfeed icons, Miniflux API
<LocationMatch "(/index\.php/apps/news/api/?.+|/tt-rss/(api|feed-icons)|/v1/)">
ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
</LocationMatch>
# Nextcloud News API detection, Fever API, Miniflux miscellanies
<LocationMatch "(/index\.php/apps/news/api/?$|/fever|/version$|/healthcheck$)">
# these locations should not be behind HTTP authentication
ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
</LocationMatch>
</VirtualHost>

5
dist/apache/arsse-fcgi.conf

@ -0,0 +1,5 @@
ProxyPreserveHost On
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
ProxyFCGISetEnvIf "-n req('Authorization')" HTTP_AUTHORIZATION "%{req:Authorization}"
ProxyPass "unix:/var/run/php/arsse.sock|fcgi://localhost/usr/share/arsse/"

34
dist/apache/arsse-loc.conf

@ -0,0 +1,34 @@
# Nextcloud News protocol
<Location "/index.php/apps/news/api">
Include "/etc/arsse/apache/arsse-fcgi.conf"
</Location>
# Tiny Tiny RSS protocol
<Location "/tt-rss/api">
Include "/etc/arsse/apache/arsse-fcgi.conf"
</Location>
# Tiny Tiny RSS feed icons
<Location "/tt-rss/feed-icons">
Include "/etc/arsse/apache/arsse-fcgi.conf"
</Location>
# Fever protocol
<Location "/fever">
Include "/etc/arsse/apache/arsse-fcgi.conf"
</Location>
# Miniflux protocol
<Location "/v1">
Include "/etc/arsse/apache/arsse-fcgi.conf"
</Location>
# Miniflux version number
<Location "/version">
Include "/etc/arsse/apache/arsse-fcgi.conf"
</Location>
# Miniflux "health check"
<Location "/healthcheck">
Include "/etc/arsse/apache/arsse-fcgi.conf"
</Location>

6
dist/apache/arsse.conf

@ -0,0 +1,6 @@
DocumentRoot "/usr/share/arsse/www"
<Directory "/usr/share/arsse/www">
Require all granted
</Directory>
Include "/etc/arsse/apache/arsse-loc.conf"

9
dist/apache/example.conf

@ -0,0 +1,9 @@
<VirtualHost *:443>
ServerName "news.example.com"
SSLEngine On
SSLCertificateFile "/etc/letsencrypt/live/news.example.com/fullchain.pem"
SSLCertificateKeyFile "/etc/letsencrypt/live/news.example.com/privkey.pem"
Include "/etc/arsse/apache/arsse.conf"
</VirtualHost>

64
dist/arch/PKGBUILD

@ -0,0 +1,64 @@
# Maintainer: J. King <jking@jkingweb.ca>
pkgname="arsse"
pkgver=0.10.4
pkgrel=1
epoch=
pkgdesc="Multi-protocol RSS/Atom newsfeed synchronization server"
arch=("any")
url="https://thearsse.com/"
license=("MIT")
conflicts=("arsse-git")
depends=()
makedepends=()
checkdepends=()
optdepends=("nginx: HTTP server"
"apache>=2.4: HTTP server"
"percona-server: Alternate database"
"postgresql>=10: Alternate database"
"php-pgsql-interpreter>=7.3: PostgreSQL database support")
backup=("etc/webapps/arsse/config.php"
"etc/webapps/arsse/systemd-environment"
"etc/php/php-fpm.d/arsse.conf"
"etc/php-legacy/php-fpm.d/arsse.conf"
"etc/webapps/arsse/nginx/example.conf"
"etc/webapps/arsse/nginx/arsse.conf"
"etc/webapps/arsse/nginx/arsse-loc.conf"
"etc/webapps/arsse/nginx/arsse-fcgi.conf"
"etc/webapps/arsse/apache/example.conf"
"etc/webapps/arsse/apache/arsse.conf"
"etc/webapps/arsse/apache/arsse-fcgi.conf"
"etc/webapps/arsse/apache/arsse-loc.conf")
source=("arsse-0.10.4.tar.gz")
md5sums=("SKIP")
package() {
# define runtime dependencies
depends=("php-interpreter>=7.3" "php-sqlite-interpreter>=7.3" "php-fpm-interpreter>=7.3")
# create most directories necessary for the final package
cd "$pkgdir"
mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/php-legacy/php-fpm.d" "etc/webapps/arsse"
# copy requisite files
cd "$srcdir/arsse"
cp -r lib locale sql vendor www CHANGELOG UPGRADING README.md arsse.php "$pkgdir/usr/share/webapps/arsse"
cp -r manual/* "$pkgdir/usr/share/doc/arsse"
cp LICENSE AUTHORS "$pkgdir/usr/share/licenses/arsse"
cp dist/sysuser.conf "$pkgdir/usr/lib/sysusers.d/arsse.conf"
cp dist/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
cp dist/php-fpm.conf "$pkgdir/etc/php/php-fpm.d/arsse.conf"
cp -r dist/man "$pkgdir/usr/share"
cp -r dist/nginx dist/apache config.defaults.php "$pkgdir/etc/webapps/arsse"
# copy files requiring special permissions
install -Dm640 dist/config.php "$pkgdir/etc/webapps/arsse"
# patch generic configuration files to use Arch-specific paths and identifiers
sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
sed -i -se 's/\/var\/run\/php\//\/run\/php-fpm\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/etc/php/php-fpm.d/arsse.conf"
sed -i -se 's/www-data/http/g' "$pkgdir/etc/php/php-fpm.d/arsse.conf"
# make a duplicate PHP-FPM pool for php-legacy
sed -se 's/php-fpm/php-fpm-legacy/' "$pkgdir/etc/php/php-fpm.d/arsse.conf" > "$pkgdir/etc/php-legacy/php-fpm.d/arsse.conf"
# copy Arch-specific versions of files
install -Dm755 dist/arch/arsse "$pkgdir/usr/bin/arsse"
cp dist/arch/nginx-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/nginx/arsse-fcgi.conf"
cp dist/arch/apache-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/apache/arsse-fcgi.conf"
cp dist/arch/*.service "$pkgdir/usr/lib/systemd/system"
cp dist/arch/systemd-environment "$pkgdir/etc/webapps/arsse/systemd-environment"
}

79
dist/arch/PKGBUILD-git

@ -0,0 +1,79 @@
# Maintainer: J. King <jking@jkingweb.ca>
pkgname="arsse-git"
pkgver=0.10.4
pkgrel=1
epoch=
pkgdesc="Multi-protocol RSS/Atom newsfeed synchronization server, bugfix-testing version"
arch=("any")
url="https://thearsse.com/"
license=("MIT")
provides=("arsse")
conflicts=("arsse")
depends=("php-interpreter>=7.3" "php-intl-interpreter>=7.3" "php-sqlite-interpreter>=7.3")
makedepends=("composer")
checkdepends=()
optdepends=("nginx: HTTP server"
"apache>=2.4: HTTP server"
"percona-server: Alternate database"
"postgresql>=10: Alternate database"
"php-pgsql-interpreter>=7.3: PostgreSQL database support")
backup=("etc/webapps/arsse/config.php"
"etc/webapps/arsse/systemd-environment"
"etc/php/php-fpm.d/arsse.conf"
"etc/php-legacy/php-fpm.d/arsse.conf"
"etc/webapps/arsse/nginx/example.conf"
"etc/webapps/arsse/nginx/arsse.conf"
"etc/webapps/arsse/nginx/arsse-loc.conf"
"etc/webapps/arsse/nginx/arsse-fcgi.conf"
"etc/webapps/arsse/apache/example.conf"
"etc/webapps/arsse/apache/arsse.conf"
"etc/webapps/arsse/apache/arsse-fcgi.conf"
"etc/webapps/arsse/apache/arsse-loc.conf")
source=("git+https://code.mensbeam.com/MensBeam/arsse/")
md5sums=("SKIP")
pkgver() {
cd "arsse"
git describe --tags | sed 's/\([^-]*-g\)/r\1/;s/-/./g'
}
build() {
cd "$srcdir/arsse"
composer install
./robo manual
composer install --no-dev -o --no-scripts
php arsse.php conf save-defaults config.defaults.php
rm -r vendor/bin
}
package() {
# define runtime dependencies
depends=("php-interpreter>=7.3" "php-sqlite-interpreter>=7.3" "php-fpm-interpreter>=7.3")
# create most directories necessary for the final package
cd "$pkgdir"
mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/php-legacy/php-fpm.d" "etc/webapps/arsse"
# copy requisite files
cd "$srcdir/arsse"
cp -r lib locale sql vendor www CHANGELOG UPGRADING README.md arsse.php "$pkgdir/usr/share/webapps/arsse"
cp -r manual/* "$pkgdir/usr/share/doc/arsse"
cp LICENSE AUTHORS "$pkgdir/usr/share/licenses/arsse"
cp dist/sysuser.conf "$pkgdir/usr/lib/sysusers.d/arsse.conf"
cp dist/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
cp dist/php-fpm.conf "$pkgdir/etc/php/php-fpm.d/arsse.conf"
cp -r dist/man "$pkgdir/usr/share"
cp -r dist/nginx dist/apache config.defaults.php "$pkgdir/etc/webapps/arsse"
# copy files requiring special permissions
install -Dm640 dist/config.php "$pkgdir/etc/webapps/arsse"
# patch generic configuration files to use Arch-specific paths and identifiers
sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
sed -i -se 's/\/var\/run\/php\//\/run\/php-fpm\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/etc/php/php-fpm.d/arsse.conf"
sed -i -se 's/www-data/http/g' "$pkgdir/etc/php/php-fpm.d/arsse.conf"
# make a duplicate PHP-FPM pool for php-legacy
sed -se 's/php-fpm/php-fpm-legacy/' "$pkgdir/etc/php/php-fpm.d/arsse.conf" > "$pkgdir/etc/php-legacy/php-fpm.d/arsse.conf"
# copy Arch-specific versions of files
install -Dm755 dist/arch/arsse "$pkgdir/usr/bin/arsse"
cp dist/arch/nginx-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/nginx/arsse-fcgi.conf"
cp dist/arch/apache-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/apache/arsse-fcgi.conf"
cp dist/arch/*.service "$pkgdir/usr/lib/systemd/system"
cp dist/arch/systemd-environment "$pkgdir/etc/webapps/arsse/systemd-environment"
}

6
dist/arch/apache-arsse-fcgi.conf

@ -0,0 +1,6 @@
ProxyPreserveHost On
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/webapps/arsse/arsse.php"
ProxyFCGISetEnvIf "-n req('Authorization')" HTTP_AUTHORIZATION "%{req:Authorization}"
# Modify the below line to begin with "unix:/run/php-fpm-legacy/" if using the php-legacy package
ProxyPass "unix:/run/php-fpm/arsse.sock|fcgi://localhost/usr/share/webapps/arsse/"

28
dist/arch/arsse

@ -0,0 +1,28 @@
#!/usr/bin/env bash
readonly default_php="/usr/bin/php"
php=""
check_sudo() {
if ! command -v sudo > /dev/null; then
printf "The sudo command is not available.\n"
exit 1
fi
}
# allow overriding the php executable
if [ -n "${ARSSE_PHP}" ] && command -v "${ARSSE_PHP}" > /dev/null; then
php="${ARSSE_PHP}"
else
php="${default_php}"
fi
if [ "$(whoami)" = "arsse" ]; then
"$php" /usr/share/webapps/arsse/arsse "$@"
elif [ "${UID}" -eq 0 ]; then
runuser -u "arsse" -- "$php" /usr/share/webapps/arsse/arsse "$@"
else
check_sudo
sudo -u "arsse" "$php" /usr/share/webapps/arsse/arsse "$@"
fi

37
dist/arch/arsse-fetch.service

@ -0,0 +1,37 @@
[Unit]
Description=The Arsse newsfeed fetching service
Documentation=https://thearsse.com/manual/
PartOf=arsse.service
[Install]
WantedBy=multi-user.target
[Service]
User=arsse
Group=arsse
Type=simple
WorkingDirectory=/usr/share/webapps/arsse
EnvironmentFile=/etc/webapps/arsse/systemd-environment
ExecStart=/usr/bin/arsse daemon
ProtectProc=invisible
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
StateDirectory=arsse
ConfigurationDirectory=arsse
PrivateTmp=true
PrivateDevices=true
RestrictSUIDSGID=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=arsse
Restart=on-failure
RestartPreventStatus=
# These directives can be used for extra security, but are disabled for now for compatibility
#ReadOnlyPaths=/
#ReadWriePaths=/var/lib/arsse
#NoExecPaths=/
#ExecPaths=/usr/bin/php

13
dist/arch/arsse.service

@ -0,0 +1,13 @@
[Unit]
Description=The Arsse newsfeed management service
Documentation=https://thearsse.com/manual/
Requires=arsse-fetch.service
Wants=php-fpm.service php-legacy-fpm.service
[Install]
WantedBy=multi-user.target
[Service]
Type=oneshot
RemainAfterExit=true
ExecStart=/usr/bin/true

16
dist/arch/nginx-arsse-fcgi.conf

@ -0,0 +1,16 @@
fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
fastcgi_pass_request_body on;
fastcgi_pass_request_headers on;
fastcgi_intercept_errors off;
fastcgi_buffering off;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param REQUEST_URI $uri;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param REMOTE_USER $remote_user;
fastcgi_param SCRIPT_FILENAME /usr/share/webapps/arsse/arsse.php;
# Modify the below line to begin with "/run/php-fpm-legacy/" if using the php-legacy package
fastcgi_pass unix:/run/php-fpm/arsse.sock;

1
dist/arch/systemd-environment

@ -0,0 +1 @@
ARSSE_PHP=/usr/bin/php

10
dist/arsse

@ -0,0 +1,10 @@
#! /usr/bin/env php
<?php
if (posix_geteuid() == 0) {
$info = posix_getpwnam("arsse");
if ($info) {
posix_setgid($info['gid']);
posix_setuid($info['uid']);
}
}
require "/usr/share/arsse/arsse.php";

15
dist/arsse.service

@ -1,15 +0,0 @@
[Unit]
Description=The Arsse feed fetching service
After=network.target mysql.service postgresql.service
[Service]
User=www-data
Group=www-data
WorkingDirectory=/usr/share/arsse
Type=simple
StandardOutput=null
StandardError=syslog
ExecStart=/usr/bin/env php /usr/share/arsse/arsse.php daemon
[Install]
WantedBy=multi-user.target

8
dist/config.php

@ -0,0 +1,8 @@
<?php
# Please refer to config.defaults.php or the manual at /usr/share/doc/arsse/
# for possible configuration parameters
return [
'dbSQLite3File' => "/var/lib/arsse/arsse.db",
];

16
dist/debian/arsse.config

@ -0,0 +1,16 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
# Set up dbconfig-common
if test -f /usr/share/dbconfig-common/dpkg/config; then
. /usr/share/dbconfig-common/dpkg/config
dbc_dbtypes="sqlite3, pgsql, mysql"
dbc_authmethod_user="password"
dbc_go arsse "$@"
fi
# Prompt for dbconfig-common configuration
db_go || true

1
dist/debian/arsse.dirs

@ -0,0 +1 @@
var/lib/arsse

18
dist/debian/arsse.install

@ -0,0 +1,18 @@
lib usr/share/arsse/
locale usr/share/arsse/
sql usr/share/arsse/
vendor usr/share/arsse/
www usr/share/arsse/
CHANGELOG usr/share/arsse/
UPGRADING usr/share/arsse/
README.md usr/share/arsse/
arsse.php usr/share/arsse/
config.defaults.php etc/arsse/
manual usr/share/doc/arsse/
dist/man/* usr/share/man/
dist/debian/config.php etc/arsse/
dist/debian/dbconfig-common.php usr/share/arsse/
debian/bin/arsse usr/bin/
debian/nginx etc/arsse/
debian/apache etc/arsse/

1
dist/debian/arsse.links

@ -0,0 +1 @@
etc/arsse/config.php usr/share/arsse/config.php

29
dist/debian/arsse.postinst

@ -0,0 +1,29 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
if [ "$1" = "configure" ]; then
# Set permissions on configuration file
dpkg-statoverride --list "/etc/arsse/config.php" >/dev/null || dpkg-statoverride --update --add root www-data 640 "/etc/arsse/config.php"
# Set up dbconfig-common
if test -f /usr/share/dbconfig-common/dpkg/postinst; then
. /usr/share/dbconfig-common/dpkg/postinst
dbc_generate_include_owner="root:www-data"
dbc_generate_include_perms="0640"
dbc_generate_include="php:/var/lib/arsse/dbconfig.inc"
dbc_pgsql_createdb_encoding="UTF8' lc_collate='C"
dbc_mysql_createdb_encoding="UTF8"
dbc_dbfile_owner="root:www-data"
dbc_dbfile_perms="0660"
dbc_go arsse "$@"
fi
fi
# dh_installdeb will replace this with shell code automatically
# generated by other debhelper scripts.
#DEBHELPER#
exit 0

20
dist/debian/arsse.postrm

@ -0,0 +1,20 @@
#!/bin/sh
set -e
if test -f /usr/share/debconf/confmodule; then
. /usr/share/debconf/confmodule
fi
# Set up dbconfig-common
if test -f /usr/share/dbconfig-common/dpkg/postrm; then
. /usr/share/dbconfig-common/dpkg/postrm
dbc_go arsse "$@"
fi
# dh_installdeb will replace this with shell code automatically
# generated by other debhelper scripts.
#DEBHELPER#
exit 0

16
dist/debian/arsse.prerm

@ -0,0 +1,16 @@
#!/bin/sh
set -e
. /usr/share/debconf/confmodule
# Set up dbconfig-common
. /usr/share/dbconfig-common/dpkg/prerm
dbc_go arsse "$@"
# dh_installdeb will replace this with shell code automatically
# generated by other debhelper scripts.
#DEBHELPER#
exit 0

1
dist/debian/compat

@ -0,0 +1 @@
10

15
dist/debian/config.php

@ -0,0 +1,15 @@
<?php
/***
Please refer to config.defaults.php or the manual at /usr/share/doc/arsse/
for possible configuration parameters.
The last line includes database auto-configuration information which
Debian may have created during installation; any database-related
configuration defined in this file will override anything defined in the
included file.
***/
return [
'dbAutoUpdate' => true,
]
+ (@include "/usr/share/arsse/dbconfig-common.php");

32
dist/debian/control

@ -0,0 +1,32 @@
Source: arsse
Maintainer: J. King <jking@jkingweb.ca>
Section: contrib/net
Priority: optional
Standards-Version: 4.5.1
Homepage: https://thearsse.com/
Vcs-Browser: https://code.mensbeam.com/MensBeam/arsse/
Vcs-Git: https://code.mensbeam.com/MensBeam/arsse/
Build-Depends: debhelper
Package: arsse
Architecture: all
Section: contrib/net
Priority: optional
Homepage: https://thearsse.com/
Description: Multi-protocol RSS/Atom newsfeed synchronization server
The Arsse bridges the gap between multiple existing newsfeed aggregator
client protocols such as Tiny Tiny RSS, Nextcloud News and Miniflux,
allowing you to use compatible clients for many protocols with a single
server.
Depends: ${misc:Depends},
dbconfig-sqlite3 | dbconfig-pgsql | dbconfig-mysql | dbconfig-no-thanks,
php (>= 7.3.0),
php-cli,
php-intl,
php-json,
php-xml,
php-sqlite3 | php-pgsql | php-mysql
Recommends: nginx | apache2,
php-fpm,
php-curl,
ca-certificates

34
dist/debian/copyright

@ -0,0 +1,34 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: arsse
Upstream-Contact: J. King <jking@jkingweb.ca>
Source: https://code.mensbeam.com/MensBeam/arsse/
License: Expat
Files: *
Copyright: 2017 J. King <jking@jkingweb.ca>
2017 Dustin Wilson <dustin@dustinwilson.com>
License: Expat
License: Expat
Copyright (c) 2017 J. King, Dustin Wilson
.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
.
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

44
dist/debian/dbconfig-common.php

@ -0,0 +1,44 @@
<?php
# This script transforms Debian's dbconfig-common PHP-format include files
# into a form usable by The Arsse. This is necessary because The Arsse
# supports defining configuration parameters for all supported database types
# at once, using separate keys for the different types
$dbconfpath = "/var/lib/arsse/dbconfig.inc"; // path defined in postinst script
if (file_exists($dbconfpath)) {
require_once "/var/lib/arsse/dbconfig.inc";
$dbtype = $dbtype ?? "";
// the returned configuration depends on the $dbtype
if ($dbtype === "sqlite3") {
$conf = ['dbDriver' => "sqlite3"];
if (strlen((string) $basepath) && strlen((string) $dbname)) {
$conf['dbSQLite3File'] = "$basepath/$dbname";
}
} elseif ($dbtype === "pgsql") {
$conf = [
'dbDriver' => "postgresql",
'dbPostgreSQLHost' => $dbserver ?? "",
'dbPostgreSQLUser' => $dbuser ?? "arsse",
'dbPostgreSQLPass' => $dbpass ?? "",
'dbPostgreSQLPort' => (int) $dbport ?: 5432,
'dbPostgreSQLDb' => $dbname ?? "arsse",
];
} elseif ($dbtype === "mysql") {
$conf = [
'dbDriver' => "mysql",
'dbMySQLHost' => $dbserver ?? "",
'dbMySQLUser' => $dbuser ?? "arsse",
'dbMySQLPass' => $dbpass ?? "",
'dbMySQLPort' => (int) $dbport ?: 3306,
'dbMySQLDb' => $dbname ?? "arsse",
];
} else {
throw new \Exception("Debian dbconfig-common configuration file $dbconfpath is invalid");
}
return $conf;
} else {
// if no configuration file exists simply return an empty array
return [];
}

6
dist/debian/lintian-overrides

@ -0,0 +1,6 @@
# We make reference to "Tiny Tiny RSS"
spelling-error-in-description Tiny Tiny (duplicate word) Tiny
# The manual for DrUUID (a dependency) includes a harmless "up" link
privacy-breach-generic usr/share/arsse/vendor/jkingweb/druuid/documentation/manual.html [<link rel="up" href="http://jkingweb.ca/code/">] (http://jkingweb.ca/code/)
# We only ask dbconfig-common questions, which don't seem to require templates
no-debconf-templates

40
dist/debian/pbuilder.sh

@ -0,0 +1,40 @@
#! /bin/bash -e
###
# This script is fed to pbuilder to build Debian packages. The base tarball
# should be created with a command similar to the following:
#
# sudo pbuilder create --basetgz pbuilder-arsse.tgz --mirror http://ftp.ca.debian.org/debian/ --extrapackages "debhelper devscripts lintian"
#
# Thereafter pbuilder can be used to build packages with this command:
#
# sudo pbuilder execute --basetgz pbuilder-arsse.tgz --bindmounts `basedir "/path/to/release/tarball"` -- pbuilder.sh "/path/to/release/tarball"
#
# This somewhat roundabout procedure is used because the pbuilder debuild
# command does not seem to work in Arch Linux, nor does pdebuild. Doing
# as much as possible within the chroot itself works around these problems.
###
# create a temporary directory
tmp=`mktemp -d`
# define various variables
here=`dirname "$1"`
tarball=`basename "$1"`
version=`echo "$tarball" | grep -oP '\d+(?:\.\d+)*' | head -1`
out="$here/debian"
in="$tmp/arsse-$version"
# create necessary directories
mkdir -p "$in" "$out"
# extract the release tarball
tar -C "$in" -xf "$1" --strip-components=1
# repackage the release tarball into a Debian "orig" tarball
tar -C "$tmp" -czf "$tmp/arsse_$version.orig.tar.gz" "arsse-$version"
# copy the "dist/debian" directory down the tree where Debian expects it
cp -r "$in/dist/debian" "$in/debian"
# build the package
cd "$in"
debuild -us -uc
# move the resultant files to their final destination
find "$tmp" -maxdepth 1 -type f -exec mv '{}' "$out" \;

26
dist/debian/rules

@ -0,0 +1,26 @@
#!/usr/bin/make -f
DH_VERBOSE = 1
%:
dh $@
execute_before_dh_install:
# Adapt the systemd service for Debian: this involves using only the "arsse-fetch" unit (renamed to "arsse"), removing the "PartOf" directive, and changing the user and group to "www-data"
cp dist/systemd/arsse-fetch.service debian/arsse.service
sed -i -se 's/^PartOf=.*//' debian/arsse.service
sed -i -se 's/^\(User\|Group\)=.*/\1=www-data/' debian/arsse.service
# Adapt the init script for Debian: this involves changing the user and group to "www-data"
cp dist/init.sh debian/arsse.init
sed -i -se 's/^\([ \t]*chown\) arsse:arsse /\1 www-data:www-data /' debian/arsse.init
# Change the user and group references in tmpfiles
cp dist/tmpfiles.conf debian/arsse.tmpfiles
sed -i -se 's/ arsse / www-data /' debian/arsse.tmpfiles
sed -i -se 's/ arsse / www-data /' debian/arsse.tmpfiles
# Change the user reference in the executable file
mkdir -p debian/bin
cp dist/arsse debian/bin/arsse
sed -i -se 's/posix_getpwnam("arsse"/posix_getpwnam("www-data"/' debian/bin/arsse
# Change PHP-FPM socket paths
cp -r dist/apache dist/nginx debian
sed -i -se 's/arsse\.sock/php-fpm.sock/' debian/apache/arsse.conf debian/nginx/arsse.conf

1
dist/debian/source/format

@ -0,0 +1 @@
3.0 (quilt)

2
dist/debian/source/lintian-overrides

@ -0,0 +1,2 @@
# Development environment is slightly out of date
newer-standards-version

78
dist/init.sh

@ -0,0 +1,78 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: arsse
# Required-Start: $local_fs $network
# Required-Stop: $local_fs postgresql mysql
# Should-Start: postgresql mysql
# Should-Stop: postgresql mysql
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: The Advanced RSS Environment
# Description: The Arsse is a multi-protocol Web newsfeed synchronization service
### END INIT INFO
# This script is designed for Debian; some adaptation will be required for other systems
PATH=/usr/sbin/:/usr/bin:/sbin:/bin
NAME=arsse
DESC="newsfeed synchronization server"
PIDFILE=/run/arsse.pid
DAEMON=/usr/bin/$NAME
. /lib/init/vars.sh
. /lib/lsb/init-functions
arsse_start() {
touch "$PIDFILE"
chown arsse:arsse "$PIDFILE"
$DAEMON daemon --fork="$PIDFILE" || return 2
}
arsse_stop() {
killproc -p "$PIDFILE" "$DAEMON"
}
arsse_reload() {
killproc -p "$PIDFILE" "$DAEMON" HUP
}
case "$1" in
start)
log_daemon_msg "Starting $DESC" "$NAME"
if pidofproc -p $PIDFILE "$DAEMON" > /dev/null 2>&1 ; then
return 1
fi
arsse_start
;;
stop)
log_daemon_msg "Stopping $DESC" "$NAME"
arsse_stop
;;
restart)
log_daemon_msg "Restarting $DESC" "$NAME"
if pidofproc -p $PIDFILE "$DAEMON" > /dev/null 2>&1 ; then
arsse_stop
fi
arsse_start
;;
try-restart)
if pidofproc -p $PIDFILE "$DAEMON" > /dev/null 2>&1 ; then
log_daemon_msg "Restarting $DESC" "$NAME"
arsse_stop
arsse_start
fi
;;
reload|force-reload)
log_daemon_msg "Reloading $DESC" "$NAME"
arsse_reload
;;
status)
status_of_proc -p $PIDFILE $DAEMON $NAME
exit $?
;;
*)
echo "Usage: $0 {start|stop|restart|try-restart|reload|status}" >&2
exit 3
;;
esac

342
dist/man/man1/arsse.1

@ -0,0 +1,342 @@
.Dd October 27, 2023
.Dt ARSSE 1
.Os "The Arsse" 0.10.4
.
.
.Sh NAME
.Nm arsse
.Nd manage an instance of The Advanced RSS Environment (The Arsse)
.
.
.Sh SYNOPSIS
.Nm "arsse user"
.Op Nm list
.Nm "arsse user add"
.Ar username
.Op Ar password
.Op Fl Fl admin
.Nm "arsse user remove"
.Ar username
.Nm "arsse user show"
.Ar username
.Nm "arsse user set"
.Ar username
.Ar property
.Ar value
.Nm "arsse user unset"
.Ar username
.Ar property
.Nm "arsse user set\-pass"
.Ar username
.Op Ar password
.Op Fl Fl fever
.Nm "arsse user unset\-pass"
.Ar username
.Op Fl Fl fever
.Nm "arsse user auth"
.Ar username
.Op Ar password
.Op Fl Fl fever
.Nm "arsse token list"
.Ar username
.Nm "arsse token create"
.Ar username
.Op Ar label
.Nm "arsse token revoke"
.Ar username
.Op Ar token
.Nm "arsse import"
.Ar username
.Op Ar file
.Op Fl f | Fl Fl flat
.Op Fl r | Fl Fl replace
.Nm "arsse export"
.Ar username
.Op Ar file
.Op Fl f | Fl Fl flat
.Nm "arsse daemon"
.Op Fl Fl fork Ns = Ns Ar pidfile
.Nm "arsse feed refresh\-all"
.Nm "arsse feed refresh"
.Ar n
.Nm "arsse conf save\-defaults"
.Nm "arsse"
.Fl Fl version | Fl h | Fl Fl help
.
.
.Sh DESCRIPTION
.Nm
allows a sufficiently privileged user to perform various administrative operations related to The Arsse, including:
.Pp
.Bl -bullet -compact
.It
Adding and removing users and managing their metadata
.It
Managing passwords and authentication tokens
.It
Importing and exporting OPML newsfeed-lists
.El
.Pp
These are documented in the next section
.Sx COMMANDS Ns No .
Further, seldom-used commands are documented in the subsequent section
.Sx ADDITIONAL COMMANDS Ns No .
.
.
.Sh COMMANDS
.
.Ss Managing users and metadata
.Bl -tag
.It Nm "user" Op Nm list
Displays a simple list of user names with one entry per line
.It Nm "user add" Ar username Oo Ar password Oc Oo Fl Fl admin Oc
Adds a new user to the database with the specified username and password.
If
.Ar password
is omitted a random password will be generated and printed.
.Pp
The
.Fl Fl admin
flag may be used to mark the user as an administrator.
This has no meaning within the context of The Arsse as a whole,
but it is used control access to certain features in the Miniflux and Nextcloud News protocols.
.It Nm "user remove" Ar username
Immediately removes a user from the database.
All associated data (folders, subscriptions, etc.) are also removed.
.It Nm "user show" Ar username
Displays a table of metadata properties and their assigned values for
.Ar username Ns No .
These properties are primarily used by the Miniflux protocol.
Consult the section
.Sx USER METADATA
for details.
.It Nm "user set" Ar username Ar property Ar value
Sets a metadata property for a user.
These properties are primarily used by the Miniflux protocol.
Consult the section
.Sx USER METADATA
for details.
.It Nm "user unset" Ar username Ar property
Clears a metadata property for a user.
The property is thereafter set to its default value, which is protocol-dependent.
.El
.
.Ss Managing passwords and authentication tokens
.Bl -tag
.It Nm "user set\-pass" Ar username Oo Ar password Oc Oo Fl Fl fever Oc
Changes a user's password to the specified value.
If no password is specified, a random password will be generated and printed.
.Pp
The
.Fl Fl fever
option sets a user's Fever protocol password instead of their general password.
As the Fever protocol requires that passwords be stored insecurely,
users do not have Fever passwords by default, and logging in to the Fever protocol is disabled until a suitable password is set.
It is highly recommended that a user's Fever password be different from their general password.
.It Nm "user unset\-pass" Ar username Oo Fl Fl fever Oc
Unsets a user's password, effectively disabling their account.
As with password setting, the
.Fl Fl fever
option may be used to operate on a user's Fever password instead of their general password.
.It Nm "user auth" Ar username Ar password Oo Fl Fl fever Oc
Tests logging a user in.
This only checks that the user's password is correctly recognized;
it has no side effects.
.Pp
The
.Fl Fl fever
option may be used to test the user's Fever protocol password, if any.
.It Nm "token list" Ar username
Displays a user's authentication tokens in a simple tabular format.
These tokens act as an alternative means of authentication for the Miniflux protocol and may be required by some clients.
They do not expire.
.It Nm "token create" Ar username Oo Ar label Oc
Creates a new random login token and prints it.
These tokens act as an alternative means of authentication for the Miniflux protocol and may be required by some clients.
An optional
.Ar label
may be specified to give the token a meaningful name.
.It Nm "token revoke" Ar username Oo Ar token Oc
Deletes the specified
.Ar token
from the database.
The token itself must be supplied, not its label.
If it is omitted all tokens for
.Ar username
are revoked.
.El
.
.Ss Importing and exporting data
.Bl -tag
.It Nm "import" Ar username Oo Ar file Oc Oo Fl r | Fl Fl replace Oc Oo Fl f | Fl Fl flat Oc
Imports the newsfeeds, folders, and tags found in the OPML formatted
.Ar file
into the account of the specified user.
If no file is specified, data is instead read from standard input.
Import operations are atomic:
if any of the newsfeeds listed in the input cannot be retrieved, the entire import operation will fail.
.Pp
The
.Fl Fl replace
(or
.Fl r Ns
) option interprets the OPML file as the list of
.Em all
desired newsfeeds, folders and tags, performing any deletion or moving of existing entries which do not appear in the flle.
If this option is not specified, the file is assumed to list desired
.Em additions only Ns No .
.Pp
The
.Fl Fl flat
(or
.Fl f Ns
) option can be used to ignore any folder structures in the file, importing any newsfeeds directly into the root folder.
Combining this with the
.Fl Fl replace
option is possible.
.It Nm "export" Ar username Oo Ar file Oc Oo Fl f | Fl Fl flat Oc
Exports a user's newsfeeds, folders, and tags to the OPML file specified by
.Ar file Ns
, or standard output if no file is specified.
Note that due to a limitation of the OPML format, any commas present in tag names will not be retained in the export.
.Pp
The
.Fl Fl flat
(or
.Fl f Ns
) option can be used to omit folders from the export.
Some OPML implementations may not support folders, or arbitrary nesting;
this option may be used when planning to import into such software.
.El
.
.
.Sh ADDITIONAL COMMANDS
.Bl -tag
.It Nm "daemon" Oo Fl Fl fork Ns = Ns Ar pidfile Oc
Starts the newsfeed fetching service.
Normally this command is only invoked by systemd.
.Pp
The
.Fl Fl fork
option executes an "old-style" fork-then-terminate daemon rather than a "new-style" non-terminating daemon.
This option should only be employed if using a System V-style init daemon on POSIX systems;
normally systemd is used. When using this option the daemon will write its process identifier to
.Ar pidfile
after forking.
.It Nm "feed refresh\-all"
Performs a one-time fetch of all stale feeds.
This command can be used as the basis of a
.Nm cron
job to keep newsfeeds up-to-date.
.It Nm "feed refresh" Ar n
Performs a one-time fetch of the feed (not subscription) identified by integer
.Ar n Ns No .
This is used internally by the fetching service and should not normally be needed.
.It Nm "conf save\-defaults" Oo Ar file Oc
Prints default configuration parameters to standard output, or to
.Ar file
if specified.
Each parameter is annotated with a short description of its purpose and usage.
.El
.
.
.Sh USER METADATA
User metadata are primarily used by the Miniflux protocol,
and most properties have identical or similar names to those used by Miniflux.
Properties may also affect other protocols, or conversely may have no effect even when using the Miniflux protocol;
this is noted below when appropriate.
.Pp
Booleans accept any of the values
.Ar true Ns No / Ns Ar false Ns No ,
.Ar 1 Ns No / Ns Ar 0 Ns No ,
.Ar yes Ns No / Ns Ar no Ns No ,
or
.Ar on Ns No / Ns Ar off Ns No .
.Pp
The following metadata properties exist for each user:
.Pp
.Bl -tag
.It Cm num No (integer)
The numeric identifier of the user.
This is assigned at user creation and is read-only.
.It Cm admin No (boolean)
Boolean. Whether the user is an administrator.
Administrators may manage other users via the Miniflux protocol,
and also may trigger feed updates manually via the Nextcloud News protocol.
.It Cm lang No (string)
The preferred language of the user as a BCP 47 language tag, for example "en-ca".
Note that since The Arsse currently only includes English text it is not used by The Arsse itself,
but clients may use this metadatum in protocols which expose it.
.It Cm tz No (string)
The time zone of the user as a Time Zone Database identifier, for example "America/Los_Angeles".
.It Cm root_folder_name No (string)
The name of the root folder, in protocols which allow it to be renamed.
.It Cm sort_asc No (boolean)
Whether the user prefers ascending sort order for articles.
Descending order is usually the default,
but explicitly setting this property false will also make a preference for descending order explicit.
.It Cm theme No (string)
The user's preferred user-interface theme.
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
.It Cm page_size No (integer)
The user's preferred page size when listing articles.
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
.It Cm shortcuts No (boolean)
Whether to enable keyboard shortcuts.
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
.It Cm gestures No (boolean)
Whether to enable touch gestures.
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
.It Cm reading_time No (boolean)
Whether to calculate and display the estimated reading time for articles.
Currently The Arsse does not calculate reading time, so changing this will likely have no effect.
.It Cm stylesheet No (string)
A user stylesheet in CSS format.
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
.El
.
.
.Sh EXAMPLES
.Bl -tag
.It Add an administrator to the database with an explicit password:
.Bd -literal
$ arsse user add \-\-admin alice "Curiouser and curiouser!"
.Ed
.It Add a regular user to the database with a random password:
.Bd -literal
$ arsse user add "Bob the Builder"
bLS!$_UUZ!iN2i_!^IC6
.Ed
.It Make Bob the Builder an administrator:
.Bd -literal
$ arsse user set "Bob the Builder" admin true
.Ed
.It Disable Alice's account by clearing her password:
.Bd -literal
$ arsse user unset\-pass alice
.Ed
.It Move all of Foobar's newsfeeds to the root folder:
.Bd -literal
$ arsse export foobar \-f | arsse import \-r foobar
.Ed
.It Fail to log in as Alice:
.Bd -literal
$ arsse user auth alice "Oh, dear!"
Authentication failed
$ echo $?
1
.Ed
.El
.
.
.Sh REPORTING BUGS
Any bugs found in The Arsse may be reported on the Web via the
.Lk https://code.mensbeam.com/MensBeam/arsse "MensBeam code repository"
or may be directed to the principal authors by e-mail:
.Pp
.Bl -bullet -compact
.It
.Lk https://jkingweb.ca/ "J. King"
.It
.Lk https://dustinwilson.com/ "Dustin Wilson"
.El

75
dist/nginx.conf

@ -1,75 +0,0 @@
server {
server_name example.com;
listen 80; # adding HTTPS configuration is highly recommended
root /usr/share/arsse/www; # adjust according to your installation path
location / {
try_files $uri $uri/ =404;
}
location @arsse {
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; # adjust according to your system configuration
fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
fastcgi_pass_request_body on;
fastcgi_pass_request_headers on;
fastcgi_intercept_errors off;
fastcgi_buffering off;
fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php; # adjust according to your installation path
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param REQUEST_URI $uri;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param REMOTE_USER $remote_user;
}
# Nextcloud News protocol
location /index.php/apps/news/api {
try_files $uri @arsse;
location ~ ^/index\.php/apps/news/api/?$ {
# this path should not be behind HTTP authentication
try_files $uri @arsse;
}
}
# Tiny Tiny RSS protocol
location /tt-rss/api {
try_files $uri @arsse;
}
# Tiny Tiny RSS feed icons
location /tt-rss/feed-icons/ {
try_files $uri @arsse;
}
# Tiny Tiny RSS special-feed icons; these are static files
location /tt-rss/images/ {
# this path should not be behind HTTP authentication
try_files $uri =404;
}
# Fever protocol
location /fever/ {
# this path should not be behind HTTP authentication
try_files $uri @arsse;
}
# Miniflux protocol
location /v1/ {
try_files $uri @arsse;
}
# Miniflux version number
location /version {
# this path should not be behind HTTP authentication
try_files $uri @arsse;
}
# Miniflux "health check"
location /healthcheck {
# this path should not be behind HTTP authentication
try_files $uri @arsse;
}
}

15
dist/nginx/arsse-fcgi.conf

@ -0,0 +1,15 @@
fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
fastcgi_pass_request_body on;
fastcgi_pass_request_headers on;
fastcgi_intercept_errors off;
fastcgi_buffering off;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param REQUEST_URI $uri;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param REMOTE_USER $remote_user;
fastcgi_pass unix:/var/run/php/arsse.sock;
fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php;

49
dist/nginx/arsse-loc.conf

@ -0,0 +1,49 @@
# Any provided static files
location / {
try_files $uri $uri/ =404;
}
# Nextcloud News protocol
location /index.php/apps/news/api {
try_files $uri @arsse;
location ~ ^/index\.php/apps/news/api/?$ {
try_files $uri @arsse_public;
}
}
# Tiny Tiny RSS protocol
location /tt-rss/api {
try_files $uri @arsse;
}
# Tiny Tiny RSS feed icons
location /tt-rss/feed-icons/ {
try_files $uri @arsse;
}
# Tiny Tiny RSS special-feed icons; these are static files
location /tt-rss/images/ {
try_files $uri =404;
}
# Fever protocol
location /fever/ {
try_files $uri @arsse;
}
# Miniflux protocol
location /v1/ {
# If put behind HTTP authentication token login will not be possible
try_files $uri @arsse;
}
# Miniflux version number
location /version {
try_files $uri @arsse_public;
}
# Miniflux "health check"
location /healthcheck {
try_files $uri @arsse_public;
}

13
dist/nginx/arsse.conf

@ -0,0 +1,13 @@
root /usr/share/arsse/www;
location @arsse {
# HTTP authentication may be enabled for this location, though this may impact some features
include /etc/arsse/nginx/arsse-fcgi.conf;
}
location @arsse_public {
# HTTP authentication should not be enabled for this location
include /etc/arsse/nginx/arsse-fcgi.conf;
}
include /etc/arsse/nginx/arsse-loc.conf;

13
dist/nginx/example.conf

@ -0,0 +1,13 @@
server {
server_name news.example.com;
listen 80;
listen [::]:80;
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/letsencrypt/live/news.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/news.example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/news.example.com/chain.pem;
include /etc/arsse/nginx/arsse.conf;
}

11
dist/php-fpm.conf

@ -0,0 +1,11 @@
[arsse]
user = arsse
group = arsse
listen = /var/run/php/arsse.sock
listen.owner = arsse
listen.group = www-data
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

36
dist/systemd/arsse-fetch.service

@ -0,0 +1,36 @@
[Unit]
Description=The Arsse newsfeed fetching service
Documentation=https://thearsse.com/manual/
PartOf=arsse.service
[Install]
WantedBy=multi-user.target
[Service]
User=arsse
Group=arsse
Type=simple
WorkingDirectory=/usr/share/arsse
ExecStart=/usr/bin/arsse daemon
ProtectProc=invisible
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
StateDirectory=arsse
ConfigurationDirectory=arsse
PrivateTmp=true
PrivateDevices=true
RestrictSUIDSGID=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=arsse
Restart=on-failure
RestartPreventStatus=
# These directives can be used for extra security, but are disabled for now for compatibility
#ReadOnlyPaths=/
#ReadWriePaths=/var/lib/arsse
#NoExecPaths=/
#ExecPaths=/usr/bin/php

14
dist/systemd/arsse.service

@ -0,0 +1,14 @@
[Unit]
Description=The Arsse newsfeed management service
Documentation=https://thearsse.com/manual/
Requires=arsse-fetch.service
BindsTo=php-fpm.service
After=php-fpm.service
[Install]
WantedBy=multi-user.target
[Service]
Type=oneshot
RemainAfterExit=true
ExecStart=/usr/bin/true

1
dist/sysuser.conf

@ -0,0 +1 @@
u arsse - "The Arsse" /var/lib/arsse -

5
dist/tmpfiles.conf

@ -0,0 +1,5 @@
z /usr/bin/arsse 0755 root arsse - -
z /etc/arsse/config.php 0640 root arsse - -
L /usr/share/arsse/config.php - root arsse - /etc/arsse/config.php
d /var/lib/arsse 0750 arsse arsse - -
L /usr/share/arsse/arsse - root arsse - /usr/share/arsse/arsse.php

16
docs/en/020_Getting_Started/010_Requirements.md

@ -1,16 +0,0 @@
The Arsse has the following requirements:
- A Linux server running Nginx or Apache 2.4
- PHP 7.1.0 or later with the following extensions:
- [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [dom](http://php.net/manual/en/book.dom.php)
- [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php)
- One of:
- [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases
- [pgsql](http://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](http://php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 10 or later databases
- [mysqli](http://php.net/manual/en/book.mysqli.php) or [pdo_mysql](http://php.net/manual/en/ref.pdo-mysql.php) for MySQL/Percona 8.0.11 or later databases
- [curl](http://php.net/manual/en/book.curl.php) (optional)
- Privileges either to create and run systemd services, or to run cron jobs
Instructions for how to satisfy the PHP extension requirements for Debian systems are included in the next section.
It is also be possible to run The Arsse on other operating systems (including Windows) and with other Web servers, but the configuration required to do so is not documented in this manual.

41
docs/en/020_Getting_Started/020_Download_and_Installation.md

@ -1,41 +0,0 @@
[TOC]
# Downloading The Arse
The latest version of The Arsse can be downloaded [from our Web site](https://thearsse.com/). If installing an older release from our archives, the attachments named _arsse-x.x.x.tar.gz_ should be used rather than those marked "Source Code".
Installation from source code is also possible, but the release packages are recommended.
# Installation
In order for The Arsse to function correctly, [its requirements](Requirements) must first be satisfied. The process of installing the required PHP extensions differs from one system to the next, but on Debian the following series of commands should do:
```sh
# Install PHP; this assumes the FastCGI process manager will be used
sudo apt install php-cli php-fpm
# Install the needed PHP extensions; php-curl is optional
sudo apt install php-intl php-json php-xml php-curl
# Install any one of the required database extensions
sudo apt install php-sqlite3 php-pgsql php-mysql
```
Then, it's a simple matter of unpacking the archive someplace (`/usr/share/arsse` is the recommended location on Debian systems, but it can be anywhere) and setting permissions:
```sh
# Unpack the archive
sudo tar -xzf arsse-x.x.x.tar.gz -C "/usr/share"
# Make the user running the Web server the owner of the files
sudo chown -R www-data:www-data "/usr/share/arsse"
# Ensure the owner can create files such as the SQLite database
sudo chmod o+rwX "/usr/share/arsse"
```
# Next steps
If using a database other than SQLite, you will likely want to [set it up](Database_Setup) before doing anything else.
In order for the various synchronization protocols to work, a Web server [must be configured](Web_Server_Configuration), and in order for The Arsse to serve users, those users [must be created](/en/Using_The_Arsse/Managing_Users).
You may also want to review the `config.defaults.php` file included in the download package and create [a configuration file](Configuration), though The Arsse can function even without using a configuration file.
Finally, The Arsse's [newsfeed refreshing service](/en/Using_The_Arsse/Keeping_Newsfeeds_Up_to_Date) needs to be installed in order for news to actually be fetched from the Internet.

71
docs/en/020_Getting_Started/020_Download_and_Installation/010_On_Arch_Linux.md

@ -0,0 +1,71 @@
[TOC]
# Downloading The Arsse
Since version 0.9.2 The Arsse is available from the [Arch User Repository](https://aur.archlinux.org/) as packages `arsse` and `arsse-git`. The latter should normally only be used to test bug fixes.
Generic release tarballs may also be downloaded [from our Web site](https://thearsse.com), and the `PKGBUILD` file (found under `arsse/dist/arch/`) can then be extracted alongside the tarball and used to build the `arsse` package. Installing directly from the generic release tarball without producing an Arch package is not recommended as the package-building process performs various adjustments to handle Arch peculiarities.
# Installation
For illustrative purposes, this document assumes the `yay` [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) will be used to download, build, and install The Arsse. This section summarises the steps necessary to configure and use The Arsse after installtion:
```sh
# Install the package
sudo yay -S arsse
# Enable the necessary PHP extensions; curl is optional but recommended; pdo_sqlite may be used instead of sqlite3, but this is not recommended
sudo sed -i -e 's/^;\(extension=\(curl\|iconv\|intl\|sqlite3\)\)$/\1/' /etc/php/php.ini
# Enable and start the necessary systemd units
sudo systemctl enable php-fpm arsse
sudo systemctl restart php-fpm arsse
```
Note that the above is the most concise process, not necessarily the recommended one. In particular [it is recommended](https://wiki.archlinux.org/title/PHP#Extensions) to use `/etc/php/conf.d/` to enable PHP extensions rather than editing `php.ini` as done above.
# Web server configuration
Sample configuration for both Nginx and Apache HTTP Server can be found in `/etc/webapps/arsse/nginx/` and `/etc/webapps/arsse/apache/`, respectively. The `example.conf` files are basic virtual host examples; the other files they include should normally be usable without modification, but may be modified if desired.
If using Apache HTTP Server the `mod_proxy` and `mod_proxy_fcgi` modules must be enabled. This can be achieved by adding the following lines to your virtual host or global configuration:
```apache
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so
```
No additional set-up is required for Nginx.
# Using an alternative PHP interpreter
The above instructions assume you will be using the `php` package as your PHP interpreter. If you wish to use `php-legacy` (which is always one feature version behind, for compatibility) a few configuration tweaks are required. The follwoing commands are a short summary:
```sh
# Enable the necessary PHP extensions; curl is optional but recommended; pdo_sqlite may be used instead of sqlite3, but this is not recommended
sudo sed -i -e 's/^;\(extension=\(curl\|iconv\|intl\|sqlite3\)\)$/\1/' /etc/php-legacy/php.ini
# Modify the system service's environment
sudo sed -i -e 's/^ARSSE_PHP=.*/ARSSE_PHP=\/usr\/bin\/php-legacy/' /etc/webapps/arsse/systemd-environment
# Modify the PAM environment for the administrative CLI
echo "export ARSSE_PHP=/usr/bin/php-legacy" | sudo tee -a /etc/profile.d/arsse >/dev/null
# Modify the Nginx and Apache HTTPD configurations
sudo sed -i -se 's/\/run\/php-fpm\//\/run\/php-fpm-legacy\//' /etc/webapps/arsse/apache/arsse-fcgi.conf /etc/webapps/arsse/nginx/arsse-fcgi.conf
```
The above procedure can also be applied to use another PHP version from AUR if so desired.
# Next steps
If using a database other than SQLite, you will likely want to [set it up](/en/Getting_Started/Database_Setup) before doing anything else.
In order for The Arsse to serve users, those users [must be created](/en/Using_The_Arsse/Managing_Users).
You may also want to review the `config.defaults.php` file included in `/etc/webapps/arsse/` or consult [the documentation for the configuration file](/en/Getting_Started/Configuration), though The Arsse should function with the default configuration.
# Upgrading
Upgrading The Arsse is done like any other package. By default The Arsse will perform any required database schema upgrades when the new version is executed, so the service does need to be restarted:
```sh
sudo systemctl restart arsse
```
Occasionally changes to Web server configuration have been required, such as when new protocols become supported; these changes are always explicit in the `UPGRADING` file.

72
docs/en/020_Getting_Started/020_Download_and_Installation/020_On_Debian_and_Derivatives.md

@ -0,0 +1,72 @@
[TOC]
# Downloading The Arsse
Since version 0.10.0 pre-built Debian packages for The Arsse are available from the [OpenSUSE Build Service](https://build.opensuse.org/) (OBS) under [the author's personal project repository](https://build.opensuse.org/package/show/home:JKingWeb/arsse). This is the preferred method for instaling the software and is the means documented below.
Generic release tarballs may also be downloaded [from our Web site](https://thearsse.com), and a Debian package built manually. Installing directly from the generic release tarball without producing a Debian package is not recommended as the Debian packages make the set-up process on Debian systems significantly simpler.
# Adding the repository
In order to install The Arsse, the OBS repository must first be configured along with its signing key:
```sh
# Add the key
wget -q -O - "https://download.opensuse.org/repositories/home:/JKingWeb/Debian_Unstable/Release.key" | gpg --dearmor | sudo tee "/usr/share/keyrings/arsse-obs-keyring.gpg" >/dev/null
# Add the repository
echo "deb [signed-by=/usr/share/keyrings/arsse-obs-keyring.gpg] https://download.opensuse.org/repositories/home:/JKingWeb/Debian_Unstable/ ./" | sudo tee "/etc/apt/sources.list.d/arsse-obs.list" >/dev/null
# Update APT's database
sudo apt update -qq
```
Please note that the "Unstable" qualifier in the repository URL is a reference to Debian's "sid" release and is not a reflection on The Arsse's stability. The repository should be suitable for any Debian version or derivative which includes a sufficiently recent version of PHP.
# Installation
Once the OBS repository is configured, installing The Arsse is achieved with a single command:
```sh
sudo apt install arsse
```
After installation is complete The Arsse will be started automatically.
During the installation process you will be prompted whether to allow `dbconfig-common` to configure The Arsse's database automatically. The default `sqlite3` (SQLite) option is a good choice, but `pgsql` (PostgreSQL) and `mysql` (MySQL) are possible alternatives. If you wish to [use a database other than SQLite](/en/Getting_Started/Database_Setup/index), you should install it before installing The Arsse:
```sh
# Install PostgreSQL
sudo apt install postgresql php-pgsql
# Install MySQL
sudo apt install mysql-server php-mysql
# Install SQLite explicitly
sudo apt install php-sqlite3
```
If you wish to change the database backend after having installed The Arsse, running `dpkg-reconfigure` after installing the database server can be used to achieve this:
```sh
sudo dpkg-reconfigure arsse
```
# Web server configuration
Sample configuration for both Nginx and Apache HTTP Server can be found in `/etc/arsse/nginx/` and `/etc/arsse/apache/`, respectively. The `example.conf` files are basic virtual host examples; the other files they include should normally be usable without modification, but may be modified if needed or desired. In particularly users of systems older than Debian 11 (Bullseye) or Ubuntu 20.04 (Focal Fossa) or derivatives will need to change the PHP-FPM socket path in Nginx or Apache's `arsse.conf`.
In order to use Apache HTTP Server the FastCGI proxy module must be enabled and the server restarted:
```sh
sudo a2enmod proxy proxy_fcgi
sudo systemctl restart apache2
```
No additional set-up is required for Nginx.
# Next steps
In order for The Arsse to serve users, those users [must be created](/en/Using_The_Arsse/Managing_Users).
You may also want to review the `config.defaults.php` file included in the download package and create [a configuration file](/en/Getting_Started/Configuration), though The Arsse can function even without using a configuration file.
# Upgrading
Upgrading The Arsse is done like any other package. Occasionally changes to Web server configuration have been required, such as when new protocols become supported; these changes are always explicit in the `UPGRADING` file.

53
docs/en/020_Getting_Started/020_Download_and_Installation/999_ On_Other_Systems.md

@ -0,0 +1,53 @@
# Downloading The Arsse
The Arsse should run on any operating system for which PHP and a Web server are available, but only the combination of Linux, Systemd, Nginx, and PHP-FPM has been extensively tested.
Below are very generic instructions and suggestions for installing The Arsse on systems for which pre-built packages are not available.
# Requirements
The Arsse has the following requirements:
- A Web server such as:
- [Nginx](https://nginx.org)
- [Apache HTTP server](https://httpd.apache.org) 2.4 or later
- PHP 7.3.0 or later with the following extensions:
- [intl](https://php.net/manual/en/book.intl.php), [json](https://php.net/manual/en/book.json.php), [hash](https://php.net/manual/en/book.hash.php), [filter](https://php.net/manual/en/book.filter.php), and [dom](https://php.net/manual/en/book.dom.php)
- [simplexml](https://php.net/manual/en/book.simplexml.php), and [iconv](https://php.net/manual/en/book.iconv.php)
- One of:
- [sqlite3](https://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases
- [pgsql](https://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](https://php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 10 or later databases
- [mysqli](https://php.net/manual/en/book.mysqli.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) for MySQL/Percona 8.0.11 or later databases
- [curl](https://php.net/manual/en/book.curl.php) (optional)
- [posix](https://php.net/manual/en/book.posix.php) and [pcntl](https://php.net/manual/en/book.pcntl.php) (both optional)
- An interface between PHP and the Web server, such as [PHP-FPM](https://php.net/manual/en/install.fpm.php)
- Privileges either to create and run system services, or to run cron jobs
# Installation
1. Download [the latest release](https://thearsse.com/releases/current) and extract it somewhere, such as `/usr/share/arsse/`
2. [Set up your database](/en/Getting_Started/Database_Setup)
3. Create [a configuration file](/en/Getting_Started/Configuration) if needed
4. Consult the files under `dist/nginx` and `dist/apache` for sample Web server configuration
5. Consult `dist/arsse` for a sample executable script which drops privileges on POSIX systems
6. Start the newsfeed fetching service:
- Sample Systemd service files are available under `dist/systemd`
- A sample System V init script is available in `dist/init.sh`
- A persistent process can be started by running `php arsse.php daemon`
- It is also possible [to use cron](/en/Using_The_Arsse/Other_Topics.html#page_Refreshing_newsfeeds_with_a_cron_job) or a similar task-scheduling tool
7. [Create users](/en/Using_The_Arsse/Managing_Users) to grant them access
# Upgrading
Upgrading The Arsse is usually simple:
1. Download the latest release
2. Check the `UPGRADING` file for any special notes
3. Stop the newsfeed refreshing service if it is running
4. Back up your configurationm and database
5. Extract the new version on top of the old one
6. Restart the newsfeed refreshing service
By default The Arsse will perform any required database schema upgrades when the new version is executed.
Occasionally changes to Web server configuration have been required, when new protocols become supported; such changes are always explicit in the `UPGRADING` file

16
docs/en/020_Getting_Started/020_Download_and_Installation/index.md

@ -0,0 +1,16 @@
# Installing from a package manager
We currently provide a few pre-built installation packages for the following operating systems:
- [Arch Linux and derivatives](On_Arch_Linux)
- [Debian and derivatives](On_Debian_and_Derivatives)
These packages significantly simplify installation, though a bit of manual effort may still be required. Updating The Arsse using these packages should require no manual intervention.
# Installing manually
For other systems The Arsse must currently be installed manually. As each operating system is different, we can only provide very general instructions:
- [Installing on other systems](On_Other_Systems)
We hope to support more operating systems in the future.

107
docs/en/020_Getting_Started/030_Web_Server_Configuration.md

@ -1,107 +0,0 @@
[TOC]
# Preface
As a PHP application, The Arsse requires the aid of a Web server in order to communicate with clients. How to install and configure a Web server in general is outside the scope of this document, but this section provides examples and advice for Web server configuration specific to The Arsse. Any server capable of interfacing with PHP should work, though we have only tested Nginx and Apache 2.4.
Samples included here only cover the bare minimum for configuring a virtual host. In particular, configuration for HTTPS (which is highly recommended) is omitted for the sake of clarity
# Configuration for Nginx
```nginx
server {
server_name example.com;
listen 80; # adding HTTPS configuration is highly recommended
root /usr/share/arsse/www; # adjust according to your installation path
location / {
try_files $uri $uri/ =404;
}
location @arsse {
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; # adjust according to your system configuration
fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
fastcgi_pass_request_body on;
fastcgi_pass_request_headers on;
fastcgi_intercept_errors off;
fastcgi_buffering off;
fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php; # adjust according to your installation path
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param REQUEST_URI $uri;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param REMOTE_USER $remote_user;
}
# Nextcloud News protocol
location /index.php/apps/news/api {
try_files $uri @arsse;
location ~ ^/index\.php/apps/news/api/?$ {
# this path should not be behind HTTP authentication
try_files $uri @arsse;
}
}
# Tiny Tiny RSS protocol
location /tt-rss/api {
try_files $uri @arsse;
}
# Tiny Tiny RSS feed icons
location /tt-rss/feed-icons/ {
try_files $uri @arsse;
}
# Tiny Tiny RSS special-feed icons; these are static files
location /tt-rss/images/ {
# this path should not be behind HTTP authentication
try_files $uri =404;
}
# Fever protocol
location /fever/ {
# this path should not be behind HTTP authentication
try_files $uri @arsse;
}
}
```
# Configuration for Apache2
There are many ways for Apache to interface with PHP, but the recommended way today is to use `mod_proxy` and `mod_proxy_fcgi` to communicate with PHP-FPM. If necessary you can enable these modules on Debian systems using the following commands:
```sh
sudo a2enmod proxy proxy_fcgi
sudo systemctl restart apache2
```
Afterward the follow virtual host configuration should work, after modifying path as appropriate:
```apache
# N.B. the unix:/var/run/php/php7.2-fpm.sock path used repeatedly below will
# vary from system to system and will be probably need to be changed
<VirtualHost *:80>
ServerName localhost
# adjust according to your installation path
DocumentRoot /usr/share/arsse/www
# adjust according to your installation path
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
ProxyPreserveHost On
# Nextcloud News v1.2, Tiny Tiny RSS API, TT-RSS newsfeed icons
<LocationMatch "(/index\.php/apps/news/api/?.+|/tt-rss/(api|feed-icons))">
ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
</LocationMatch>
# Nextcloud News API detection, Fever API
<LocationMatch "(/index\.php/apps/news/api/?$|/fever)">
# these locations should not be behind HTTP authentication
ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
</LocationMatch>
</VirtualHost>
```

14
docs/en/020_Getting_Started/040_Database_Setup/000_SQLite.md

@ -8,9 +8,17 @@
<dt>Minimum version</dt>
<dd>3.8.3</dd>
<dt>Configuration</dt>
<dd><a href="../Configuration.html#page_Database-settings">General</a>, <a href="../Configuration.html#page_Database-settings-specific-to-SQLite-3">Specific</a></dd>
<dd><a href="../Configuration.html#page_Database_settings">General</a>, <a href="../Configuration.html#page_Database_settings_specific_to_SQLite_3">Specific</a></dd>
</dl>
SQLite requires very little set-up. By default the database will be created at the root of The Arsse's program directory (e.g. `/usr/share/arsse/arsse.db`), but this can be changed with the [`dbSQLite3File` setting](/en/Getting_Started/Configuration#page_dbSQLite3File).
SQLite requires very little set-up. By default the database will be created at one of the following locations depending on installation method:
Regardless of the location chosen, The Arsse **must** be able to both read from and write to the database file, as well as create files in its directory. This is because SQLite also creates a write-ahead log file and a shared-memory file during operation.
| Installation method | Default database path |
|---------------------|------------------------------------------------------|
| Arch Linux package | `/var/lib/arsse/arsse.db` |
| Debian package | `/var/lib/dbconfig-common/sqlite3/arsse/arsse` |
| Manual installation | `arsse.db` in the The Arsse's installation directory |
This path can be changed with the [`dbSQLite3File` setting](/en/Getting_Started/Configuration#page_dbSQLite3File).
Regardless of the location used, The Arsse **must** be able to both read from and write to the database file, as well as create files in its directory. This is because SQLite also creates a write-ahead log file and a shared-memory file during operation.

2
docs/en/020_Getting_Started/040_Database_Setup/010_PostgreSQL.md

@ -8,7 +8,7 @@
<dt>Minimum version</dt>
<dd>10</dd>
<dt>Configuration</dt>
<dd><a href="../Configuration.html#page_Database-settings">General</a>, <a href="../Configuration.html#page_Database-settings-specific-to-PostgreSQL">Specific</a></dd>
<dd><a href="../Configuration.html#page_Database_settings">General</a>, <a href="../Configuration.html#page_Database_settings_specific_to_PostgreSQL">Specific</a></dd>
</dl>
If for whatever reason an SQLite database does not suit your configuration, PostgreSQL is the best alternative. It is functionally equivalent to SQLite in every way.

2
docs/en/020_Getting_Started/040_Database_Setup/020_MySQL.md

@ -8,7 +8,7 @@
<dt>Minimum version</dt>
<dd>8.0.11</dd>
<dt>Configuration</dt>
<dd><a href="../Configuration.html#page_Database-settings">General</a>, <a href="../Configuration.html#page_Database-settings-specific-to-MySQL">Specific</a></dd>
<dd><a href="../Configuration.html#page_Database_settings">General</a>, <a href="../Configuration.html#page_Database_settings_specific_to_MySQL">Specific</a></dd>
</dl>
While MySQL can be used as a database for The Arsse, this is **not recommended** due to MySQL's technical limitations. It is fully functional, but may fail with some newsfeeds where other database systems do not. Additionally, it is particularly important before upgrading from one version of The Arsse to the next to back up your database: a failure in a database upgrade can corrupt your database much more easily than when using other database systems.

18
docs/en/020_Getting_Started/050_Configuration.md

@ -1,6 +1,14 @@
# The configuration file
The Arsse looks for configuration in a file named `config.php` in the directory where it is installed. For example, if The Arsse is installed at `/usr/share/arsse`, it will look for configuration in the file `/usr/share/arsse/config.php`. It is not an error for this file not to exist or to be empty: The Arsse will function with no configuration whatsoever, provided other conditions allow.
Depending on how The Arsse was installed, it will look for configuration in the following places:
| Installation method | Default configuration file path |
|---------------------|--------------------------------------------------------|
| Arch Linux package | `/etc/webapps/arsse/config.php` |
| Debian package | `/etc/arsse/config.php` |
| Manual installation | `config.php` in the The Arsse's installation directory |
It is not an error for this file not to exist or to be empty: The Arsse will function with no configuration whatsoever, provided other conditions allow.
The configuration file is a PHP script which returns an associative array with keys and values for one or more settings. Any settings which are not specified in the configuration file will be set to their defaults. Invalid values will cause an error on start-up, while unknown keys are ignored. A basic configuration file might look like this:
@ -12,10 +20,10 @@ The configuration file is a PHP script which returns an associative array with k
];
```
The `config.defaults.php` file included with copies of The Arsse contains an annotated listing of every configuration setting with its default value. The settings are also documented in more detail below.
# List of all settings
The `config.defaults.php` file included with copies of The Arsse contains an annotated listing of every configuration setting with its default value. The settings are also documented in more detail below.
## General settings
### lang
@ -321,7 +329,7 @@ It is also possible to specify the fully-qualified name of a class which impleme
The interval the newsfeed fetching service observes between checks for new articles. Note that requests to foreign servers are not necessarily made at this frequency: each newsfeed is assigned its own time at which to be next retrieved. This setting instead defines the length of time the fetching service will sleep between periods of activity.
Consult "[How Often Newsfeeds Are Fetched](/en/Using_The_Arsse/Keeping_Newsfeeds_Up_to_Date#page_Appendix-how-often-newsfeeds-are-fetched)" for details on how often newsfeeds are fetched.
Consult "[How Often Newsfeeds Are Fetched](/en/Using_The_Arsse/Other_Topics#page_How_often_newsfeeds_are_fetched)" for details on newsfeed update frequency.
### serviceQueueWidth
@ -355,7 +363,7 @@ The default value is equal to two megabytes.
|---------|---------|
| boolean | `true` |
Whether to allow the possibility of fetching full article contents from an article's source, if a newsfeed only provides excerpts. Whether fetching will actually happen is governed by a per-newsfeed toggle (defaulting to `false`) which currently can only be changed by manually editing the database.
Whether to allow the possibility of fetching full article contents from an article's source, if a newsfeed only provides excerpts. Whether fetching will actually happen is governed by a per-newsfeed toggle (defaulting to `false`) which currently can only be changed via the Miniflux protocol.
### fetchUserAgentString

3
docs/en/020_Getting_Started/index.md

@ -1,3 +0,0 @@
Presently installing and setting up The Arsse is a manual process. We hope to have pre-configured installation packages available for various operating systems eventually, but for now the pages in this section should help get you up and running.
Though The Arsse itself makes no assumptions about the operating system which hosts it, we use and have the most experience with Debian; the instructions contained here therefore are for Debian systems and will probably either not work with other systems or not be consistent with their conventions. Nevertheless, they should still serve as a useful guide.

22
docs/en/025_Using_The_Arsse/010_Managing_Users.md

@ -9,13 +9,13 @@ This section describes in brief some CLI commands. Please read [the general note
When first installed, The Arsse has no users configured. You may add users by executing the following command:
```sh
sudo -u www-data php arsse.php user add "user@example.com" "example password"
sudo arsse user add "user@example.com" "example password"
```
The password argument is optional: if no password is provided, a random one is generated and printed out:
```console
$ sudo -u www-data php arsse.php user add "jane.doe"
$ sudo arsse user add "jane.doe"
Ji0ivMYqi6gKxQK1MHuE
```
@ -24,13 +24,13 @@ Ji0ivMYqi6gKxQK1MHuE
Setting a user's password is nearly identical to adding a user:
```sh
sudo -u www-data php arsse.php user set-pass "user@example.com" "new password"
sudo arsse user set-pass "user@example.com" "new password"
```
As when adding a user, the password argument is optional: if no password is provided, a random one is generated and printed out:
```console
$ sudo -u www-data php arsse.php user set-pass "jane.doe"
$ sudo arsse user set-pass "jane.doe"
Ummn173XjbJT4J3Gnx0a
```
@ -39,13 +39,13 @@ Ummn173XjbJT4J3Gnx0a
Before a user can make use of [the Fever protocol](/en/Supported_Protocols/Fever), a Fever-specific password for that user must be set. It is _highly recommended_ that this not be the samer as the user's main password. The password can be set by adding the `--fever` option to the normal password-changing command:
```sh
sudo -u www-data php arsse.php user set-pass --fever "user@example.com" "fever password"
sudo arsse user set-pass --fever "user@example.com" "fever password"
```
As when setting a main password, the password argument is optional: if no password is provided, a random one is generated and printed out:
```console
$ sudo -u www-data php arsse.php user set-pass --fever "jane.doe"
$ sudo arsse user set-pass --fever "jane.doe"
YfZJHq4fNTRUKDYhzQdR
```
@ -54,16 +54,16 @@ YfZJHq4fNTRUKDYhzQdR
[Miniflux](/en/Supported_Protocols/Miniflux) clients may optionally log in using tokens: randomly-generated strings which act as persistent passwords. For now these must be generated using the command-line interface:
```console
$ sudo -u www-data php arsse.php token create "jane.doe"
$ sudo arsse token create "jane.doe"
xRK0huUE9KHNHf_x_H8JG0oRDo4t_WV44whBtr8Ckf0=
```
Multiple tokens may be generated for use with different clients, and descriptive labels can be assigned for later identification:
```console
$ sudo -u www-data php arsse.php token create "jane.doe" Newsflash
$ sudo arsse token create "jane.doe" Newsflash
xRK0huUE9KHNHf_x_H8JG0oRDo4t_WV44whBtr8Ckf0=
$ sudo -u www-data php arsse.php token create "jane.doe" Reminiflux
$ sudo arsse token create "jane.doe" Reminiflux
L7asI2X_d-krinGJd1GsiRdFm2o06ZUlgD22H913hK4=
```
@ -76,13 +76,13 @@ Users may also have various metadata properties set. These largely exist for com
The flag may be changed using the following command:
```sh
sudo -u www-data php arsse.php user set "jane.doe" admin true
sudo arsse user set "jane.doe" admin true
```
As a shortcut it is also possible to create administrators directly:
```sh
sudo -u www-data php arsse.php user add "user@example.com" "example password" --admin
sudo arsse user add "user@example.com" "example password" --admin
```
Please consult the integrated help for more details on metadata and their effects.

8
docs/en/025_Using_The_Arsse/020_Importing_and_Exporting.md

@ -9,7 +9,7 @@ This section describes in brief some CLI commands. Please read [the general note
It's possible to import not only newsfeeds but also folders and Fever groups using OPML files. The process is simple:
```sh
sudo -u www-data php arsse.php import "user@example.com" "subscriptions.opml"
sudo arsse import "user@example.com" "subscriptions.opml"
```
The importer is forgiving, but some OPML files may fail, with the reason printed out. Files are either imported in total, or not at all.
@ -19,7 +19,7 @@ The importer is forgiving, but some OPML files may fail, with the reason printed
It's possible to export not only newsfeeds but also folders and Fever groups to OPML files. The process is simple:
```sh
sudo -u www-data php arsse.php export "user@example.com" "subscriptions.opml"
sudo arsse export "user@example.com" "subscriptions.opml"
```
The output might look like this:
@ -46,9 +46,9 @@ Not all protocols supported by The Arsse allow modifying newsfeeds or folders, e
```sh
# export your newsfeeds
sudo -u www-data php arsse.php export "user@example.com" "subscriptions.opml"
sudo arsse export "user@example.com" "subscriptions.opml"
# make any changes you want in your editor of choice
nano "subscriptions.opml"
# re-import the modified information
sudo -u www-data php arsse.php import "user@example.com" "subscriptions.opml" --replace
sudo arsse import "user@example.com" "subscriptions.opml" --replace
```

49
docs/en/025_Using_The_Arsse/030_Keeping_Newsfeeds_Up_to_Date.md

@ -1,49 +0,0 @@
[TOC]
# Preface
In normal operation The Arsse is expected to regularly check whether newsfeeds might have new articles, then fetch them and process them to present new or updated articles to clients. This can be achieved either by having The Arsse operate a persistent background process (termed a [daemon](https://en.wikipedia.org/wiki/Daemon_(computing)) or service), or by using an external scheduler to periodically perform single checks. Normally a daemon is preferred.
There are many ways to administer daemons, and many schedulers can be used. This section outlines a few, but many other arrangements are possible.
# As a daemon via systemd
The Arsse includes a sample systemd service unit file which can be used to quickly get a daemon running with the following procedure:
```sh
# Copy the service unit
sudo cp "/usr/share/arsse/dist/arsse.service" "/etc/systemd/system"
# Modify the unit file if needed
sudoedit "/etc/systemd/system/arsse.service"
# Enable and start the service
sudo systemctl enable --now arsse
```
The Arsse's feed updater can then be manipulated as with any other service. Consult [the `systemctl` manual](https://www.freedesktop.org/software/systemd/man/systemctl.html) for details.
# As a cron job
Keeping newsfeeds updated with [cron](https://en.wikipedia.org/wiki/Cron) is not difficult. Simply run the following command:
```sh
sudo crontab -u www-data -e
```
And add a line such as this one:
```
*/2 * * * * /usr/bin/env php /usr/share/arsse/arsse.php refresh-all
```
Thereafter The Arsse's will be scheduled to check newsfeeds every two minutes. Consult the manual pages for the `crontab` [format](http://man7.org/linux/man-pages/man5/crontab.5.html) and [command](http://man7.org/linux/man-pages/man1/crontab.1.html) for details.
# Appendix: how often newsfeeds are fetched
Though by default The Arsse will wake up every two minutes, newsfeeds are not actually downloaded so frequently. Instead, each newsfeed is assigned a time at which it should next be fetched, and once that time is reached a [conditional request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) is made. The interval between requests for a particular newsfeed can vary from 15 minutes to 24 hours based on multiple factors such as:
- The length of time since the newsfeed last changed
- The interval between publishing of articles in the newsfeed
- Whether the last fetch or last several fetches resulted in error
As a general rule, newsfeeds which change frequently are checked frequently, and those which change seldom are fetched at most daily.

34
docs/en/025_Using_The_Arsse/030_Other_Topics.md

@ -0,0 +1,34 @@
[TOC]
# Preface
This section describes in brief some CLI commands. Please read [the general notes on the command line interface](index) before continuing.
# Refreshing newsfeeds with a cron job
Normally The Arsse has a systemd service which checks newsfeeds for updates and processes them into its database for the user. If for whatever reason this is not practical a [cron](https://en.wikipedia.org/wiki/Cron) job may be used instead.
Keeping newsfeeds updated with cron is not difficult. Simply run the following command:
```sh
sudo crontab -u arsse -e
```
And add a line such as this one:
```
*/2 * * * * /usr/bin/arsse refresh-all
```
Thereafter The Arsse's will be scheduled to check newsfeeds every two minutes. Consult the manual pages for the `crontab` [format](http://man7.org/linux/man-pages/man5/crontab.5.html) and [command](http://man7.org/linux/man-pages/man1/crontab.1.html) for details.
# How often newsfeeds are fetched
Though by default The Arsse will wake up every two minutes, newsfeeds are not actually downloaded so frequently. Instead, each newsfeed is assigned a time at which it should next be fetched, and once that time is reached a [conditional request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) is made. The interval between requests for a particular newsfeed can vary from 15 minutes to 24 hours based on multiple factors such as:
- The length of time since the newsfeed last changed
- The interval between publishing of articles in the newsfeed
- Whether the last fetch or last several fetches resulted in error
As a general rule, newsfeeds which change frequently are checked frequently, and those which change seldom are fetched at most daily.

12
docs/en/025_Using_The_Arsse/040_Upgrading_to_a_New_Version.md

@ -1,12 +0,0 @@
Upgrading The Arsse is usually simple:
1. Download the latest release
2. Check the `UPGRADING` file for any special notes
3. Stop the newsfeed refreshing service if it is running
4. Extract the new version on top of the old one
5. Ensure permissions are still correct
6. Restart the newsfeed refreshing service
By default The Arsse will perform any required database schema upgrades when the new version is executed, and release packages contain all newly required library dependencies.
Occasionally changes to Web server configuration have been required, when new protocols become supported; such changes are always explicit in the `UPGRADING` file

15
docs/en/025_Using_The_Arsse/index.md

@ -2,19 +2,8 @@
This section details a few administrative tasks which may need to be performed after installing The Arsse. As no Web-based administrative interface is included, these tasks are generally performed via command line interface.
Though this section describes some commands briefly, complete documentation of The Arsse's command line interface is not included in this manual. Documentation for CLI commands can instead be viewed with the CLI itself by executing `php arsse.php --help`.
Though this section describes some commands briefly, complete documentation of The Arsse's command line interface is not included in this manual. Documentation for CLI commands can instead be viewed using the system manual service by executing `man arsse`.
# A Note on Command Invocation
Particularly if using an SQLite database, it's important that administrative commands be executed as the same user who owns The Arsse's files. To that end the examples in this section all use the verbose formulation `sudo -u www-data php arsse.php` (with `www-data` being the user under which Web servers run in Debian), but it is possible to simplify invocation to `sudo arsse` if an executable file named `arsse` is created somewhere in the sudo path with the following content:
```php
#! /usr/bin/env php
<?php
if (posix_geteuid() == 0) {
$info = posix_getpwnam("www-data");
posix_setegid($info['gid']);
posix_seteuid($info['uid']);
}
include "/usr/share/arsse/arsse.php";
```
Particularly if using an SQLite database, it's important that administrative commands be executed as the same user who owns The Arsse's files. To that end our releases include an `arsse` executable which drops privileges when executed as root. Commands in this section assume this executable is being used.

2
docs/en/030_Supported_Protocols/005_Miniflux.md

@ -34,13 +34,11 @@ Miniflux version 2.0.28 is emulated, though not all features are implemented
# Differences
- Various error codes and messages differ due to significant implementation differences
- `PUT` requests which return a body respond with `200 OK` rather than `201 Created`
- The "All" category is treated specially (see below for details)
- Feed and category titles consisting only of whitespace are rejected along with the empty string
- Filtering rules may not function identically (see below for details)
- The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked
- Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization
- Querying articles for both read/unread and removed statuses will not return all removed articles
- Search strings will match partial words
- OPML import either succeeds or fails atomically: if one feed fails, no feeds are imported

2
docs/en/030_Supported_Protocols/010_Nextcloud_News.md

@ -10,7 +10,7 @@
<dt>API endpoint</dt>
<dd>/index.php/apps/news/api/v1-2/</dd>
<dt>Specifications</dt>
<dd><a href="https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md">Version 1.2</a></dd>
<dd><a href="https://github.com/nextcloud/news/blob/master/docs/api/api-v1-2.md">Version 1.2</a></dd>
</dl>
The Nextcloud News protocol was the first supported by The Arsse, and has been supported in full since version 0.3.0.

4
docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md

@ -10,7 +10,7 @@
<dt>API endpoint</dt>
<dd>/tt-rss/api</dd>
<dt>Specifications</dt>
<dd><a href="https://git.tt-rss.org/git/tt-rss/wiki/ApiReference">Main</a>, <a href="https://git.tt-rss.org/fox/tt-rss/wiki/SearchSyntax">search syntax</a>, <a href="https://github.com/jangernert/FeedReader/blob/master/data/tt-rss-feedreader-plugin/README.md">FeedReader extensions</a>, <a href="https://github.com/hrk/tt-rss-newsplus-plugin/blob/master/README.md">News+ extension</a></dd>
<dd><a href="https://tt-rss.org/wiki/ApiReference">Main</a>, <a href="https://tt-rss.org/wiki/SearchSyntax">search syntax</a>, <a href="https://github.com/jangernert/FeedReader/blob/master/data/tt-rss-feedreader-plugin/README.md">FeedReader extensions</a>, <a href="https://github.com/hrk/tt-rss-newsplus-plugin/blob/master/README.md">News+ extension</a></dd>
</dl>
The Arsse supports not only the Tiny Tiny RSS protocol, but also extensions required by the FeedReader client and the more commonly supported `getCompactHeadlines` extension.
@ -37,7 +37,7 @@ The Arsse does not currently support the entire protocol. Notably missing featur
- Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways:
- Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"`
- Invalid dates are ignored rather than assumed to be `"1970-01-01"`
- Only a single negative date is allowed (this is a known bug rather than intentional)
- Specifying multiple non-negative dates usually returns no results as articles must match all specified dates simultaneously; The Arsse instead returns articles matching any of the specified dates
- Dates are always relative to UTC
- Full-text search is not yet employed with any database, including PostgreSQL
- Article hashes are normally SHA1; The Arsse uses SHA256 hashes

4
docs/en/040_Compatible_Clients.md

@ -121,14 +121,14 @@ The Arsse does not at this time have any first party clients. However, because T
</td>
</tr>
<tr>
<td><a href="https://reeder.app/">Reeder</a></td>
<td><a href="https://reeder.app/">Reeder 3</a></td>
<td>macOS</td>
<td class="N"></td>
<td class="N"></td>
<td class="N"></td>
<td class="Y"></td>
<td>
<p>Also available for iOS.</p>
<p>Also available for iOS. Reeder 5 no longer supports the Fever protocol.</p>
</td>
</tr>
<tr>

14
lib/AbstractException.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse;
abstract class AbstractException extends \Exception {
@ -14,6 +15,7 @@ abstract class AbstractException extends \Exception {
"Exception.arrayEmpty" => 10002,
"ExceptionType.strictFailure" => 10011,
"ExceptionType.typeUnknown" => 10012,
"Exception.extMissing" => 10021,
"Lang/Exception.defaultFileMissing" => 10101,
"Lang/Exception.fileMissing" => 10102,
"Lang/Exception.fileUnreadable" => 10103,
@ -102,6 +104,18 @@ abstract class AbstractException extends \Exception {
"ImportExport/Exception.invalidFolderCopy" => 10614,
"ImportExport/Exception.invalidTagName" => 10615,
"Rule/Exception.invalidPattern" => 10701,
"Service/Exception.pidNotFile" => 10801,
"Service/Exception.pidDirMissing" => 10802,
"Service/Exception.pidDirUnresolvable" => 10803,
"Service/Exception.pidUnusable" => 10804,
"Service/Exception.pidUnreadable" => 10805,
"Service/Exception.pidUnwritable" => 10806,
"Service/Exception.pidUncreatable" => 10807,
"Service/Exception.pidCorrupt" => 10808,
"Service/Exception.pidDuplicate" => 10809,
"Service/Exception.pidLocked" => 10810,
"Service/Exception.pidInaccessible" => 10811,
"Service/Exception.forkFailed" => 10812,
];
protected $symbol;

33
lib/Arsse.php

@ -4,10 +4,20 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse;
class Arsse {
public const VERSION = "0.9.0";
public const VERSION = "0.10.5";
public const REQUIRED_EXTENSIONS = [
"intl", // as this extension is required to prepare formatted messages, its absence will throw a distinct English-only exception
"dom",
"filter",
"json", // part of the PHP core since version 8.0
"hash", // part of the PHP core since version 7.4
"simplexml", // required by PicoFeed only
"iconv", // required by PicoFeed only
];
/** @var Factory */
public static $obj;
@ -20,6 +30,12 @@ class Arsse {
/** @var User */
public static $user;
/** @codeCoverageIgnore */
public static function bootstrap(): void {
$conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf;
static::load($conf);
}
public static function load(Conf $conf): void {
static::$obj = static::$obj ?? new Factory;
static::$lang = static::$lang ?? new Lang;
@ -28,4 +44,19 @@ class Arsse {
static::$db = static::$db ?? new Database;
static::$user = static::$user ?? new User;
}
/** Checks whether the specified extensions are loaded and throws an exception if any are not */
public static function checkExtensions(string ...$ext): void {
$missing = [];
foreach ($ext as $e) {
if (!extension_loaded($e)) {
$missing[] = $e;
}
}
if ($missing) {
$total = sizeof($missing);
$first = $missing[0];
throw new Exception("extMissing", ['first' => $first, 'total' => $total]);
}
}
}

252
lib/CLI.php

@ -4,232 +4,45 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\Arsse\REST\Fever\User as Fever;
use JKingWeb\Arsse\ImportExport\OPML;
use JKingWeb\Arsse\REST\Miniflux\Token as Miniflux;
use JKingWeb\Arsse\Service\Daemon;
class CLI {
public const USAGE = <<<USAGE_TEXT
Usage:
arsse.php daemon
arsse.php feed refresh-all
arsse.php feed refresh <n>
arsse.php conf save-defaults [<file>]
arsse.php user [list]
arsse.php user add <username> [<password>] [--admin]
arsse.php user remove <username>
arsse.php user show <username>
arsse.php user set <username> <property> <value>
arsse.php user unset <username> <property>
arsse.php user set-pass <username> [<password>]
[--oldpass=<pass>] [--fever]
arsse.php user unset-pass <username>
[--oldpass=<pass>] [--fever]
arsse.php user set-pass <username> [<password>] [--fever]
arsse.php user unset-pass <username> [--fever]
arsse.php user auth <username> <password> [--fever]
arsse.php token list <username>
arsse.php token create <username> [<label>]
arsse.php token revoke <username> [<token>]
arsse.php import <username> [<file>]
[-f | --flat] [-r | --replace]
arsse.php export <username> [<file>]
[-f | --flat]
arsse.php import <username> [<file>] [-f|--flat] [-r|--replace]
arsse.php export <username> [<file>] [-f|--flat]
arsse.php daemon [--fork=PIDFILE]
arsse.php feed refresh-all
arsse.php feed refresh <n>
arsse.php conf save-defaults [<file>]
arsse.php --version
arsse.php -h | --help
arsse.php -h|--help
The Arsse command-line interface can be used to perform various administrative
tasks such as starting the newsfeed refresh service, managing users, and
importing or exporting data.
Commands:
daemon
Starts the newsfeed refreshing service, which will refresh stale feeds at
the configured interval automatically.
feed refresh-all
Refreshes any stale feeds once, then exits. This performs the same
function as the daemon command without looping; this is useful if use of
a scheduler such a cron is preferred over a persitent service.
feed refresh <n>
Refreshes a single feed by numeric ID. This is principally for internal
use as the feed ID numbers are not usually exposed to the user.
conf save-defaults [<file>]
Prints default configuration parameters to standard output, or to <file>
if specified. Each parameter is annotated with a short description of its
purpose and usage.
user [list]
Prints a list of all existing users, one per line.
user add <username> [<password>] [--admin]
Adds the user specified by <username>, with the provided password
<password>. If no password is specified, a random password will be
generated and printed to standard output. The --admin option will make
the user an administrator, which allows them to manage users via the
Miniflux protocol, among other things.
user remove <username>
Removes the user specified by <username>. Data related to the user,
including folders and subscriptions, are immediately deleted. Feeds to
which the user was subscribed will be retained and refreshed until the
configured retention time elapses.
user show <username>
Displays the metadata of a user in a basic tabular format. See below for
details on the various properties displayed.
user set <username> <property> <value>
Sets a user's metadata property to the supplied value. See below for
details on the various properties available.
user unset <username> <property>
Sets a user's metadata property to its default value. See below for
details on the various properties available. What the default value
for a property evaluates to depends on which protocol is used.
user set-pass <username> [<password>]
See the manual page for more details:
Changes <username>'s password to <password>. If no password is specified,
a random password will be generated and printed to standard output.
The --oldpass=<pass> option can be used to supply a user's exiting
password if this is required by the authentication driver to change a
password. Currently this is not used by any existing driver.
The --fever option sets a user's Fever protocol password instead of their
general password. As Fever requires that passwords be stored insecurely,
users do not have Fever passwords by default, and logging in to the Fever
protocol is disabled until a password is set. It is highly recommended
that a user's Fever password be different from their general password.
user unset-pass <username>
Unsets a user's password, effectively disabling their account. As with
password setting, the --oldpass and --fever options may be used.
user auth <username> <password>
Tests logging in as <username> with password <password>. This only checks
that the user's password is correctly recognized; it has no side effects.
The --fever option may be used to test the user's Fever protocol password,
if any.
token list <username>
Lists available tokens for <username> in a simple tabular format. These
tokens act as an alternative means of authentication for the Miniflux
protocol and may be required by some clients. They do not expire.
token create <username> [<label>]
Creates a new login token for <username> and prints it. These tokens act
as an alternative means of authentication for the Miniflux protocol and
may be required by some clients. An optional label may be specified to
give the token a meaningful name.
token revoke <username> [<token>]
Deletes the specified token from the database. The token itself must be
supplied, not its label. If it is omitted all tokens are revoked.
import <username> [<file>]
Imports the feeds, folders, and tags found in the OPML formatted <file>
into the account of <username>. If no file is specified, data is instead
read from standard input.
The --replace option interprets the OPML file as the list of all desired
feeds, folders and tags, performing any deletion or moving of existing
entries which do not appear in the flle. If this option is not specified,
the file is assumed to list desired additions only.
The --flat option can be used to ignore any folder structures in the file,
importing any feeds only into the root folder.
export <username> [<file>]
Exports <username>'s feeds, folders, and tags to the OPML file specified
by <file>, or standard output if none is provided. Note that due to a
limitation of the OPML format, any commas present in tag names will not be
retained in the export.
The --flat option can be used to omit folders from the export. Some OPML
implementations may not support folders, or arbitrary nesting; this option
may be used when planning to import into such software.
User metadata:
User metadata are primarily used by the Miniflux protocol, and most
properties have identical or similar names to those used by Miniflux.
Properties may also affect other protocols, or conversely may have no
effect even when using the Miniflux protocol; this is noted below when
appropriate.
Booleans accept any of the values true/false, 1/0, yes/no, on/off.
The following metadata properties exist for each user:
num
Integer. The numeric identifier of the user. This is assigned at user
creation and is read-only.
admin
Boolean. Whether the user is an administrator. Administrators may
manage other users via the Miniflux protocol, and also may trigger
feed updates manually via the Nextcloud News protocol.
lang
String. The preferred language of the user, as a BCP 47 language tag
e.g. "en-ca". Note that since The Arsse currently only includes
English text it is not used by The Arsse itself, but clients may
use this metadatum in protocols which expose it.
tz
String. The time zone of the user, as a tzdata identifier e.g.
"America/Los_Angeles".
root_folder_name
String. The name of the root folder, in protocols which allow it to
be renamed.
sort_asc
Boolean. Whether the user prefers ascending sort order for articles.
Descending order is usually the default, but explicitly setting this
property false will also make a preference for descending order
explicit.
theme
String. The user's preferred theme. This is not used by The Arsse
itself, but clients may use this metadatum in protocols which expose
it.
page_size
Integer. The user's preferred page size when listing articles. This is
not used by The Arsse itself, but clients may use this metadatum in
protocols which expose it.
shortcuts
Boolean. Whether to enable keyboard shortcuts. This is not used by
The Arsse itself, but clients may use this metadatum in protocols which
expose it.
gestures
Boolean. Whether to enable touch gestures. This is not used by
The Arsse itself, but clients may use this metadatum in protocols which
expose it.
reading_time
Boolean. Whether to calculate and display the estimated reading time
for articles. Currently The Arsse does not calculate reading time, so
changing this will likely have no effect.
stylesheet
String. A user CSS stylesheet. This is not used by The Arsse itself,
but clients may use this metadatum in protocols which expose it.
man arsse
USAGE_TEXT;
protected function usage($prog): string {
@ -249,8 +62,7 @@ USAGE_TEXT;
/** @codeCoverageIgnore */
protected function loadConf(): bool {
$conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf;
Arsse::load($conf);
Arsse::bootstrap();
return true;
}
@ -268,11 +80,15 @@ USAGE_TEXT;
'help' => false,
]);
try {
// ensure the require extensions are loaded
Arsse::checkExtensions(...Arsse::REQUIRED_EXTENSIONS);
// reconstitute multi-token commands (e.g. user add) into a single string
$cmd = $this->command($args);
if ($cmd && !in_array($cmd, ["", "conf save-defaults"])) {
// only certain commands don't require configuration to be loaded
if ($cmd && !in_array($cmd, ["", "conf save-defaults", "daemon"])) {
// only certain commands don't require configuration to be loaded; daemon loads configuration after forking (if applicable)
$this->loadConf();
}
// run the requested command
switch ($cmd) {
case "":
if ($args['--version']) {
@ -282,7 +98,12 @@ USAGE_TEXT;
}
return 0;
case "daemon":
Arsse::$obj->get(Service::class)->watch(true);
if ($args['--fork'] !== null) {
return $this->serviceFork($args['--fork']);
} else {
$this->loadConf();
Arsse::$obj->get(Service::class)->watch(true);
}
return 0;
case "feed refresh":
return (int) !Arsse::$db->feedUpdate((int) $args['<n>'], true);
@ -323,14 +144,14 @@ USAGE_TEXT;
}
return 0;
} else {
return $this->userAddOrSetPassword("passwordSet", $args["<username>"], $args["<password>"], $args["--oldpass"]);
return $this->userAddOrSetPassword("passwordSet", $args["<username>"], $args["<password>"]);
}
// no break
case "user unset-pass":
if ($args['--fever']) {
Arsse::$obj->get(Fever::class)->unregister($args["<username>"]);
} else {
Arsse::$user->passwordUnset($args["<username>"], $args["--oldpass"]);
Arsse::$user->passwordUnset($args["<username>"]);
}
return 0;
case "user remove":
@ -360,6 +181,23 @@ USAGE_TEXT;
fwrite(STDERR, $msg.\PHP_EOL);
}
protected function serviceFork(string $pidfile): int {
// initialize the object factory
Arsse::$obj = Arsse::$obj ?? new Factory;
// create a Daemon object which contains various helper functions
$daemon = Arsse::$obj->get(Daemon::class);
// resolve the PID file to its absolute path; this also checks its readability and writability
$pidfile = $daemon->checkPIDFilePath($pidfile);
// daemonize
$daemon->fork($pidfile);
// start the fetching service as normal
$this->loadConf();
Arsse::$obj->get(Service::class)->watch(true);
// after the service has been shut down, delete the PID file and exit cleanly
unlink($pidfile);
return 0;
}
protected function userAddOrSetPassword(string $method, string $user, string $password = null, string $oldpass = null): int {
$passwd = Arsse::$user->$method(...array_slice(func_get_args(), 1));
if (is_null($password)) {

5
lib/Conf.php

@ -5,14 +5,17 @@
/** Conf class */
declare(strict_types=1);
namespace JKingWeb\Arsse;
use AllowDynamicProperties;
use JKingWeb\Arsse\Misc\ValueInfo as Value;
/** Class for loading, saving, and querying configuration
*
* The Conf class serves both as a means of importing and querying configuration information, as well as a source for default parameters when a configuration file does not specify a value.
* All public properties are configuration parameters that may be set by the server administrator. */
#[AllowDynamicProperties]
class Conf {
/** @var string Default language to use for logging and errors */
public $lang = "en";
@ -265,7 +268,7 @@ class Conf {
protected function propertyImport(string $key, $value, string $file = "") {
$typeName = $this->types[$key]['name'] ?? "mixed";
$typeConst = $this->types[$key]['const'] ?? Value::T_MIXED;
$nullable = (int) (bool) ($this->types[$key]['const'] & Value::M_NULL);
$nullable = (int) (bool) ($typeConst & Value::M_NULL);
try {
if ($typeName === "\\DateInterval") {
// date intervals have special handling: if the existing value (ultimately, the default value)

1
lib/Conf/Exception.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Conf;
class Exception extends \JKingWeb\Arsse\AbstractException {

28
lib/Context/AbstractContext.php

@ -0,0 +1,28 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
abstract class AbstractContext {
protected $props = [];
protected $parent = null;
protected function act(string $prop, int $set, $value) {
if ($set) {
if (is_null($value)) {
unset($this->props[$prop]);
$this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop];
} else {
$this->props[$prop] = true;
$this->$prop = $value;
}
return $this->parent ?? $this;
} else {
return isset($this->props[$prop]);
}
}
}

36
lib/Context/BooleanMembers.php

@ -0,0 +1,36 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
trait BooleanMembers {
public $unread = null;
public $starred = null;
public $hidden = null;
public $labelled = null;
public $annotated = null;
public function unread(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function starred(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function hidden(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelled(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function annotated(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
}

41
lib/Context/Context.php

@ -4,18 +4,15 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
class Context extends ExclusionContext {
class Context extends RootContext {
use BooleanMembers;
use ExclusionMembers;
/** @var ExclusionContext */
public $not;
public $limit = 0;
public $offset = 0;
public $unread;
public $starred;
public $hidden;
public $labelled;
public $annotated;
public function __construct() {
$this->not = new ExclusionContext($this);
@ -30,32 +27,4 @@ class Context extends ExclusionContext {
public function __destruct() {
unset($this->not);
}
public function limit(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function offset(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function unread(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function starred(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function hidden(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelled(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function annotated(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
}

251
lib/Context/ExclusionContext.php

@ -4,55 +4,21 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\Date;
class ExclusionContext {
public $folder;
public $folders;
public $folderShallow;
public $foldersShallow;
public $tag;
public $tags;
public $tagName;
public $tagNames;
public $subscription;
public $subscriptions;
public $edition;
public $editions;
public $article;
public $articles;
public $label;
public $labels;
public $labelName;
public $labelNames;
public $annotationTerms;
public $searchTerms;
public $titleTerms;
public $authorTerms;
public $oldestArticle;
public $latestArticle;
public $oldestEdition;
public $latestEdition;
public $modifiedSince;
public $notModifiedSince;
public $markedSince;
public $notMarkedSince;
namespace JKingWeb\Arsse\Context;
protected $props = [];
protected $parent;
class ExclusionContext extends AbstractContext {
use ExclusionMembers;
public function __construct(self $c = null) {
$this->parent = $c;
public function __construct(Context $parent = null) {
$this->parent = $parent;
}
public function __clone() {
// if the context was cloned because its parent was cloned, change the parent to the clone
if ($this->parent) {
$t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1];
if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") {
if (($t['object'] ?? null) instanceof Context && $t['function'] === "__clone") {
$this->parent = $t['object'];
}
}
@ -62,209 +28,4 @@ class ExclusionContext {
public function __destruct() {
unset($this->parent);
}
protected function act(string $prop, int $set, $value) {
if ($set) {
if (is_null($value)) {
unset($this->props[$prop]);
$this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop];
} else {
$this->props[$prop] = true;
$this->$prop = $value;
}
return $this->parent ?? $this;
} else {
return isset($this->props[$prop]);
}
}
protected function cleanIdArray(array $spec, bool $allowZero = false): array {
$spec = array_values($spec);
for ($a = 0; $a < sizeof($spec); $a++) {
if (ValueInfo::id($spec[$a], $allowZero)) {
$spec[$a] = (int) $spec[$a];
} else {
$spec[$a] = null;
}
}
return array_values(array_unique(array_filter($spec, function($v) {
return !is_null($v);
})));
}
protected function cleanStringArray(array $spec): array {
$spec = array_values($spec);
$stop = sizeof($spec);
for ($a = 0; $a < $stop; $a++) {
if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) {
$spec[$a] = $str;
} else {
unset($spec[$a]);
}
}
return array_values(array_unique($spec));
}
public function folder(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folders(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folderShallow(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function foldersShallow(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tag(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tags(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscription(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscriptions(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function edition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function article(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function editions(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function articles(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function label(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labels(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function annotationTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function searchTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function titleTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function authorTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function latestArticle(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function oldestArticle(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function latestEdition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function oldestEdition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function modifiedSince($spec = null) {
$spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function notModifiedSince($spec = null) {
$spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function markedSince($spec = null) {
$spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function notMarkedSince($spec = null) {
$spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
}

262
lib/Context/ExclusionMembers.php

@ -0,0 +1,262 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\Date;
trait ExclusionMembers {
public $folder = null;
public $folders = [];
public $folderShallow = null;
public $foldersShallow = [];
public $tag = null;
public $tags = [];
public $tagName = null;
public $tagNames = [];
public $subscription = null;
public $subscriptions = [];
public $edition = null;
public $editions = [];
public $article = null;
public $articles = [];
public $label = null;
public $labels = [];
public $labelName = null;
public $labelNames = [];
public $annotationTerms = [];
public $searchTerms = [];
public $titleTerms = [];
public $authorTerms = [];
public $articleRange = [null, null];
public $editionRange = [null, null];
public $modifiedRange = [null, null];
public $modifiedRanges = [];
public $markedRange = [null, null];
public $markedRanges = [];
protected function cleanIdArray(array $spec, bool $allowZero = false): array {
$spec = array_values($spec);
for ($a = 0; $a < sizeof($spec); $a++) {
if (ValueInfo::id($spec[$a], $allowZero)) {
$spec[$a] = (int) $spec[$a];
} else {
$spec[$a] = null;
}
}
return array_values(array_unique(array_filter($spec, function($v) {
return !is_null($v);
})));
}
protected function cleanStringArray(array $spec): array {
$spec = array_values($spec);
$stop = sizeof($spec);
for ($a = 0; $a < $stop; $a++) {
if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) {
$spec[$a] = $str;
} else {
unset($spec[$a]);
}
}
return array_values(array_unique($spec));
}
protected function cleanDateRangeArray(array $spec): array {
$spec = array_values($spec);
$stop = sizeof($spec);
for ($a = 0; $a < $stop; $a++) {
if (!is_array($spec[$a]) || sizeof($spec[$a]) !== 2) {
unset($spec[$a]);
} else {
$spec[$a] = ValueInfo::normalize($spec[$a], ValueInfo::T_DATE | ValueInfo::M_ARRAY | ValueInfo::M_DROP);
if ($spec[$a] === [null, null]) {
unset($spec[$a]);
}
}
}
return array_values(array_unique($spec, \SORT_REGULAR));
}
public function folder(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folders(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folderShallow(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function foldersShallow(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tag(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tags(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscription(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscriptions(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function edition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function article(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function editions(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function articles(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function label(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labels(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function annotationTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function searchTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function titleTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function authorTerms(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function articleRange(?int $start = null, ?int $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [$start, $end];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function editionRange(?int $start = null, ?int $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [$start, $end];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function modifiedRange($start = null, $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [Date::normalize($start), Date::normalize($end)];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function modifiedRanges(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanDateRangeArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function markedRange($start = null, $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [Date::normalize($start), Date::normalize($end)];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function markedRanges(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanDateRangeArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
}

21
lib/Context/RootContext.php

@ -0,0 +1,21 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
abstract class RootContext extends AbstractContext {
public $limit = 0;
public $offset = 0;
public function limit(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function offset(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
}

51
lib/Context/UnionContext.php

@ -0,0 +1,51 @@
<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
class UnionContext extends RootContext implements \ArrayAccess, \Countable, \IteratorAggregate {
protected $contexts = [];
#[\ReturnTypeWillChange]
public function offsetExists($offset) {
return isset($this->contexts[$offset]);
}
#[\ReturnTypeWillChange]
public function offsetGet($offset) {
return $this->contexts[$offset] ?? null;
}
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value) {
assert($value instanceof RootContext, new \Exception("Union contexts may only contain other non-exclusion contexts"));
if (isset($offset)) {
$this->contexts[$offset] = $value;
} else {
$this->contexts[] = $value;
}
}
#[\ReturnTypeWillChange]
public function offsetUnset($offset) {
unset($this->contexts[$offset]);
}
public function count(): int {
return count($this->contexts);
}
public function getIterator(): \Traversable {
foreach ($this->contexts as $k => $c) {
yield $k => $c;
}
}
public function __construct(RootContext ...$context) {
$this->contexts = $context;
}
}

621
lib/Database.php

@ -4,13 +4,17 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse;
use JKingWeb\DrUUID\UUID;
use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\Context\RootContext;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\QueryFilter;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\Misc\URL;
use JKingWeb\Arsse\Rule\Rule;
@ -41,7 +45,8 @@ use JKingWeb\Arsse\Rule\Exception as RuleException;
* concerns, will typically follow different conventions.
*
* Note that operations on users should be performed with the User class rather
* than the Database class directly. This is to allow for alternate user sources.
* than the Database class directly. This is to allow for alternate user
* databases e.g. LDAP, although not such support for alternatives exists yet.
*/
class Database {
/** The version number of the latest schema the interface is aware of */
@ -271,10 +276,14 @@ class Database {
}
$hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : "";
// NOTE: This roundabout construction (with 'select' rather than 'values') is required by MySQL, because MySQL is riddled with pitfalls and exceptions
$this->db->prepare("INSERT INTO arsse_users(id,password,num) select ?, ?, ((select max(num) from arsse_users) + 1)", "str", "str")->runArray([$user,$hash]);
$this->db->prepare("INSERT INTO arsse_users(id,password,num) select ?, ?, (coalesce((select max(num) from arsse_users), 0) + 1)", "str", "str")->runArray([$user,$hash]);
return true;
}
/** Renames a user
*
* This does not have an effect on their numeric ID, but has a cascading effect on many tables
*/
public function userRename(string $user, string $name): bool {
if ($user === $name) {
return false;
@ -328,6 +337,11 @@ class Database {
return true;
}
/** Retrieves any metadata associated with a user
*
* @param string $user The user whose metadata is to be retrieved
* @param bool $includeLarge Whether to include values which can be arbitrarily large text
*/
public function userPropertiesGet(string $user, bool $includeLarge = true): array {
$basic = $this->db->prepare("SELECT num, admin from arsse_users where id = ?", "str")->run($user)->getRow();
if (!$basic) {
@ -345,6 +359,11 @@ class Database {
return $meta;
}
/** Set one or more metadata properties for a user
*
* @param string $user The user whose metadata is to be sedt
* @param array $data An associative array of property names and values
*/
public function userPropertiesSet(string $user, array $data): bool {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
@ -529,22 +548,27 @@ class Database {
// check to make sure the parent exists, if one is specified
$parent = $this->folderValidateId($user, $parent)['id'];
$q = new Query(
"SELECT
"WITH RECURSIVE
folders as (
select id from arsse_folders where owner = ? and coalesce(parent,0) = ? union all select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id
)
select
id,
name,
arsse_folders.parent as parent,
coalesce(children,0) as children,
coalesce(feeds,0) as feeds
FROM arsse_folders
left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id
left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id"
from arsse_folders
left join (select parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id
left join (select folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id",
["str", "strict int"],
[$user, $parent]
);
if (!$recursive) {
$q->setWhere("owner = ?", "str", $user);
$q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent);
} else {
$q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union all select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]);
$q->setWhere("id in (SELECT id from folders)");
$q->setWhere("id in (select id from folders)");
}
$q->setOrder("name");
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
@ -679,14 +703,14 @@ class Database {
$p = $this->db->prepareArray(
"WITH RECURSIVE
target as (
SELECT ? as userid, ? as source, ? as dest, ? as new_name
select ? as userid, ? as source, ? as dest, ? as new_name
),
folders as (
SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source
select id from arsse_folders join target on owner = userid and coalesce(parent,0) = source
union all
select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id
)
SELECT
select
case when
((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0)))
then 1 else 0 end as extant,
@ -791,9 +815,15 @@ class Database {
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
$integer = $this->db->sqlToken("integer");
$q = new Query(
"SELECT
"WITH RECURSIVE
topmost(f_id, top) as (
select id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id
),
folders(folder) as (
select ? union all select id from arsse_folders join folders on parent = folder
)
select
s.id as id,
s.feed as feed,
f.url,source,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,f.etag,s.scrape,
@ -806,7 +836,7 @@ class Database {
folder, t.top as top_folder, d.name as folder_name, dt.name as top_folder_name,
coalesce(s.title, f.title) as title,
coalesce((articles - hidden - marked), coalesce(articles,0)) as unread
FROM arsse_subscriptions as s
from arsse_subscriptions as s
join arsse_feeds as f on f.id = s.feed
left join topmost as t on t.f_id = s.folder
left join arsse_folders as d on s.folder = d.id
@ -823,23 +853,21 @@ class Database {
select
subscription,
sum(hidden) as hidden,
sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
sum(case when \"read\" = 1 and hidden = 0 then 1 else 0 end) as marked
from arsse_marks group by subscription
) as mark_stats on mark_stats.subscription = s.id"
) as mark_stats on mark_stats.subscription = s.id",
["str", "int"],
[$user, $folder]
);
$q->setWhere("s.owner = ?", ["str"], [$user]);
$nocase = $this->db->sqlToken("nocase");
$q->setOrder("pinned desc, coalesce(s.title, f.title) collate $nocase");
// topmost folders belonging to the user
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
if ($id) {
// if an ID is specified, add a suitable WHERE condition and bindings
// this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
$q->setWhere("s.id = ?", "int", $id);
} elseif ($folder && $recursive) {
// if a folder is specified and we're listing recursively, add a common table expression to list it and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union all select id from arsse_folders join folders on parent = folder", "int", $folder);
// add a suitable WHERE condition
// if a folder is specified and we're listing recursively, add a suitable WHERE condition
$q->setWhere("folder in (select folder from folders)");
} elseif (!$recursive) {
// if we're not listing recursively, match against only the specified folder (even if it is null)
@ -857,12 +885,18 @@ class Database {
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
$q = new Query("SELECT count(*) from arsse_subscriptions");
$q = new Query(
"WITH RECURSIVE
folders(folder) as (
select ? union all select id from arsse_folders join folders on parent = folder
)
select count(*) from arsse_subscriptions",
["int"],
[$folder]
);
$q->setWhere("owner = ?", "str", $user);
if ($folder) {
// if the specified folder exists, add a common table expression to list it and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union all select id from arsse_folders join folders on parent = folder", "int", $folder);
// add a suitable WHERE condition
// if the specified folder exists, add a suitable WHERE condition
$q->setWhere("folder in (select folder from folders)");
}
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
@ -1302,12 +1336,12 @@ class Database {
"UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ?, icon = ? WHERE id = ?",
["str", "str", "datetime", "strict str", "datetime", "int", "int", "int"]
)->run(
$feed->data->title,
$feed->data->siteUrl,
$feed->title,
$feed->siteUrl,
$feed->lastModified,
$feed->resource->getEtag(),
$feed->etag,
$feed->nextFetch,
sizeof($feed->data->items),
sizeof($feed->items),
$icon,
$feedID
);
@ -1449,6 +1483,7 @@ class Database {
*/
protected function articleColumns(): array {
$greatest = $this->db->sqlToken("greatest");
$least = $this->db->sqlToken("least");
return [
'id' => "arsse_articles.id", // The article's unchanging numeric ID
'edition' => "latest_editions.edition", // The article's numeric ID which increases each time it is modified in the feed
@ -1468,6 +1503,8 @@ class Database {
'hidden' => "coalesce(arsse_marks.hidden,0)", // Whether the article is hidden
'starred' => "coalesce(arsse_marks.starred,0)", // Whether the article is starred
'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", // Whether the article is unread
'labelled' => "$least(coalesce(label_stats.assigned,0),1)", // Whether the article has at least one label
'annotated' => "(case when coalesce(arsse_marks.note,'') <> '' then 1 else 0 end)", // Whether the article has a note
'note' => "coalesce(arsse_marks.note,'')", // The article's note, if any
'published_date' => "arsse_articles.published", // The date at which the article was first published i.e. its creation date
'edited_date' => "arsse_articles.edited", // The date at which the article was last edited according to the feed
@ -1484,33 +1521,11 @@ class Database {
* If an empty column list is supplied, a count of articles matching the context is queried instead
*
* @param string $user The user whose articles are to be queried
* @param Context $context The search context
* @param RootContext $context The search context
* @param array $cols The columns to request in the result set
*/
protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query {
// validate input
if ($context->subscription()) {
$this->subscriptionValidateId($user, $context->subscription);
}
if ($context->folder()) {
$this->folderValidateId($user, $context->folder);
}
if ($context->folderShallow()) {
$this->folderValidateId($user, $context->folderShallow);
}
if ($context->edition()) {
$this->articleValidateEdition($user, $context->edition);
}
if ($context->article()) {
$this->articleValidateId($user, $context->article);
}
if ($context->label()) {
$this->labelValidateId($user, $context->label, false);
}
if ($context->labelName()) {
$this->labelValidateId($user, $context->labelName, true);
}
// prepare the output column list; the column definitions are also used later
protected function articleQuery(string $user, RootContext $context, array $cols = ["id"]): Query {
// prepare the output column list; the column definitions are also used for ordering
$colDefs = $this->articleColumns();
if (!$cols) {
// if no columns are specified return a count; don't borther with sorting
@ -1534,7 +1549,23 @@ class Database {
assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist"));
// define the basic query, to which we add lots of stuff where necessary
$q = new Query(
"SELECT
"WITH RECURSIVE
folders(id,req) as (
select 0, 0 union all select f.id, f.id from arsse_folders as f where owner = ? union all select f1.id, req from arsse_folders as f1 join folders on coalesce(parent,0)=folders.id
),
folders_top(id,top) as (
select f.id, f.id from arsse_folders as f where owner = ? and parent is null union all select f.id, top from arsse_folders as f join folders_top as t on parent=t.id
),
folder_data(id,name,top,top_name) as (
select f1.id, f1.name, top, f2.name from arsse_folders as f1 join folders_top as f0 on f1.id = f0.id join arsse_folders as f2 on f2.id = top
),
labelled(article,label_id,label_name) as (
select m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1
),
tagged(subscription,tag_id,tag_name) as (
select m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1
)
select
$outColumns
from arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ?
@ -1543,195 +1574,181 @@ class Database {
left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id
left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id
join (
SELECT article, max(id) as edition from arsse_editions group by article
select article, max(id) as edition from arsse_editions group by article
) as latest_editions on arsse_articles.id = latest_editions.article
left join (
SELECT arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article
select arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article
) as label_stats on label_stats.article = arsse_articles.id",
["str", "str"],
[$user, $user]
["str", "str", "str", "str", "str", "str"],
[$user, $user, $user, $user, $user, $user]
);
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
$q->setCTE("folder_data(id,name,top,top_name)", "SELECT f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top");
$q->setLimit($context->limit, $context->offset);
if ($context instanceof UnionContext) {
// if the context is a union context, we compute each context in turn
$q->setWhereRestrictive(false);
foreach ($context as $c) {
$q->setWhereGroup($this->articleFilter($c));
}
} else {
// if the context is not a union, first validate input to catch 404s and the like
if ($context->subscription()) {
$this->subscriptionValidateId($user, $context->subscription);
}
if ($context->folder()) {
$this->folderValidateId($user, $context->folder);
}
if ($context->folderShallow()) {
$this->folderValidateId($user, $context->folderShallow);
}
if ($context->edition()) {
$this->articleValidateEdition($user, $context->edition);
}
if ($context->article()) {
$this->articleValidateId($user, $context->article);
}
if ($context->label()) {
$this->labelValidateId($user, $context->label, false);
}
if ($context->labelName()) {
$this->labelValidateId($user, $context->labelName, true);
}
// ensure any used array-type context options contain at least one member
foreach ([
"articles",
"editions",
"subscriptions",
"folders",
"foldersShallow",
"labels",
"labelNames",
"tags",
"tagNames",
"searchTerms",
"titleTerms",
"authorTerms",
"annotationTerms",
"modifiedRanges",
"markedRanges",
] as $m) {
if ($context->$m() && !$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]);
}
}
// next compute the context, supplying the query to manipulate directly
$this->articleFilter($context, $q);
}
// return the query
return $q;
}
protected function articleFilter(Context $context, QueryFilter $q = null) {
$q = $q ?? new QueryFilter;
$colDefs = $this->articleColumns();
// handle the simple context options
$options = [
// each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation
"edition" => ["edition", "=", "int", ""],
"editions" => ["edition", "in", "int", ""],
"article" => ["id", "=", "int", ""],
"articles" => ["id", "in", "int", ""],
"oldestArticle" => ["id", ">=", "int", "latestArticle"],
"latestArticle" => ["id", "<=", "int", "oldestArticle"],
"oldestEdition" => ["edition", ">=", "int", "latestEdition"],
"latestEdition" => ["edition", "<=", "int", "oldestEdition"],
"modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"],
"notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"],
"markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"],
"notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"],
"folderShallow" => ["folder", "=", "int", ""],
"foldersShallow" => ["folder", "in", "int", ""],
"subscription" => ["subscription", "=", "int", ""],
"subscriptions" => ["subscription", "in", "int", ""],
"unread" => ["unread", "=", "bool", ""],
"starred" => ["starred", "=", "bool", ""],
"hidden" => ["hidden", "=", "bool", ""],
// each context array consists of a column identifier (see $colDefs above), a comparison operator, and a data type; the "between" operator has special handling
"edition" => ["edition", "=", "int"],
"editions" => ["edition", "in", "int"],
"article" => ["id", "=", "int"],
"articles" => ["id", "in", "int"],
"articleRange" => ["id", "between", "int"],
"editionRange" => ["edition", "between", "int"],
"modifiedRange" => ["modified_date", "between", "datetime"],
"markedRange" => ["marked_date", "between", "datetime"],
"folderShallow" => ["folder", "=", "int"],
"foldersShallow" => ["folder", "in", "int"],
"subscription" => ["subscription", "=", "int"],
"subscriptions" => ["subscription", "in", "int"],
"unread" => ["unread", "=", "bool"],
"starred" => ["starred", "=", "bool"],
"hidden" => ["hidden", "=", "bool"],
"labelled" => ["labelled", "=", "bool"],
"annotated" => ["annotated", "=", "bool"],
];
foreach ($options as $m => [$col, $op, $type, $pair]) {
if (!$context->$m()) {
// context is not being used
continue;
} elseif (is_array($context->$m)) {
// context option is an array of values
if (!$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
}
[$clause, $types, $values] = $this->generateIn($context->$m, $type);
$q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values);
} elseif ($pair && $context->$pair()) {
// option is paired with another which is also being used
if ($op === ">=") {
$q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]);
foreach ($options as $m => [$col, $op, $type]) {
if ($context->$m()) {
if ($op === "between") {
// option is a range
if ($context->$m[0] === null) {
// range is open at the low end
$q->setWhere("{$colDefs[$col]} <= ?", $type, $context->$m[1]);
} elseif ($context->$m[1] === null) {
// range is open at the high end
$q->setWhere("{$colDefs[$col]} >= ?", $type, $context->$m[0]);
} else {
// range is bounded in both directions
$q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m);
}
} elseif (is_array($context->$m)) {
// context option is an array of values
[$clause, $types, $values] = $this->generateIn($context->$m, $type);
$q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values);
} else {
// option has already been paired
continue;
$q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m);
}
} else {
$q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m);
}
}
// further handle exclusionary options if specified
foreach ($options as $m => [$col, $op, $type, $pair]) {
if (!method_exists($context->not, $m) || !$context->not->$m()) {
// context option is not being used
continue;
} elseif (is_array($context->not->$m)) {
if (!$context->not->$m) {
// for exclusions we don't care if the array is empty
continue;
}
[$clause, $types, $values] = $this->generateIn($context->not->$m, $type);
$q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values);
} elseif ($pair && $context->not->$pair()) {
// option is paired with another which is also being used
if ($op === ">=") {
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]);
// handle the exclusionary version
if (method_exists($context->not, $m) && $context->not->$m()) {
if ($op === "between") {
// option is a range
if ($context->not->$m[0] === null) {
// range is open at the low end
$q->setWhereNot("{$colDefs[$col]} <= ?", $type, $context->not->$m[1]);
} elseif ($context->not->$m[1] === null) {
// range is open at the high end
$q->setWhereNot("{$colDefs[$col]} >= ?", $type, $context->not->$m[0]);
} else {
// range is bounded in both directions
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m);
}
} elseif (is_array($context->not->$m)) {
if (!$context->not->$m) {
// for exclusions we don't care if the array is empty
continue;
}
[$clause, $types, $values] = $this->generateIn($context->not->$m, $type);
$q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values);
} else {
// option has already been paired
continue;
$q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m);
}
} else {
$q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m);
}
}
// handle labels and tags
// handle folder trees, labels, and tags
$options = [
'label' => [
'match_col' => "arsse_articles.id",
'cte_name' => "labelled",
'cte_cols' => ["article", "label_id", "label_name"],
'cte_body' => "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1",
'cte_types' => ["str"],
'cte_values' => [$user],
'options' => [
'label' => ['use_name' => false, 'multi' => false],
'labels' => ['use_name' => false, 'multi' => true],
'labelName' => ['use_name' => true, 'multi' => false],
'labelNames' => ['use_name' => true, 'multi' => true],
],
],
'tag' => [
'match_col' => "arsse_subscriptions.id",
'cte_name' => "tagged",
'cte_cols' => ["subscription", "tag_id", "tag_name"],
'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1",
'cte_types' => ["str"],
'cte_values' => [$user],
'options' => [
'tag' => ['use_name' => false, 'multi' => false],
'tags' => ['use_name' => false, 'multi' => true],
'tagName' => ['use_name' => true, 'multi' => false],
'tagNames' => ['use_name' => true, 'multi' => true],
],
],
// each context array consists of a common table expression to select from, the column to match in the main join, the column to match in the CTE, the column to select in the CTE, an operator, and a type for the match in the CTE
'folder' => ["folders", "folder", "folders.id", "req", "=", "int"],
'folders' => ["folders", "folder", "folders.id", "req", "in", "int"],
'label' => ["labelled", "id", "labelled.article", "label_id", "=", "int"],
'labels' => ["labelled", "id", "labelled.article", "label_id", "in", "int"],
'labelName' => ["labelled", "id", "labelled.article", "label_name", "=", "str"],
'labelNames' => ["labelled", "id", "labelled.article", "label_name", "in", "str"],
'tag' => ["tagged", "subscription", "tagged.subscription", "tag_id", "=", "int"],
'tags' => ["tagged", "subscription", "tagged.subscription", "tag_id", "in", "int"],
'tagName' => ["tagged", "subscription", "tagged.subscription", "tag_name", "=", "str"],
'tagNames' => ["tagged", "subscription", "tagged.subscription", "tag_name", "in", "str"],
];
foreach ($options as $opt) {
$seen = false;
$match = $opt['match_col'];
$table = $opt['cte_name'];
foreach ($opt['options'] as $m => $props) {
$named = $props['use_name'];
$multi = $props['multi'];
$selection = $opt['cte_cols'][0];
$col = $opt['cte_cols'][$named ? 2 : 1];
if ($context->$m()) {
$seen = true;
if (!$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
}
if ($multi) {
[$test, $types, $values] = $this->generateIn($context->$m, $named ? "str" : "int");
$test = "in ($test)";
} else {
$test = "= ?";
$types = $named ? "str" : "int";
$values = $context->$m;
}
$q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values);
foreach ($options as $m => [$cte, $outerCol, $selection, $innerCol, $op, $type]) {
if ($context->$m()) {
if ($op === "in") {
[$inClause, $inTypes, $inValues] = $this->generateIn($context->$m, $type);
$q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues);
} else {
$q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->$m);
}
if ($context->not->$m()) {
$seen = true;
if ($multi) {
[$test, $types, $values] = $this->generateIn($context->not->$m, $named ? "str" : "int");
$test = "in ($test)";
} else {
$test = "= ?";
$types = $named ? "str" : "int";
$values = $context->not->$m;
}
// handle the exclusionary version
if ($context->not->$m()) {
if ($op === "in") {
if (!$context->not->$m) {
// for exclusions we don't care if the array is empty
continue;
}
$q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values);
[$inClause, $inTypes, $inValues] = $this->generateIn($context->not->$m, $type);
$q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues);
} else {
$q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->not->$m);
}
}
if ($seen) {
$spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")";
$q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']);
}
}
// handle complex context options
if ($context->annotated()) {
$comp = ($context->annotated) ? "<>" : "=";
$q->setWhere("coalesce(arsse_marks.note,'') $comp ''");
}
if ($context->labelled()) {
// any label (true) or no label (false)
$op = $context->labelled ? ">" : "=";
$q->setWhere("coalesce(label_stats.assigned,0) $op 0");
}
if ($context->folder()) {
// add a common table expression to list the folder and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union all select id from arsse_folders join folders on coalesce(parent,0) = folder", "int", $context->folder);
// limit subscriptions to the listed folders
$q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)");
}
if ($context->folders()) {
[$inClause, $inTypes, $inValues] = $this->generateIn($context->folders, "int");
// add a common table expression to list the folders and their children so that we select from the entire subtree
$q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]);
// limit subscriptions to the listed folders
$q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)");
}
if ($context->not->folder()) {
// add a common table expression to list the folder and its children so that we exclude from the entire subtree
$q->setCTE("folders_excluded(folder)", "SELECT ? union all select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder);
// excluded any subscriptions in the listed folders
$q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)");
}
if ($context->not->folders()) {
[$inClause, $inTypes, $inValues] = $this->generateIn($context->not->folders, "int");
// add a common table expression to list the folders and their children so that we select from the entire subtree
$q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]);
// limit subscriptions to the listed folders
$q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)");
}
// handle text-matching context options
$options = [
@ -1741,29 +1758,56 @@ class Database {
"annotationTerms" => ["note"],
];
foreach ($options as $m => $columns) {
if (!$context->$m()) {
continue;
} elseif (!$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
}
$columns = array_map(function($c) use ($colDefs) {
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
return $colDefs[$c];
}, $columns);
$q->setWhere(...$this->generateSearch($context->$m, $columns));
if ($context->$m()) {
$q->setWhere(...$this->generateSearch($context->$m, $columns));
}
// handle the exclusionary version
if ($context->not->$m() && $context->not->$m) {
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
}
}
// further handle exclusionary text-matching context options
foreach ($options as $m => $columns) {
if (!$context->not->$m() || !$context->not->$m) {
continue;
// handle arrays of ranges
$options = [
'modifiedRanges' => ["modified_date", "datetime"],
'markedRanges' => ["marked_date", "datetime"],
];
foreach ($options as $m => [$col, $type]) {
if ($context->$m()) {
$subq = (new QueryFilter)->setWhereRestrictive(false);
foreach ($context->$m as $r) {
if ($r[0] === null) {
// range is open at the low end
$subq->setWhere("{$colDefs[$col]} <= ?", $type, $r[1]);
} elseif ($r[1] === null) {
// range is open at the high end
$subq->setWhere("{$colDefs[$col]} >= ?", $type, $r[0]);
} else {
// range is bounded in both directions
$subq->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r);
}
}
$q->setWhereGroup($subq);
}
// handle the exclusionary version
if ($context->not->$m() && $context->not->$m) {
foreach ($context->not->$m as $r) {
if ($r[0] === null) {
// range is open at the low end
$q->setWhereNot("{$colDefs[$col]} <= ?", $type, $r[1]);
} elseif ($r[1] === null) {
// range is open at the high end
$q->setWhereNot("{$colDefs[$col]} >= ?", $type, $r[0]);
} else {
// range is bounded in both directions
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r);
}
}
}
$columns = array_map(function($c) use ($colDefs) {
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
return $colDefs[$c];
}, $columns);
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
}
// return the query
return $q;
}
@ -1772,11 +1816,11 @@ class Database {
* If an empty column list is supplied, a count of articles is returned instead
*
* @param string $user The user whose articles are to be listed
* @param Context $context The search context
* @param RootContext $context The search context
* @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type
* @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance
*/
public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result {
public function articleList(string $user, RootContext $context = null, array $fields = ["id"], array $sort = []): Db\Result {
// make a base query based on context and output columns
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, $fields);
@ -1818,9 +1862,9 @@ class Database {
/** Returns a count of articles which match the given query context
*
* @param string $user The user whose articles are to be counted
* @param Context $context The search context
* @param RootContext $context The search context
*/
public function articleCount(string $user, Context $context = null): int {
public function articleCount(string $user, RootContext $context = null): int {
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, []);
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
@ -1837,10 +1881,10 @@ class Database {
*
* @param string $user The user who owns the articles to be modified
* @param array $data An associative array of properties to modify. Anything not specified will remain unchanged
* @param Context $context The query context to match articles against
* @param RootContext $context The query context to match articles against
* @param bool $updateTimestamp Whether to also update the timestamp. This should only be false if a mark is changed as a result of an automated action not taken by the user
*/
public function articleMark(string $user, array $data, Context $context = null, bool $updateTimestamp = true): int {
public function articleMark(string $user, array $data, RootContext $context = null, bool $updateTimestamp = true): int {
$data = [
'read' => $data['read'] ?? null,
'starred' => $data['starred'] ?? null,
@ -1865,10 +1909,23 @@ class Database {
// marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks
$this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0");
// set read marks
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']);
$q->pushCTE("target_articles(article,subscription)");
$q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']);
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
$subq->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']);
$q = new Query(
"WITH RECURSIVE
target_articles(article, subscription) as (
{$subq->getQuery()}
)
update arsse_marks
set
\"read\" = ?,
touched = 1
where
article in (select article from target_articles)
and subscription in (select distinct subscription from target_articles)",
[$subq->getTypes(), "bool"],
[$subq->getValues(), $data['read']]
);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
// get the articles associated with the requested editions
if ($context->edition()) {
@ -1878,14 +1935,27 @@ class Database {
}
// set starred, hidden, and/or note marks (unless all requested editions actually do not exist)
if ($context->article || $context->articles) {
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
$setData = array_filter($data, function($v) {
return isset($v);
});
[$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
$q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
[$set, $setTypes, $setValues] = $this->generateSet($setData, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
$subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
$q = new Query(
"WITH RECURSIVE
target_articles(article, subscription) as (
{$subq->getQuery()}
)
update arsse_marks
set
touched = 1,
$set
where
article in (select article from target_articles)
and subscription in (select distinct subscription from target_articles)",
[$subq->getTypes(), $setTypes],
[$subq->getValues(), $setValues]
);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
// finally set the modification date for all touched marks and return the number of affected marks
@ -1906,17 +1976,29 @@ class Database {
return 0;
}
}
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
$setData = array_filter($data, function($v) {
return isset($v);
});
[$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
[$set, $setTypes, $setValues] = $this->generateSet($setData, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
if ($updateTimestamp) {
$set .= ", modified = CURRENT_TIMESTAMP";
}
$q->setBody("UPDATE arsse_marks set $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
$subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
$q = new Query(
"WITH RECURSIVE
target_articles(article, subscription) as (
{$subq->getQuery()}
)
update arsse_marks
set
$set
where
article in (select article from target_articles)
and subscription in (select distinct subscription from target_articles)",
[$subq->getTypes(), $setTypes],
[$subq->getValues(), $setValues]
);
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
$tr->commit();
@ -1971,7 +2053,6 @@ class Database {
/** Deletes from the database articles which are beyond the configured clean-up threshold */
public function articleCleanup(): bool {
$integer = $this->db->sqlToken("integer");
$query = $this->db->prepareArray(
"WITH RECURSIVE
exempt_articles as (
@ -1997,8 +2078,8 @@ class Database {
left join (
select
article,
sum(cast((starred = 1 and hidden = 0) as $integer)) as starred,
sum(cast((\"read\" = 1 or hidden = 1) as $integer)) as \"read\",
sum(case when starred = 1 and hidden = 0 then 1 else 0 end) as starred,
sum(case when \"read\" = 1 or hidden = 1 then 1 else 0 end) as \"read\",
max(arsse_marks.modified) as marked_date
from arsse_marks
group by article
@ -2086,7 +2167,7 @@ class Database {
}
/** Returns the numeric identifier of the most recent edition of an article matching the given context */
public function editionLatest(string $user, Context $context = null): int {
public function editionLatest(string $user, RootContext $context = null): int {
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, ["latest_edition"]);
return (int) $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getValue();
@ -2129,7 +2210,6 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them
*/
public function labelList(string $user, bool $includeEmpty = true): Db\Result {
$integer = $this->db->sqlToken("integer");
return $this->db->prepareArray(
"SELECT * FROM (
SELECT
@ -2145,7 +2225,7 @@ class Database {
SELECT
label,
sum(hidden) as hidden,
sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
sum(case when \"read\" = 1 and hidden = 0 then 1 else 0 end) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
@ -2195,7 +2275,6 @@ class Database {
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
$integer = $this->db->sqlToken("integer");
$out = $this->db->prepareArray(
"SELECT
id,
@ -2210,7 +2289,7 @@ class Database {
SELECT
label,
sum(hidden) as hidden,
sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
sum(case when \"read\" = 1 and hidden = 0 then 1 else 0 end) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
@ -2289,11 +2368,11 @@ class Database {
*
* @param string $user The owner of the label
* @param integer|string $id The numeric identifier or name of the label
* @param Context $context The query context matching the desired articles
* @param RootContext $context The query context matching the desired articles
* @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
public function labelArticlesSet(string $user, $id, RootContext $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode));
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->labelValidateId($user, $id, $byName, true)['id'];

1
lib/Db/AbstractDriver.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
use JKingWeb\Arsse\Arsse;

1
lib/Db/AbstractResult.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
abstract class AbstractResult implements Result {

1
lib/Db/AbstractStatement.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
use JKingWeb\Arsse\Misc\Date;

1
lib/Db/Driver.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
interface Driver {

1
lib/Db/Exception.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
class Exception extends \JKingWeb\Arsse\AbstractException {

1
lib/Db/ExceptionInput.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
class ExceptionInput extends \JKingWeb\Arsse\AbstractException {

1
lib/Db/ExceptionRetry.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
class ExceptionRetry extends \JKingWeb\Arsse\AbstractException {

1
lib/Db/ExceptionTimeout.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db;
class ExceptionTimeout extends \JKingWeb\Arsse\AbstractException {

9
lib/Db/MySQL/Driver.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\MySQL;
use JKingWeb\Arsse\Arsse;
@ -81,8 +82,6 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
switch (strtolower($token)) {
case "nocase":
return '"utf8mb4_unicode_ci"';
case "integer":
return "signed integer";
case "asc":
return "";
default:
@ -163,6 +162,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
}
protected function makeConnection(string $db, string $user, string $password, string $host, int $port, string $socket): void {
$drv = new \mysqli_driver;
$drv->report_mode = \MYSQLI_REPORT_OFF;
$this->db = mysqli_init();
$this->db->options(\MYSQLI_SET_CHARSET_NAME, "utf8mb4");
$this->db->options(\MYSQLI_OPT_INT_AND_FLOAT_NATIVE, false);
@ -222,9 +223,9 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
public function maintenance(): bool {
// with MySQL each table must be analyzed separately, so we first have to get a list of tables
foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) {
foreach ($this->query("SHOW TABLES like 'arsse%'") as $table) {
$table = array_pop($table);
if (!preg_match("/^arsse_[a-z_]+$/", $table)) {
if (!preg_match("/^arsse_[a-z_]+$/D", $table)) {
// table is not one of ours
continue; // @codeCoverageIgnore
}

5
lib/Db/MySQL/ExceptionBuilder.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\MySQL;
use JKingWeb\Arsse\Db\Exception;
@ -27,7 +28,7 @@ trait ExceptionBuilder {
public static function buildConnectionException($code, string $msg): array {
switch ($code) {
case 1045:
// @codeCoverageIgnoreStart
// @codeCoverageIgnoreStart
case 1043:
case 1044:
case 1046:
@ -48,7 +49,7 @@ trait ExceptionBuilder {
case 2018:
case 2026:
case 2028:
// @codeCoverageIgnoreEnd
// @codeCoverageIgnoreEnd
return [Exception::class, 'connectionFailure', ['engine' => "MySQL", 'message' => $msg]];
default:
return [Exception::class, 'engineErrorGeneral', $msg]; // @codeCoverageIgnore

4
lib/Db/MySQL/PDODriver.php

@ -4,6 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\MySQL;
use JKingWeb\Arsse\Arsse;
@ -28,7 +29,8 @@ class PDODriver extends Driver {
]);
try {
$this->db = new \PDO($dsn, $user, $password, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_STRINGIFY_FETCHES => true,
]);
} catch (\PDOException $e) {
$msg = $e->getMessage();

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

Loading…
Cancel
Save