Compare commits

..

9 Commits

Author SHA1 Message Date
Athou
69cf674a02 use 120 max line length for both server and client code (was 100 and 140) 2026-11-03 06:48:15 +01:00
Jérémie Panzer
29987a460d Merge pull request #2362 from Athou/renovate/org.jsoup-jsoup-1.x
Update dependency org.jsoup:jsoup to v1.24.1
2026-11-03 06:42:50 +01:00
renovate[bot]
fc8cd9c0a2 Update dependency org.jsoup:jsoup to v1.24.1 2026-11-03 04:47:00 +00:00
Jérémie Panzer
44c2893fff Merge pull request #2361 from Athou/renovate/com.diffplug.spotless-spotless-maven-plugin-3.x
Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.11.0
2026-11-03 02:35:16 +01:00
renovate[bot]
2e66fd4702 Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.11.0 2026-11-03 00:12:34 +00:00
Athou
01ac8d6ebd block NAT64 local-use prefix in SSRF protection 2026-11-02 13:09:31 +01:00
Athou
ddff408afe escape username in password recovery email 2026-11-02 12:34:14 +01:00
Athou
0c280c4dc4 invalidate existing sessions on password change 2026-11-02 12:17:45 +01:00
renovate[bot]
ca4642f7c9 Lock file maintenance 2026-11-02 00:55:45 +00:00
166 changed files with 1781 additions and 2108 deletions

View File

@@ -4,7 +4,7 @@
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 4, "indentWidth": 4,
"lineEnding": "lf", "lineEnding": "lf",
"lineWidth": 140 "lineWidth": 120
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {

View File

@@ -163,13 +163,13 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.29.8", "version": "7.29.12",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz",
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.8", "@babel/parser": "^7.29.12",
"@babel/types": "^7.29.8", "@babel/types": "^7.29.11",
"@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28", "@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2" "jsesc": "^3.0.2"
@@ -283,9 +283,9 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.11", "version": "7.29.12",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
"integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.29.11" "@babel/types": "^7.29.11"
@@ -321,17 +321,17 @@
} }
}, },
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.29.10", "version": "7.29.12",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz",
"integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.8", "@babel/generator": "^7.29.12",
"@babel/helper-globals": "^7.29.7", "@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.9", "@babel/parser": "^7.29.12",
"@babel/template": "^7.29.7", "@babel/template": "^7.29.7",
"@babel/types": "^7.29.8", "@babel/types": "^7.29.11",
"debug": "^4.3.1" "debug": "^4.3.1"
}, },
"engines": { "engines": {
@@ -631,9 +631,9 @@
} }
}, },
"node_modules/@csstools/css-color-parser": { "node_modules/@csstools/css-color-parser": {
"version": "4.2.6", "version": "4.2.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
"integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -682,9 +682,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
"integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1581,9 +1581,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.155.0", "version": "0.156.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/oxc-project" "url": "https://github.com/sponsors/oxc-project"
@@ -1679,9 +1679,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1695,9 +1695,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1711,9 +1711,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1727,9 +1727,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1743,9 +1743,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1759,9 +1759,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1775,9 +1775,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1794,9 +1794,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1813,9 +1813,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1832,9 +1832,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1851,9 +1851,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1870,9 +1870,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1889,9 +1889,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1905,9 +1905,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1921,9 +1921,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2158,9 +2158,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2202,9 +2202,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.6.5", "version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.9.0" "undici-types": "~8.9.0"
@@ -2796,9 +2796,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.33", "version": "2.11.34",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
"integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==", "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.cjs" "baseline-browser-mapping": "dist/cli.cjs"
@@ -2831,9 +2831,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.29.4", "version": "4.29.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz",
"integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -2850,10 +2850,10 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.29", "baseline-browser-mapping": "^2.11.33",
"caniuse-lite": "^1.0.30001816", "caniuse-lite": "^1.0.30001819",
"electron-to-chromium": "^1.5.459", "electron-to-chromium": "^1.5.464",
"node-releases": "^2.0.59", "node-releases": "^2.0.60",
"update-browserslist-db": "^1.3.4" "update-browserslist-db": "^1.3.4"
}, },
"bin": { "bin": {
@@ -2898,9 +2898,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001819", "version": "1.0.30001820",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz",
"integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -3245,9 +3245,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.464", "version": "1.5.472",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz",
"integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
@@ -3258,9 +3258,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": { "node_modules/entities": {
"version": "8.1.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
@@ -4274,9 +4274,9 @@
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "1.4.3", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4430,9 +4430,9 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.60", "version": "2.0.63",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz",
"integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -4536,9 +4536,9 @@
} }
}, },
"node_modules/ora/node_modules/ansi-regex": { "node_modules/ora/node_modules/ansi-regex": {
"version": "6.4.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4625,13 +4625,13 @@
} }
}, },
"node_modules/parse5": { "node_modules/parse5": {
"version": "8.0.1", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"entities": "^8.0.0" "entities": "^9.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1" "url": "https://github.com/inikulin/parse5?sponsor=1"
@@ -4694,9 +4694,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.31", "version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"devOptional": true, "devOptional": true,
"funding": [ "funding": [
{ {
@@ -5228,12 +5228,12 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.15", "version": "1.2.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.155.0", "@oxc-project/types": "=0.156.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@@ -5243,21 +5243,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm-eabi": "1.2.16",
"@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-android-arm64": "1.2.16",
"@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.16",
"@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.16",
"@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16",
"@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.16",
"@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.16",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.16",
"@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.16",
"@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.16",
"@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.16",
"@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.16",
"@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.16",
"@rolldown/binding-win32-x64-msvc": "1.2.15" "@rolldown/binding-win32-x64-msvc": "1.2.16"
} }
}, },
"node_modules/saxes": { "node_modules/saxes": {
@@ -5467,9 +5467,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "6.2.2", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz",
"integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -5533,9 +5533,9 @@
} }
}, },
"node_modules/tldts-core": { "node_modules/tldts-core": {
"version": "7.4.19", "version": "7.4.20",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
"integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==", "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -5579,9 +5579,9 @@
} }
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.9.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tss-react": { "node_modules/tss-react": {

View File

@@ -29,7 +29,7 @@
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@react-querybuilder/mantine": "8.26.0", "@react-querybuilder/mantine": "8.26.0",
"@reduxjs/toolkit": "2.13.0", "@reduxjs/toolkit": "2.13.0",
"@rolldown/plugin-babel": "0.3.0", "@rolldown/plugin-babel": "0.2.4",
"axios": "1.21.0", "axios": "1.21.0",
"dayjs": "1.11.23", "dayjs": "1.11.23",
"escape-string-regexp": "5.0.0", "escape-string-regexp": "5.0.0",

View File

@@ -38,7 +38,8 @@ function Providers(
children: React.ReactNode children: React.ReactNode
}> }>
) { ) {
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor const primaryColor =
useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
return ( return (
<I18nProvider i18n={i18n}> <I18nProvider i18n={i18n}>
<MantineProvider <MantineProvider
@@ -87,7 +88,10 @@ function AppRoutes() {
<Route path="register" element={<RegistrationPage />} /> <Route path="register" element={<RegistrationPage />} />
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} /> <Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
<Route path="passwordReset" element={<PasswordResetPage />} /> <Route path="passwordReset" element={<PasswordResetPage />} />
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}> <Route
path="app"
element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}
>
<Route path="category"> <Route path="category">
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} /> <Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
<Route path=":id/details" element={<CategoryDetailsPage />} /> <Route path=":id/details" element={<CategoryDetailsPage />} />

View File

@@ -61,7 +61,8 @@ export const client = {
getRoot: async () => await axiosInstance.get<Category>("category/get"), getRoot: async () => await axiosInstance.get<Category>("category/get"),
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req), modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req), collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }), getEntries: async (req: GetEntriesPaginatedRequest) =>
await axiosInstance.get<Entries>("category/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req), markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req), add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req), delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
@@ -76,7 +77,8 @@ export const client = {
feed: { feed: {
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`), get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req), modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }), getEntries: async (req: GetEntriesPaginatedRequest) =>
await axiosInstance.get<Entries>("feed/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req), markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req), fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
refreshAll: async () => await axiosInstance.get("feed/refreshAll"), refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
@@ -107,7 +109,8 @@ export const client = {
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req), register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req), initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req), passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
passwordResetCallback: async (req: PasswordResetConfirmationRequest) => await axiosInstance.post("user/passwordResetCallback", req), passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
await axiosInstance.post("user/passwordResetCallback", req),
getSettings: async () => await axiosInstance.get<Settings>("user/settings"), getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings), saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
sendTestPushNotification: async (settings: PushNotificationSettings) => sendTestPushNotification: async (settings: PushNotificationSettings) =>
@@ -156,9 +159,13 @@ function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFe
} }
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> { function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data return (
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
)
} }
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> { function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data return (
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
)
} }

View File

@@ -1,6 +1,14 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
import { Constants } from "@/app/constants" import { Constants } from "@/app/constants"
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks" import {
loadEntries,
loadMoreEntries,
markAllEntries,
markEntry,
markMultipleEntries,
starEntry,
tagEntry,
} from "@/app/entries/thunks"
import type { Entry } from "@/app/types" import type { Entry } from "@/app/types"
export type EntrySourceType = "category" | "feed" | "tag" export type EntrySourceType = "category" | "feed" | "tag"
@@ -85,12 +93,16 @@ export const entriesSlice = createSlice({
} }
}) })
builder.addCase(markAllEntries.pending, (state, action) => { builder.addCase(markAllEntries.pending, (state, action) => {
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) { for (const e of state.entries.filter(e =>
action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true
)) {
e.read = true e.read = true
} }
}) })
builder.addCase(starEntry.pending, (state, action) => { builder.addCase(starEntry.pending, (state, action) => {
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) { for (const e of state.entries.filter(
e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId
)) {
e.starred = action.meta.arg.starred e.starred = action.meta.arg.starred
} }
}) })

View File

@@ -40,7 +40,9 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
const state = thunkApi.getState() const state = thunkApi.getState()
const { source } = state.entries const { source } = state.entries
const offset = const offset =
state.user.settings?.readingMode === "all" || state.entries.search || (source.type === "category" && source.id === "starred") state.user.settings?.readingMode === "all" ||
state.entries.search ||
(source.type === "category" && source.id === "starred")
? state.entries.entries.length ? state.entries.entries.length
: state.entries.entries.filter(e => !e.read).length : state.entries.entries.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type) const endpoint = getEndpoint(state.entries.source.type)
@@ -220,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
const entryIndex = state.entries.entries.indexOf(entry) const entryIndex = state.entries.entries.indexOf(entry)
const entriesToKeepOnTopWhenScrolling = const entriesToKeepOnTopWhenScrolling =
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex) viewMode === "expanded"
? 0
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling] const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
const entryElement = document.getElementById(Constants.dom.entryId(entry)) const entryElement = document.getElementById(Constants.dom.entryId(entry))
@@ -228,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
if (entryElement && entryElementToScrollTo) { if (entryElement && entryElementToScrollTo) {
const scrollMode = state.user.settings?.scrollMode const scrollMode = state.user.settings?.scrollMode
const entryEntirelyVisible = const entryEntirelyVisible =
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement) Constants.layout.isTopVisible(entryElementToScrollTo) &&
Constants.layout.isBottomVisible(entryElement)
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) { if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
const scrollSpeed = state.user.settings?.scrollSpeed const scrollSpeed = state.user.settings?.scrollSpeed
const margin = viewMode === "detailed" ? 8 : 3 const margin = viewMode === "detailed" ? 8 : 3
@@ -242,7 +247,12 @@ export const selectEntry = createAppAsyncThunk(
} }
) )
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => { const scrollToEntry = (
entryElement: HTMLElement,
margin: number,
scrollSpeed: number | undefined,
onScrollEnded: () => void
) => {
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect() const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
const offset = (header?.bottom ?? 0) + margin const offset = (header?.bottom ?? 0) + margin
scrollToWithCallback({ scrollToWithCallback({

View File

@@ -2,11 +2,17 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
import { Constants } from "@/app/constants" import { Constants } from "@/app/constants"
import { redirectTo } from "@/app/redirect/slice" import { redirectTo } from "@/app/redirect/slice"
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login"))) export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/login"))
)
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register"))) export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/register"))
)
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup"))) export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/setup"))
)
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => { export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
window.location.href = "api-documentation/" window.location.href = "api-documentation/"
@@ -38,15 +44,21 @@ export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details"
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`)) thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
) )
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`))) export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/tag/${id}`))
)
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) => export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`)) thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
) )
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add"))) export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/add"))
)
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings"))) export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/settings"))
)
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) => export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/admin/users")) thunkApi.dispatch(redirectTo("/app/admin/users"))
@@ -56,6 +68,10 @@ export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (
thunkApi.dispatch(redirectTo("/app/admin/metrics")) thunkApi.dispatch(redirectTo("/app/admin/metrics"))
) )
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate"))) export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/donate"))
)
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about"))) export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/about"))
)

View File

@@ -1,4 +1,7 @@
import { createAppAsyncThunk } from "@/app/async-thunk" import { createAppAsyncThunk } from "@/app/async-thunk"
import { client } from "@/app/client" import { client } from "@/app/client"
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data)) export const reloadServerInfos = createAppAsyncThunk(
"server/infos",
async () => await client.server.getServerInfos().then(r => r.data)
)

View File

@@ -41,4 +41,5 @@ export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector => useSelector(selector, shallowEqual) export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector =>
useSelector(selector, shallowEqual)

View File

@@ -6,7 +6,10 @@ import { incrementUnreadCount } from "@/app/tree/slice"
import type { CollapseRequest, Subscription } from "@/app/types" import type { CollapseRequest, Subscription } from "@/app/types"
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils" import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data)) export const reloadTree = createAppAsyncThunk(
"tree/reload",
async () => await client.category.getRoot().then(r => r.data)
)
export const collapseTreeCategory = createAppAsyncThunk( export const collapseTreeCategory = createAppAsyncThunk(
"tree/category/collapse", "tree/category/collapse",

View File

@@ -2,14 +2,26 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
import { client } from "@/app/client" import { client } from "@/app/client"
import type { Settings } from "@/app/types" import type { Settings } from "@/app/types"
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data)) export const reloadSettings = createAppAsyncThunk(
"settings/reload",
async () => await client.user.getSettings().then(r => r.data)
)
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data)) export const reloadProfile = createAppAsyncThunk(
"profile/reload",
async () => await client.user.getProfile().then(r => r.data)
)
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data)) export const reloadTags = createAppAsyncThunk(
"entries/tags",
async () => await client.entry.getTags().then(r => r.data)
)
export const changeSettings = createAppAsyncThunk("settings/change", async (newSettings: Partial<Settings>, thunkApi) => { export const changeSettings = createAppAsyncThunk(
const { settings } = thunkApi.getState().user "settings/change",
if (!settings) return async (newSettings: Partial<Settings>, thunkApi) => {
await client.user.saveSettings({ ...settings, ...newSettings }) const { settings } = thunkApi.getState().user
}) if (!settings) return
await client.user.saveSettings({ ...settings, ...newSettings })
}
)

View File

@@ -43,13 +43,27 @@ export function categoryHasNewEntries(category?: TreeCategory): boolean {
.some(f => f.hasNewEntries) .some(f => f.hasNewEntries)
} }
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => { export const calculatePlaceholderSize = ({
width,
height,
maxWidth,
}: {
width?: number
height?: number
maxWidth: number
}) => {
const placeholderWidth = width && Math.min(width, maxWidth) const placeholderWidth = width && Math.min(width, maxWidth)
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
return { width: placeholderWidth, height: placeholderHeight } return { width: placeholderWidth, height: placeholderHeight }
} }
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => { export const scrollToWithCallback = ({
options,
onScrollEnded,
}: {
options: ScrollToOptions
onScrollEnded: () => void
}) => {
const offset = (options.top ?? 0).toFixed() const offset = (options.top ?? 0).toFixed()
const onScroll = throttle(100, () => { const onScroll = throttle(100, () => {

View File

@@ -1,6 +1,14 @@
import type { MessageDescriptor } from "@lingui/core" import type { MessageDescriptor } from "@lingui/core"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { ActionIcon, type ActionIconVariant, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core" import {
ActionIcon,
type ActionIconVariant,
Box,
Button,
type ButtonVariant,
Tooltip,
useMantineTheme,
} from "@mantine/core"
import { forwardRef, type MouseEventHandler, type ReactNode } from "react" import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
import { Constants } from "@/app/constants" import { Constants } from "@/app/constants"
import { useActionButton } from "@/hooks/useActionButton" import { useActionButton } from "@/hooks/useActionButton"

View File

@@ -14,7 +14,9 @@ export function MarkAllAsReadConfirmationDialog() {
const sourceLabel = useAppSelector(state => state.entries.sourceLabel) const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now() const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
const search = useAppSelector(state => state.entries.search) const search = useAppSelector(state => state.entries.search)
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread) const markAllAsReadNavigateToNextUnread = useAppSelector(
state => state.user.settings?.markAllAsReadNavigateToNextUnread
)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const onConfirm = async () => { const onConfirm = async () => {
@@ -33,7 +35,8 @@ export function MarkAllAsReadConfirmationDialog() {
) )
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected) await dispatch(selectNextUnreadTreeItem({ direction: "forward" })) if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
} }
return ( return (
@@ -51,7 +54,8 @@ export function MarkAllAsReadConfirmationDialog() {
)} )}
{threshold > 0 && ( {threshold > 0 && (
<Trans> <Trans>
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read? Are you sure you want to mark entries older than {threshold} days of{" "}
<Code>{sourceLabel}</Code> as read?
</Trans> </Trans>
)} )}
</Text> </Text>

View File

@@ -15,5 +15,12 @@ export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => {
description = <Trans>Push notifications are not configured in your user settings.</Trans> description = <Trans>Push notifications are not configured in your user settings.</Trans>
} }
return <Checkbox label={<Trans>Receive push notifications</Trans>} disabled={disabled} description={description} {...props} /> return (
<Checkbox
label={<Trans>Receive push notifications</Trans>}
disabled={disabled}
description={description}
{...props}
/>
)
} }

View File

@@ -34,7 +34,11 @@ export function UserEdit(props: Readonly<UserEditProps>) {
<form onSubmit={form.onSubmit(saveUser.execute)}> <form onSubmit={form.onSubmit(saveUser.execute)}>
<Stack> <Stack>
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required /> <TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} /> <PasswordInput
label={<Trans>Password</Trans>}
{...form.getInputProps("password")}
required={!props.user}
/>
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} /> <TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} /> <Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} /> <Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />

View File

@@ -15,7 +15,9 @@ describe("Content component", () => {
}) })
it("renders iframe tag when included in content", () => { it("renders iframe tag when included in content", () => {
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider }) const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, {
wrapper: MantineProvider,
})
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com") expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
}) })

View File

@@ -1,6 +1,14 @@
import { Box, Mark } from "@mantine/core" import { Box, Mark } from "@mantine/core"
import escapeStringRegexp from "escape-string-regexp" import escapeStringRegexp from "escape-string-regexp"
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave" import {
ALLOWED_TAG_LIST,
type ChildrenNode,
Interweave,
Matcher,
type MatchResponse,
type Node,
type TransformCallback,
} from "interweave"
import React from "react" import React from "react"
import styleToObject from "style-to-object" import styleToObject from "style-to-object"
import { Constants } from "@/app/constants" import { Constants } from "@/app/constants"

View File

@@ -300,7 +300,9 @@ export function FeedEntries() {
entry={entry} entry={entry}
expanded={!!entry.expanded || viewMode === "expanded"} expanded={!!entry.expanded || viewMode === "expanded"}
selected={entry.id === selectedEntryId} selected={entry.id === selectedEntryId}
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")} showSelectionIndicator={
entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")
}
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined} maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
onHeaderClick={event => headerClicked(entry, event)} onHeaderClick={event => headerClicked(entry, event)}
onHeaderRightClick={event => headerRightClicked(entry, event)} onHeaderRightClick={event => headerRightClicked(entry, event)}

View File

@@ -34,67 +34,80 @@ const useStyles = tss
maxWidth?: number maxWidth?: number
fontSizePercentage: number fontSizePercentage: number
}>() }>()
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => { .create(
let backgroundColor: string ({
if (colorScheme === "dark") { theme,
backgroundColor = read ? "inherit" : theme.colors.dark[5] colorScheme,
} else { read,
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit" expanded,
} viewMode,
rtl,
showSelectionIndicator,
maxWidth,
fontSizePercentage,
}) => {
let backgroundColor: string
if (colorScheme === "dark") {
backgroundColor = read ? "inherit" : theme.colors.dark[5]
} else {
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
}
let marginY = 10 let marginY = 10
if (viewMode === "title") { if (viewMode === "title") {
marginY = 2 marginY = 2
} else if (viewMode === "cozy") { } else if (viewMode === "cozy") {
marginY = 6 marginY = 6
} }
let mobileMarginY = 6 let mobileMarginY = 6
if (viewMode === "title") { if (viewMode === "title") {
mobileMarginY = 2 mobileMarginY = 2
} else if (viewMode === "cozy") { } else if (viewMode === "cozy") {
mobileMarginY = 4 mobileMarginY = 4
} }
let backgroundHoverColor = backgroundColor let backgroundHoverColor = backgroundColor
if (!expanded && !read) { if (!expanded && !read) {
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1] backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
} }
let paperBorderLeftColor = "" let paperBorderLeftColor = ""
if (showSelectionIndicator) { if (showSelectionIndicator) {
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6] const borderLeftColor =
paperBorderLeftColor = `${borderLeftColor} !important` colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
} paperBorderLeftColor = `${borderLeftColor} !important`
}
return { return {
paper: { paper: {
backgroundColor, backgroundColor,
borderLeftColor: paperBorderLeftColor, borderLeftColor: paperBorderLeftColor,
marginTop: marginY, marginTop: marginY,
marginBottom: marginY, marginBottom: marginY,
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: { [`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
marginTop: mobileMarginY, marginTop: mobileMarginY,
marginBottom: mobileMarginY, marginBottom: mobileMarginY,
}, },
"@media (hover: hover)": { "@media (hover: hover)": {
"&:hover": { "&:hover": {
backgroundColor: backgroundHoverColor, backgroundColor: backgroundHoverColor,
},
}, },
}, },
}, headerLink: {
headerLink: { fontSize: `${fontSizePercentage}%`,
fontSize: `${fontSizePercentage}%`, color: "inherit",
color: "inherit", textDecoration: "none",
textDecoration: "none", },
}, body: {
body: { fontSize: `${fontSizePercentage}%`,
fontSize: `${fontSizePercentage}%`, direction: rtl ? "rtl" : "ltr",
direction: rtl ? "rtl" : "ltr", maxWidth: maxWidth ?? "100%",
maxWidth: maxWidth ?? "100%", },
}, }
} }
}) )
export function FeedEntry(props: Readonly<FeedEntryProps>) { export function FeedEntry(props: Readonly<FeedEntryProps>) {
const viewMode = useAppSelector(state => state.user.localSettings.viewMode) const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
@@ -116,7 +129,9 @@ export function FeedEntry(props: Readonly<FeedEntryProps>) {
const showExternalLinkIcon = const showExternalLinkIcon =
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode) externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
const showStarIcon = const showStarIcon =
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode) props.entry.markable &&
starIconDisplayMode &&
["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
const swipeHandlers = useSwipeable({ const swipeHandlers = useSwipeable({
onSwipedLeft: props.onSwipedLeft, onSwipedLeft: props.onSwipedLeft,

View File

@@ -35,7 +35,12 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
const { openLinkInBackgroundTab } = useBrowserExtension() const { openLinkInBackgroundTab } = useBrowserExtension()
return ( return (
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}> <Menu
id={Constants.dom.entryContextMenuId(props.entry)}
theme={colorScheme}
animation={false}
className={classes.menu}
>
<Item <Item
onClick={() => { onClick={() => {
window.open(props.entry.url, "_blank", "noreferrer") window.open(props.entry.url, "_blank", "noreferrer")
@@ -63,13 +68,19 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
{props.entry.markable && ( {props.entry.markable && (
<> <>
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}> <Item
onClick={async () =>
await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))
}
>
<Group> <Group>
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />} {props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} {props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
</Group> </Group>
</Item> </Item>
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}> <Item
onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}
>
<Group> <Group>
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />} {props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>} {props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}

View File

@@ -1,7 +1,16 @@
import { msg } from "@lingui/core/macro" import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { Group, Indicator, Popover, TagsInput } from "@mantine/core" import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb" import {
TbArrowBarToDown,
TbExternalLink,
TbMail,
TbMailOpened,
TbShare,
TbStar,
TbStarOff,
TbTag,
} from "react-icons/tb"
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks" import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store" import { useAppDispatch, useAppSelector } from "@/app/store"
import type { Entry } from "@/app/types" import type { Entry } from "@/app/types"
@@ -71,7 +80,12 @@ export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
{tags && ( {tags && (
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}> <Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
<Popover.Target> <Popover.Target>
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}> <Indicator
label={props.entry.tags.length}
disabled={props.entry.tags.length === 0}
inline
size={16}
>
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} /> <ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
</Indicator> </Indicator>
</Popover.Target> </Popover.Target>

View File

@@ -104,7 +104,9 @@ export function ShareButtons(
}> }>
) { ) {
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site]) const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(
site => sharingSettings?.[site]
)
const url = encodeURIComponent(props.url) const url = encodeURIComponent(props.url)
const desc = encodeURIComponent(props.description) const desc = encodeURIComponent(props.description)
const clipboardAvailable = typeof navigator.clipboard !== "undefined" const clipboardAvailable = typeof navigator.clipboard !== "undefined"
@@ -119,7 +121,9 @@ export function ShareButtons(
{showNativeSection && ( {showNativeSection && (
<SimpleGrid cols={4}> <SimpleGrid cols={4}>
{clipboardAvailable && <CopyUrlButton url={props.url} />} {clipboardAvailable && <CopyUrlButton url={props.url} />}
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />} {nativeSharingAvailable && (
<BrowserNativeShareButton url={props.url} description={props.description} />
)}
</SimpleGrid> </SimpleGrid>
)} )}

View File

@@ -36,7 +36,12 @@ export function AddCategory() {
<form onSubmit={form.onSubmit(addCategory.execute)}> <form onSubmit={form.onSubmit(addCategory.execute)}>
<Stack> <Stack>
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required /> <TextInput
label={<Trans>Category</Trans>}
placeholder={_(msg`Category`)}
{...form.getInputProps("name")}
required
/>
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable /> <CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
<Group justify="center"> <Group justify="center">
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>

View File

@@ -44,8 +44,8 @@ export function ImportOpml() {
placeholder={_(msg`OPML file`)} placeholder={_(msg`OPML file`)}
description={ description={
<Trans> <Trans>
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your An opml file is an XML file containing feed URLs and categories. You can get an OPML
data from other feed reading services. file by exporting your data from other feed reading services.
</Trans> </Trans>
} }
{...form.getInputProps("file")} {...form.getInputProps("file")}

View File

@@ -85,8 +85,8 @@ export function Subscribe() {
placeholder="https://www.mysite.com/rss" placeholder="https://www.mysite.com/rss"
description={ description={
<Trans> <Trans>
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed The URL for the feed you want to subscribe to. You can also use the website's url
will try to find the feed in the page. directly and CommaFeed will try to find the feed in the page.
</Trans> </Trans>
} }
required required
@@ -101,8 +101,17 @@ export function Subscribe() {
> >
<Stack> <Stack>
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled /> <TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus /> <TextInput
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable /> label={<Trans>Feed name</Trans>}
{...step1Form.getInputProps("title")}
required
autoFocus
/>
<CategorySelect
label={<Trans>Category</Trans>}
{...step1Form.getInputProps("categoryId")}
clearable
/>
</Stack> </Stack>
</Stepper.Step> </Stepper.Step>
</Stepper> </Stepper>
@@ -117,7 +126,11 @@ export function Subscribe() {
</Button> </Button>
)} )}
{activeStep === 1 && ( {activeStep === 1 && (
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}> <Button
type="submit"
leftSection={<TbRss size={16} />}
loading={fetchFeed.loading || subscribe.loading}
>
<Trans>Subscribe</Trans> <Trans>Subscribe</Trans>
</Button> </Button>
)} )}

View File

@@ -26,7 +26,16 @@ const fields: Field[] = [
{ name: "categoriesLower", label: "Categories (lower case)" }, { name: "categoriesLower", label: "Categories (lower case)" },
] ]
const textOperators = new Set(["=", "!=", "contains", "beginsWith", "endsWith", "doesNotContain", "doesNotBeginWith", "doesNotEndWith"]) const textOperators = new Set([
"=",
"!=",
"contains",
"beginsWith",
"endsWith",
"doesNotContain",
"doesNotBeginWith",
"doesNotEndWith",
])
function toCelString(query: RuleGroupType): string { function toCelString(query: RuleGroupType): string {
if (query.rules.length === 0) { if (query.rules.length === 0) {

View File

@@ -24,7 +24,10 @@ export function Star(
} }
return ( return (
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}> <Tooltip
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
openDelay={Constants.tooltip.delay}
>
<ActionIcon variant="transparent" onClick={onClick}> <ActionIcon variant="transparent" onClick={onClick}>
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />} {props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
</ActionIcon> </ActionIcon>

View File

@@ -17,7 +17,13 @@ import {
TbSortDescending, TbSortDescending,
TbUser, TbUser,
} from "react-icons/tb" } from "react-icons/tb"
import { markAllAsReadWithConfirmationIfRequired, reloadEntries, search, selectNextEntry, selectPreviousEntry } from "@/app/entries/thunks" import {
markAllAsReadWithConfirmationIfRequired,
reloadEntries,
search,
selectNextEntry,
selectPreviousEntry,
} from "@/app/entries/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store" import { useAppDispatch, useAppSelector } from "@/app/store"
import { changeSettings } from "@/app/user/thunks" import { changeSettings } from "@/app/user/thunks"
import { ActionButton } from "@/components/ActionButton" import { ActionButton } from "@/components/ActionButton"
@@ -143,7 +149,13 @@ export function Header() {
onClick={toggleReadingMode} onClick={toggleReadingMode}
/> />
<ActionButton <ActionButton
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />} icon={
settings.readingOrder === "asc" ? (
<TbSortAscending size={iconSize} />
) : (
<TbSortDescending size={iconSize} />
)
}
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`} label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
onClick={toggleReadingOrder} onClick={toggleReadingOrder}
/> />
@@ -160,7 +172,11 @@ export function Header() {
placeholder={_(msg`Search`)} placeholder={_(msg`Search`)}
{...searchForm.getInputProps("search")} {...searchForm.getInputProps("search")}
leftSection={<TbSearch size={iconSize} />} leftSection={<TbSearch size={iconSize} />}
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />} rightSection={
<CloseButton
onClick={async () => await (searchFromStore && dispatch(search("")))}
/>
}
autoFocus autoFocus
/> />
</form> </form>

View File

@@ -31,7 +31,13 @@ import {
} from "react-icons/tb" } from "react-icons/tb"
import { throttle } from "throttle-debounce" import { throttle } from "throttle-debounce"
import { client } from "@/app/client" import { client } from "@/app/client"
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "@/app/redirect/thunks" import {
redirectToAbout,
redirectToAdminUsers,
redirectToDonate,
redirectToMetrics,
redirectToSettings,
} from "@/app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store" import { useAppDispatch, useAppSelector } from "@/app/store"
import type { ViewMode } from "@/app/types" import type { ViewMode } from "@/app/types"
import { setFontSizePercentage, setViewMode } from "@/app/user/slice" import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
@@ -124,7 +130,13 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
} }
return ( return (
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened} preventPositionChangeWhenVisible={false}> <Menu
position="bottom-end"
closeOnItemClick={false}
opened={opened}
onChange={setOpened}
preventPositionChangeWhenVisible={false}
>
<Menu.Target>{props.control}</Menu.Target> <Menu.Target>{props.control}</Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{profile && <Menu.Label>{profile.name}</Menu.Label>} {profile && <Menu.Label>{profile.name}</Menu.Label>}
@@ -164,7 +176,9 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
}} }}
> >
<Trans>Fetch all my feeds now</Trans> <Trans>Fetch all my feeds now</Trans>
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>} {!forceRefreshEnabled && (
<span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>
)}
</Menu.Item> </Menu.Item>
<Divider /> <Divider />

View File

@@ -83,7 +83,11 @@ export function CustomCodeSettings() {
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}> <Button
type="submit"
leftSection={<TbDeviceFloppy size={16} />}
loading={saveCustomCode.loading}
>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
</Group> </Group>

View File

@@ -111,7 +111,11 @@ export function DisplaySettings() {
/> />
<Switch <Switch
label={<Trans>Navigate to the next category/feed with unread entries when marking all entries as read</Trans>} label={
<Trans>
Navigate to the next category/feed with unread entries when marking all entries as read
</Trans>
}
checked={settings.markAllAsReadNavigateToNextUnread} checked={settings.markAllAsReadNavigateToNextUnread}
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))} onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
/> />

View File

@@ -1,6 +1,18 @@
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core" import {
Anchor,
Box,
Button,
Checkbox,
Divider,
Group,
Input,
PasswordInput,
Stack,
Text,
TextInput,
} from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals" import { openConfirmModal } from "@mantine/modals"
import { useEffect } from "react" import { useEffect } from "react"
@@ -91,8 +103,9 @@ export function ProfileSettings() {
label={<Trans>API key</Trans>} label={<Trans>API key</Trans>}
description={ description={
<Trans> <Trans>
This is your API key. It can be used for some read-only API operations and grants access to the Fever API This is your API key. It can be used for some read-only API operations and grants access
and the Google Reader API. Use the form at the bottom of the page to generate a new API key to the Fever API and the Google Reader API. Use the form at the bottom of the page to
generate a new API key
</Trans> </Trans>
} }
readOnly readOnly
@@ -103,7 +116,8 @@ export function ProfileSettings() {
label={<Trans>OPML export</Trans>} label={<Trans>OPML export</Trans>}
description={ description={
<Trans> <Trans>
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services Export your subscriptions and categories as an OPML file that can be imported in other
feed reading services
</Trans> </Trans>
} }
> >
@@ -118,8 +132,8 @@ export function ProfileSettings() {
label={<Trans>Fever API</Trans>} label={<Trans>Fever API</Trans>}
description={ description={
<Trans> <Trans>
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. CommaFeed is compatible with the Fever API. Use the following URL in your
Login with your username and your <u>API key</u>. Fever-compatible mobile client. Login with your username and your <u>API key</u>.
</Trans> </Trans>
} }
> >
@@ -134,8 +148,8 @@ export function ProfileSettings() {
label={<Trans>Google Reader API</Trans>} label={<Trans>Google Reader API</Trans>}
description={ description={
<Trans> <Trans>
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible CommaFeed is compatible with the Google Reader API. Use the following URL in your Google
mobile client. Login with your username and your <u>API key</u>. Reader-compatible mobile client. Login with your username and your <u>API key</u>.
</Trans> </Trans>
} }
> >
@@ -165,8 +179,14 @@ export function ProfileSettings() {
description={<Trans>Changing password will generate a new API key</Trans>} description={<Trans>Changing password will generate a new API key</Trans>}
{...form.getInputProps("newPassword")} {...form.getInputProps("newPassword")}
/> />
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} /> <PasswordInput
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} /> label={<Trans>Confirm password</Trans>}
{...form.getInputProps("newPasswordConfirmation")}
/>
<Checkbox
label={<Trans>Generate new API key</Trans>}
{...form.getInputProps("newApiKey", { type: "checkbox" })}
/>
<Group> <Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>

View File

@@ -50,8 +50,8 @@ export function PushNotificationSettings() {
label={<Trans>Push notification service</Trans>} label={<Trans>Push notification service</Trans>}
description={ description={
<Trans> <Trans>
Receive push notifications when new feed entries are discovered. Enable "Receive push notifications" in the Receive push notifications when new feed entries are discovered. Enable "Receive push
settings of each feed for which you want to receive notifications. notifications" in the settings of each feed for which you want to receive notifications.
</Trans> </Trans>
} }
data={[ data={[
@@ -77,7 +77,12 @@ export function PushNotificationSettings() {
required required
{...form.getInputProps("serverUrl")} {...form.getInputProps("serverUrl")}
/> />
<TextInput label={<Trans>Topic</Trans>} placeholder="commafeed" required {...form.getInputProps("topic")} /> <TextInput
label={<Trans>Topic</Trans>}
placeholder="commafeed"
required
{...form.getInputProps("topic")}
/>
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} /> <TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
</> </>
)} )}

View File

@@ -119,8 +119,11 @@ export function Tree() {
const categoryNode = (category: Category, level = 0) => { const categoryNode = (category: Category, level = 0) => {
if (!isCategoryDisplayed(category)) return null if (!isCategoryDisplayed(category)) return null
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold)) const hasError =
const hasWarning = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy)) !category.expanded &&
flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
const hasWarning =
!category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
return ( return (
<TreeNode <TreeNode
id={category.id} id={category.id}

View File

@@ -80,7 +80,11 @@ export function TreeNode(props: Readonly<TreeNodeProps>) {
data-type={props.type} data-type={props.type}
data-unread-count={props.unread} data-unread-count={props.unread}
> >
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon"> <Box
mr={6}
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
className="cf-treenode-icon"
>
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center> <Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
</Box> </Box>
<Box className={classes.nodeText}>{props.name}</Box> <Box className={classes.nodeText}>{props.name}</Box>

View File

@@ -1,10 +1,26 @@
import { msg } from "@lingui/core/macro" import { msg } from "@lingui/core/macro"
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core" import {
Anchor,
Box,
Center,
Container,
Divider,
Group,
Image,
Space,
Title,
useMantineColorScheme,
} from "@mantine/core"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { SiGithub, SiX } from "react-icons/si" import { SiGithub, SiX } from "react-icons/si"
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb" import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
import { client } from "@/app/client" import { client } from "@/app/client"
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "@/app/redirect/thunks" import {
redirectToApiDocumentation,
redirectToLogin,
redirectToRegistration,
redirectToRootCategory,
} from "@/app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store" import { useAppDispatch, useAppSelector } from "@/app/store"
import welcomePageDark from "@/assets/welcome_page_dark.png" import welcomePageDark from "@/assets/welcome_page_dark.png"
import welcomePageLight from "@/assets/welcome_page_light.png" import welcomePageLight from "@/assets/welcome_page_light.png"

View File

@@ -69,7 +69,11 @@ export function AdminUsersPage() {
<Title order={3} mb="md"> <Title order={3} mb="md">
<Group> <Group>
<Trans>Manage users</Trans> <Trans>Manage users</Trans>
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}> <ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={() => openUserEditModal(<Trans>Add user</Trans>)}
>
<TbPlus size={20} /> <TbPlus size={20} />
</ActionIcon> </ActionIcon>
</Group> </Group>

View File

@@ -19,8 +19,10 @@ const shownGauges: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size", "com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size",
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers", "com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers",
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers", "com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers",
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active": "Feed Refresh Engine active push notifications workers", "com.commafeed.backend.feed.FeedRefreshEngine.notifier.active":
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue": "Feed Refresh Engine queued push notifications workers", "Feed Refresh Engine active push notifications workers",
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue":
"Feed Refresh Engine queued push notifications workers",
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries", "com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage", "com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users", "com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",

View File

@@ -51,7 +51,12 @@ function NextUnreadBookmarklet() {
return ( return (
<Box> <Box>
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} /> <CategorySelect
value={categoryId}
onChange={c => c && setCategoryId(c)}
withAll
description={<Trans>Category</Trans>}
/>
<NativeSelect <NativeSelect
data={[ data={[
{ value: "desc", label: _(msg`Newest first`) }, { value: "desc", label: _(msg`Newest first`) },
@@ -73,7 +78,8 @@ function NextUnreadBookmarklet() {
export function AboutPage() { export function AboutPage() {
const version = useAppSelector(state => state.server.serverInfos?.version) const version = useAppSelector(state => state.server.serverInfos?.version)
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit) const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension() const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } =
useBrowserExtension()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return ( return (
@@ -100,7 +106,9 @@ export function AboutPage() {
</Trans> </Trans>
</Box> </Box>
<Box> <Box>
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans> <Trans>
If you encounter an issue, please report it on the issues page of the GitHub project.
</Trans>
</Box> </Box>
</Section> </Section>
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}> <Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>

View File

@@ -1,7 +1,21 @@
import { msg } from "@lingui/core/macro" import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core" import {
Anchor,
Box,
Button,
Code,
Container,
Divider,
Group,
Input,
NumberInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals" import { openConfirmModal } from "@mantine/modals"
import { useEffect } from "react" import { useEffect } from "react"
@@ -130,7 +144,12 @@ export function CategoryDetailsPage() {
clearable clearable
withoutCategoryIds={excludedCategoryIds} withoutCategoryIds={excludedCategoryIds}
/> />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} /> <NumberInput
label={<Trans>Position</Trans>}
{...form.getInputProps("position")}
required
min={0}
/>
</> </>
)} )}
@@ -140,7 +159,11 @@ export function CategoryDetailsPage() {
</Button> </Button>
{editable && ( {editable && (
<> <>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}> <Button
type="submit"
leftSection={<TbDeviceFloppy size={16} />}
loading={modifyCategory.loading}
>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
<Divider orientation="vertical" /> <Divider orientation="vertical" />

View File

@@ -19,8 +19,8 @@ export function DonatePage() {
<Trans> <Trans>
<Box>Hey,</Box> <Box>Hey,</Box>
<Box mt="xs"> <Box mt="xs">
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years
an interest in helping me continue supporting CommaFeed. now. Thanks for taking an interest in helping me continue supporting CommaFeed.
</Box> </Box>
</Trans> </Trans>

View File

@@ -133,7 +133,11 @@ export function FeedDetailsPage() {
<Input.Wrapper label={<Trans>Generated feed url</Trans>}> <Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box> <Box>
{apiKey && ( {apiKey && (
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer"> <Anchor
href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`}
target="_blank"
rel="noreferrer"
>
<Trans>Link</Trans> <Trans>Link</Trans>
</Anchor> </Anchor>
)} )}
@@ -146,10 +150,16 @@ export function FeedDetailsPage() {
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required /> <TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable /> <CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} /> <NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
<ReceivePushNotificationsChechbox {...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })} /> <ReceivePushNotificationsChechbox
{...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })}
/>
<NumberInput <NumberInput
label={<Trans>Auto-mark as read</Trans>} label={<Trans>Auto-mark as read</Trans>}
description={<Trans>Mark entries in this feed as read after this number of days. Leave empty to disable.</Trans>} description={
<Trans>
Mark entries in this feed as read after this number of days. Leave empty to disable.
</Trans>
}
suffix={` ${_(msg`days`)}`} suffix={` ${_(msg`days`)}`}
{...form.getInputProps("autoMarkAsReadAfterDays")} {...form.getInputProps("autoMarkAsReadAfterDays")}
min={1} min={1}
@@ -159,21 +169,25 @@ export function FeedDetailsPage() {
label={<Trans>Filtering expression</Trans>} label={<Trans>Filtering expression</Trans>}
description={ description={
<Trans> <Trans>
Build a filter expression to indicate what you want to read. Entries that don't match will be marked as read Build a filter expression to indicate what you want to read. Entries that don't match
automatically. will be marked as read automatically.
</Trans> </Trans>
} }
> >
{feed.filterLegacy && ( {feed.filterLegacy && (
<MantineAlert color="yellow" icon={<TbAlertTriangle />}> <MantineAlert color="yellow" icon={<TbAlertTriangle />}>
<Trans> <Trans>
This feed has a legacy filter that cannot be edited and is not applied. Please recreate the filter using This feed has a legacy filter that cannot be edited and is not applied. Please
the new expression editor. The legacy filter expression was: <Code>{feed.filterLegacy}</Code> recreate the filter using the new expression editor. The legacy filter expression
was: <Code>{feed.filterLegacy}</Code>
</Trans> </Trans>
</MantineAlert> </MantineAlert>
)} )}
<Box mt="xs"> <Box mt="xs">
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} /> <FilteringExpressionEditor
initialValue={feed.filter}
onChange={value => form.setFieldValue("filter", value)}
/>
</Box> </Box>
</Input.Wrapper> </Input.Wrapper>

View File

@@ -20,7 +20,8 @@ function NoSubscriptionHelp() {
<Box> <Box>
<Center> <Center>
<Trans> <Trans>
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page? You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of
the page?
</Trans> </Trans>
</Center> </Center>
</Box> </Box>
@@ -51,7 +52,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
const viewport = useViewportSize() const viewport = useViewportSize()
const theme = useMantineTheme() const theme = useMantineTheme()
const noSubscriptions = useAppSelector( const noSubscriptions = useAppSelector(
state => state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0) state =>
state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
) )
const sourceLabel = useAppSelector(state => state.entries.sourceLabel) const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl) const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
@@ -63,7 +65,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
if (!root) return { unreadCount: 0, hasNewEntries: false } if (!root) return { unreadCount: 0, hasNewEntries: false }
if (props.sourceType === "category") { if (props.sourceType === "category") {
const category = id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id) const category =
id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
return { return {
unreadCount: categoryUnreadCount(category), unreadCount: categoryUnreadCount(category),
hasNewEntries: categoryHasNewEntries(category), hasNewEntries: categoryHasNewEntries(category),

View File

@@ -50,8 +50,8 @@ export function InitialSetupPage() {
</Title> </Title>
<Box mb="md"> <Box mb="md">
<Trans> <Trans>
Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get Welcome! This appears to be the first time you're running CommaFeed. Please create an
started. administrator account to get started.
</Trans> </Trans>
</Box> </Box>
{setup.error && ( {setup.error && (

View File

@@ -1,7 +1,19 @@
import { msg } from "@lingui/core/macro" import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core" import {
Anchor,
Box,
Button,
Center,
Container,
Group,
Paper,
PasswordInput,
Stack,
TextInput,
Title,
} from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
@@ -49,7 +61,11 @@ export function LoginPage() {
placeholder={_(msg`User Name or E-mail`)} placeholder={_(msg`User Name or E-mail`)}
{...form.getInputProps("name")} {...form.getInputProps("name")}
description={ description={
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : "" serverInfos?.demoAccountEnabled ? (
<Trans>Try out CommaFeed with the demo account: demo/demo</Trans>
) : (
""
)
} }
size="md" size="md"
required required

View File

@@ -1,7 +1,19 @@
import { msg } from "@lingui/core/macro" import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core" import {
Anchor,
Box,
Button,
Center,
Container,
Group,
Paper,
PasswordInput,
Stack,
TextInput,
Title,
} from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
@@ -71,7 +83,13 @@ export function RegistrationPage() {
<form onSubmit={form.onSubmit(register.execute)}> <form onSubmit={form.onSubmit(register.execute)}>
<Stack> <Stack>
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required /> <TextInput
label="User Name"
placeholder="User Name"
{...form.getInputProps("name")}
size="md"
required
/>
<TextInput <TextInput
type="email" type="email"
label={<Trans>E-mail address</Trans>} label={<Trans>E-mail address</Trans>}

View File

@@ -274,7 +274,7 @@
<plugin> <plugin>
<groupId>com.diffplug.spotless</groupId> <groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId> <artifactId>spotless-maven-plugin</artifactId>
<version>3.10.4</version> <version>3.11.0</version>
<?m2e ignore?> <?m2e ignore?>
<executions> <executions>
<execution> <execution>
@@ -290,6 +290,7 @@
<googleJavaFormat> <googleJavaFormat>
<version>${google-java-format.version}</version> <version>${google-java-format.version}</version>
<style>AOSP</style> <style>AOSP</style>
<maxLineLength>120</maxLineLength>
<reorderImports>true</reorderImports> <reorderImports>true</reorderImports>
</googleJavaFormat> </googleJavaFormat>
</java> </java>
@@ -434,7 +435,7 @@
<dependency> <dependency>
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.23.2</version> <version>1.24.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.ibm.icu</groupId> <groupId>com.ibm.icu</groupId>

View File

@@ -34,16 +34,14 @@ public class CommaFeedApplication {
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0"); System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0"); System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
PasswordConstraintValidator.setMinimumPasswordLength( PasswordConstraintValidator.setMinimumPasswordLength(config.users().minimumPasswordLength());
config.users().minimumPasswordLength());
if (config.imageProxyEnabled()) { if (config.imageProxyEnabled()) {
ImageProxyUrl.generateKey(); ImageProxyUrl.generateKey();
} }
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) { if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
throw new IllegalStateException( throw new IllegalStateException("password recovery is enabled but no public base url is set");
"password recovery is enabled but no public base url is set");
} }
feedRefreshEngine.start(); feedRefreshEngine.start();

View File

@@ -19,24 +19,20 @@ import java.util.Optional;
/** /**
* CommaFeed configuration * CommaFeed configuration
* *
* <p>Default values are for production, they can be overridden in application.properties for other * <p>Default values are for production, they can be overridden in application.properties for other profiles
* profiles
*/ */
@ConfigMapping(prefix = "commafeed") @ConfigMapping(prefix = "commafeed")
@ConfigRoot(phase = ConfigPhase.RUN_TIME) @ConfigRoot(phase = ConfigPhase.RUN_TIME)
public interface CommaFeedConfiguration { public interface CommaFeedConfiguration {
/** /** Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. */
* Whether to expose a robots.txt file that disallows web crawlers and search engine indexers.
*/
@WithDefault("true") @WithDefault("true")
boolean hideFromWebCrawlers(); boolean hideFromWebCrawlers();
/** /**
* If enabled, images in feed entries will be proxied through the server instead of accessed * If enabled, images in feed entries will be proxied through the server instead of accessed directly by the
* directly by the browser. * browser.
* *
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds * <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
* that are followed.
*/ */
@WithDefault("false") @WithDefault("false")
boolean imageProxyEnabled(); boolean imageProxyEnabled();
@@ -50,9 +46,8 @@ public interface CommaFeedConfiguration {
boolean passwordRecoveryEnabled(); boolean passwordRecoveryEnabled();
/** /**
* The base URL of the application to use in the password recovery link in the email. We can't * The base URL of the application to use in the password recovery link in the email. We can't use the URL sent by
* use the URL sent by the browser of the user because of malicious Host header injection * the browser of the user because of malicious Host header injection attacks.
* attacks.
*/ */
Optional<String> passwordRecoveryPublicBaseUrl(); Optional<String> passwordRecoveryPublicBaseUrl();
@@ -87,16 +82,14 @@ public interface CommaFeedConfiguration {
Websocket websocket(); Websocket websocket();
/** /**
* Duration to wait for the feed refresh engine and the task scheduler to stop when the * Duration to wait for the feed refresh engine and the task scheduler to stop when the application is shutting
* application is shutting down. * down.
*/ */
@WithDefault("2s") @WithDefault("2s")
Duration shutdownTimeout(); Duration shutdownTimeout();
interface HttpClient { interface HttpClient {
/** /** User-Agent string that will be used by the http client, leave empty for the default one. */
* User-Agent string that will be used by the http client, leave empty for the default one.
*/
Optional<String> userAgent(); Optional<String> userAgent();
/** Time to wait for a connection to be established. */ /** Time to wait for a connection to be established. */
@@ -123,19 +116,15 @@ public interface CommaFeedConfiguration {
@WithDefault("1m") @WithDefault("1m")
Duration idleConnectionsEvictionInterval(); Duration idleConnectionsEvictionInterval();
/** /** If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. */
* If a feed is larger than this, it will be discarded to prevent memory issues while
* parsing the feed.
*/
@WithDefault("5M") @WithDefault("5M")
MemorySize maxResponseSize(); MemorySize maxResponseSize();
/** /**
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, * Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could
* which could potentially expose internal resources. * potentially expose internal resources.
* *
* <p>You may want to enable this if you host a public instance of CommaFeed with * <p>You may want to enable this if you host a public instance of CommaFeed with registrations open.
* registrations open.
*/ */
@WithDefault("true") @WithDefault("true")
boolean blockLocalAddresses(); boolean blockLocalAddresses();
@@ -147,9 +136,8 @@ public interface CommaFeedConfiguration {
interface HttpClientCache { interface HttpClientCache {
/** /**
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts * Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when
* (e.g. when subscribing to a feed for the first time or when clicking "fetch all my feeds * subscribing to a feed for the first time or when clicking "fetch all my feeds now").
* now").
*/ */
@WithDefault("true") @WithDefault("true")
boolean enabled(); boolean enabled();
@@ -169,8 +157,7 @@ public interface CommaFeedConfiguration {
Duration interval(); Duration interval();
/** /**
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an * Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
* upper bound when:
* *
* <ul> * <ul>
* <li>an error occurs while refreshing a feed and we're backing off exponentially * <li>an error occurs while refreshing a feed and we're backing off exponentially
@@ -182,10 +169,10 @@ public interface CommaFeedConfiguration {
Duration maxInterval(); Duration maxInterval();
/** /**
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average * If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries
* time between entries and the time since the last entry was published. The interval will * and the time since the last entry was published. The interval will be sometimes between the default refresh
* be sometimes between the default refresh interval (`commafeed.feed-refresh.interval`) and * interval (`commafeed.feed-refresh.interval`) and the maximum refresh interval
* the maximum refresh interval (`commafeed.feed-refresh.max-interval`). * (`commafeed.feed-refresh.max-interval`).
* *
* <p>See {@link FeedRefreshIntervalCalculator} for details. * <p>See {@link FeedRefreshIntervalCalculator} for details.
*/ */
@@ -207,8 +194,8 @@ public interface CommaFeedConfiguration {
int databaseThreads(); int databaseThreads();
/** /**
* Duration after which a user is considered inactive. Feeds for inactive users are not * Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log
* refreshed until they log in again. * in again.
* *
* <p>0 to disable. * <p>0 to disable.
*/ */
@@ -216,15 +203,15 @@ public interface CommaFeedConfiguration {
Duration userInactivityPeriod(); Duration userInactivityPeriod();
/** /**
* Duration after which the evaluation of a filtering expresion to mark an entry as read is * Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have
* considered to have timed out. * timed out.
*/ */
@WithDefault("500ms") @WithDefault("500ms")
Duration filteringExpressionEvaluationTimeout(); Duration filteringExpressionEvaluationTimeout();
/** /**
* Duration after which the "Fetch all my feeds now" action is available again after use to * Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming
* avoid spamming feeds. * feeds.
*/ */
@WithDefault("0") @WithDefault("0")
Duration forceRefreshCooldownDuration(); Duration forceRefreshCooldownDuration();
@@ -240,10 +227,7 @@ public interface CommaFeedConfiguration {
@WithDefault("5") @WithDefault("5")
int threads(); int threads();
/** /** Maximum amount of notifications that can be queued before new notifications are discarded. */
* Maximum amount of notifications that can be queued before new notifications are
* discarded.
*/
@Min(1) @Min(1)
@WithDefault("100") @WithDefault("100")
int queueCapacity(); int queueCapacity();
@@ -256,8 +240,8 @@ public interface CommaFeedConfiguration {
int retriesBeforeBackoff(); int retriesBeforeBackoff();
/** /**
* Duration to wait before retrying after an error. Will be multiplied by the number of * Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last
* errors since the last successful fetch. * successful fetch.
*/ */
@WithDefault("1h") @WithDefault("1h")
Duration backoffInterval(); Duration backoffInterval();
@@ -286,8 +270,7 @@ public interface CommaFeedConfiguration {
Duration entriesMaxAge(); Duration entriesMaxAge();
/** /**
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will * Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted.
* be deleted.
* *
* <p>0 to disable. * <p>0 to disable.
*/ */
@@ -320,9 +303,7 @@ public interface CommaFeedConfiguration {
boolean keepStarredEntries(); boolean keepStarredEntries();
default Instant statusesInstantThreshold() { default Instant statusesInstantThreshold() {
return statusesMaxAge().toMillis() > 0 return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null;
? Instant.now().minus(statusesMaxAge())
: null;
} }
} }
} }
@@ -346,23 +327,17 @@ public interface CommaFeedConfiguration {
} }
interface Websocket { interface Websocket {
/** /** Enable websocket connection so the server can notify web clients that there are new entries for feeds. */
* Enable websocket connection so the server can notify web clients that there are new
* entries for feeds.
*/
@WithDefault("true") @WithDefault("true")
boolean enabled(); boolean enabled();
/** /** Interval at which the client will send a ping message on the websocket to keep the connection alive. */
* Interval at which the client will send a ping message on the websocket to keep the
* connection alive.
*/
@WithDefault("15m") @WithDefault("15m")
Duration pingInterval(); Duration pingInterval();
/** /**
* If the websocket connection is disabled or the connection is lost, the client will reload * If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at
* the feed tree at this interval. * this interval.
*/ */
@WithDefault("30s") @WithDefault("30s")
Duration treeReloadInterval(); Duration treeReloadInterval();

View File

@@ -10,7 +10,10 @@ import io.quarkus.security.UnauthorizedException;
import jakarta.annotation.Priority; import jakarta.annotation.Priority;
import jakarta.validation.ValidationException; import jakarta.validation.ValidationException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie; import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -29,11 +32,9 @@ public class ExceptionMappers {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
@ServerExceptionMapper(CommaFeedApplicationException.class) @ServerExceptionMapper(CommaFeedApplicationException.class)
public RestResponse<CommaFeedApplicationError> applicationError( public RestResponse<CommaFeedApplicationError> applicationError(CommaFeedApplicationException e) {
CommaFeedApplicationException e) {
ResponseBuilder<CommaFeedApplicationError> response = ResponseBuilder<CommaFeedApplicationError> response =
ResponseBuilder.create( ResponseBuilder.create(e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
if (e.type().status() == Status.UNAUTHORIZED) { if (e.type().status() == Status.UNAUTHORIZED) {
response.cookie(cookieService.buildLogoutCookie()); response.cookie(cookieService.buildLogoutCookie());
} }
@@ -43,14 +44,24 @@ public class ExceptionMappers {
@ServerExceptionMapper(UnauthorizedException.class) @ServerExceptionMapper(UnauthorizedException.class)
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) { public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
return RestResponse.status( return RestResponse.status(
Status.UNAUTHORIZED, Status.UNAUTHORIZED, new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
} }
@ServerExceptionMapper(AuthenticationFailedException.class) @ServerExceptionMapper(AuthenticationFailedException.class)
public RestResponse<AuthenticationFailed> authenticationFailed( public RestResponse<AuthenticationFailed> authenticationFailed(
AuthenticationFailedException e) { AuthenticationFailedException e, HttpHeaders headers, UriInfo uriInfo) {
NewCookie logoutCookie = cookieService.buildLogoutCookie(); NewCookie logoutCookie = cookieService.buildLogoutCookie();
// a browser navigating with an invalidated session would otherwise display the error
// instead of the page, retry the request now that the cookie is removed
if (headers.getAcceptableMediaTypes().stream()
.anyMatch(m -> MediaType.TEXT_HTML_TYPE.isCompatible(m) && !m.isWildcardType())) {
return ResponseBuilder.<AuthenticationFailed>create(Status.TEMPORARY_REDIRECT)
.location(uriInfo.getRequestUri())
.cookie(logoutCookie)
.build();
}
return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage())) return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()))
.cookie(logoutCookie) .cookie(logoutCookie)
.build(); .build();

View File

@@ -56,27 +56,24 @@ public class HttpClientFactory {
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE; private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress(); private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
private static final IPAddress NAT64_LOCAL_USE_RANGE = new IPAddressString("64:ff9b:1::/48").getAddress();
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final CommaFeedVersion version; private final CommaFeedVersion version;
public CloseableHttpAsyncClient newClient(int poolSize) { public CloseableHttpAsyncClient newClient(int poolSize) {
PoolingAsyncClientConnectionManager connectionManager = PoolingAsyncClientConnectionManager connectionManager = newConnectionManager(config, poolSize);
newConnectionManager(config, poolSize);
String userAgent = String userAgent =
config.httpClient() config.httpClient()
.userAgent() .userAgent()
.orElseGet(() -> .orElseGet(() ->
String.format( String.format(
"CommaFeed/%s (https://github.com/Athou/commafeed)", "CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
version.getVersion()));
return newClient(config, connectionManager, userAgent); return newClient(config, connectionManager, userAgent);
} }
private CloseableHttpAsyncClient newClient( private CloseableHttpAsyncClient newClient(
CommaFeedConfiguration config, CommaFeedConfiguration config, PoolingAsyncClientConnectionManager connectionManager, String userAgent) {
PoolingAsyncClientConnectionManager connectionManager,
String userAgent) {
List<Header> headers = new ArrayList<>(); List<Header> headers = new ArrayList<>();
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en")); headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache")); headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
@@ -84,10 +81,8 @@ public class HttpClientFactory {
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*")); headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate")); headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap = SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap = new LinkedHashMap<>();
new LinkedHashMap<>(); contentDecoderMap.put(ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
contentDecoderMap.put(
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new); contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
RedirectStrategy redirectStrategy = RedirectStrategy redirectStrategy =
@@ -105,8 +100,7 @@ public class HttpClientFactory {
.setDefaultHeaders(headers) .setDefaultHeaders(headers)
.setConnectionManager(connectionManager) .setConnectionManager(connectionManager)
.evictExpiredConnections() .evictExpiredConnections()
.evictIdleConnections( .evictIdleConnections(TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap)) .setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy) .setRedirectStrategy(redirectStrategy)
.build(); .build();
@@ -114,10 +108,8 @@ public class HttpClientFactory {
return client; return client;
} }
private PoolingAsyncClientConnectionManager newConnectionManager( private PoolingAsyncClientConnectionManager newConnectionManager(CommaFeedConfiguration config, int poolSize) {
CommaFeedConfiguration config, int poolSize) { SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
SSLFactory sslFactory =
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
DnsResolver dnsResolver = DnsResolver dnsResolver =
config.httpClient().blockLocalAddresses() config.httpClient().blockLocalAddresses()
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER) ? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
@@ -129,14 +121,12 @@ public class HttpClientFactory {
ConnectionConfig.custom() ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout())) .setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout())) .setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
.setTimeToLive( .setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive()))
Timeout.of(config.httpClient().connectionTimeToLive()))
.build()) .build())
.setDefaultTlsConfig( .setDefaultTlsConfig(
TlsConfig.custom() TlsConfig.custom()
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE) .setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
.setHandshakeTimeout( .setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout()))
Timeout.of(config.httpClient().sslHandshakeTimeout()))
.build()) .build())
.setMaxConnPerRoute(poolSize) .setMaxConnPerRoute(poolSize)
.setMaxConnTotal(poolSize) .setMaxConnTotal(poolSize)
@@ -149,7 +139,11 @@ public class HttpClientFactory {
} }
private static boolean isLocalAddress(IPAddress ip) { private static boolean isLocalAddress(IPAddress ip) {
if (ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip)) { if (ip.isLocal()
|| ip.isLoopback()
|| ip.isMulticast()
|| CGNAT_RANGE.contains(ip)
|| NAT64_LOCAL_USE_RANGE.contains(ip)) {
return true; return true;
} }
@@ -185,8 +179,7 @@ public class HttpClientFactory {
InetAddress[] addresses = delegate.resolve(host); InetAddress[] addresses = delegate.resolve(host);
for (InetAddress addr : addresses) { for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) { if (isLocalAddress(addr)) {
throw new UnknownHostException( throw new UnknownHostException("Access to local address blocked: " + addr.getHostAddress());
"Access to local address blocked: " + addr.getHostAddress());
} }
} }
return addresses; return addresses;
@@ -222,8 +215,7 @@ public class HttpClientFactory {
for (InetAddress addr : addresses) { for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) { if (isLocalAddress(addr)) {
throw new HttpException( throw new HttpException("Access to local address blocked: " + addr.getHostAddress());
"Access to local address blocked: " + addr.getHostAddress());
} }
} }

View File

@@ -75,32 +75,22 @@ public class HttpGetter {
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads()); this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
this.cache = newCache(config); this.cache = newCache(config);
metrics.registerGauge( metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
MetricRegistry.name(getClass(), "cache", "size"),
() -> cache == null ? 0 : cache.size());
metrics.registerGauge( metrics.registerGauge(
MetricRegistry.name(getClass(), "cache", "memoryUsage"), MetricRegistry.name(getClass(), "cache", "memoryUsage"),
() -> () ->
cache == null cache == null
? 0 ? 0
: cache.asMap().values().stream() : cache.asMap().values().stream().mapToInt(e -> ArrayUtils.getLength(e.content)).sum());
.mapToInt(e -> ArrayUtils.getLength(e.content))
.sum());
} }
public HttpResult get(String url) public HttpResult get(String url)
throws IOException, throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
return get(HttpRequest.builder(url).build()); return get(HttpRequest.builder(url).build());
} }
public HttpResult get(HttpRequest request) public HttpResult get(HttpRequest request)
throws IOException, throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
URI uri = URI.create(request.getUrl()); URI uri = URI.create(request.getUrl());
ensureHttpScheme(uri.getScheme()); ensureHttpScheme(uri.getScheme());
@@ -178,8 +168,7 @@ public class HttpGetter {
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback = FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
new FutureCallback<>() { new FutureCallback<>() {
@Override @Override
public void completed( public void completed(Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
@Override @Override
public void failed(Exception ex) {} public void failed(Exception ex) {}
@@ -283,15 +272,13 @@ public class HttpGetter {
} }
return CacheBuilder.newBuilder() return CacheBuilder.newBuilder()
.weigher((HttpRequest key, HttpResponse value) -> .weigher((HttpRequest key, HttpResponse value) -> value.content() != null ? value.content().length : 0)
value.content() != null ? value.content().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue()) .maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration()) .expireAfterWrite(cacheConfig.expiration())
.build(); .build();
} }
private record LimitedContentLengthEntityConsumer( private record LimitedContentLengthEntityConsumer(@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
implements AsyncEntityConsumer<byte[]> { implements AsyncEntityConsumer<byte[]> {
@Override @Override
@@ -324,9 +311,7 @@ public class HttpGetter {
@Override @Override
protected void data(ByteBuffer src, boolean endOfStream) throws IOException { protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
if (out.size() + src.remaining() > maxBytes) { if (out.size() + src.remaining() > maxBytes) {
throw new IOException( throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes));
"Response size exceeds the maximum allowed size (%s bytes)"
.formatted(maxBytes));
} }
byte[] chunk = new byte[src.remaining()]; byte[] chunk = new byte[src.remaining()];
src.get(chunk); src.get(chunk);
@@ -366,8 +351,7 @@ public class HttpGetter {
this(message, null, null); this(message, null, null);
} }
public NotModifiedException( public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) {
String message, String newLastModifiedHeader, String newEtagHeader) {
super(message); super(message);
this.newLastModifiedHeader = newLastModifiedHeader; this.newLastModifiedHeader = newLastModifiedHeader;
this.newEtagHeader = newEtagHeader; this.newEtagHeader = newEtagHeader;

View File

@@ -65,11 +65,7 @@ public class Urls {
try { try {
return URI.create(baseUrl).resolve(relativeUrl).toString(); return URI.create(baseUrl).resolve(relativeUrl).toString();
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.debug( log.debug("Unable to create absolute url from relative url: {} base: {}", relativeUrl, baseUrl, e);
"Unable to create absolute url from relative url: {} base: {}",
relativeUrl,
baseUrl,
e);
return null; return null;
} }
} }
@@ -86,8 +82,8 @@ public class Urls {
} }
/** /**
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to * Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to identify a feed and
* identify a feed and avoid duplicates * avoid duplicates
*/ */
public static String normalize(String url) { public static String normalize(String url) {
if (url == null) { if (url == null) {

View File

@@ -31,9 +31,7 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
} }
public FeedCategory findById(User user, Long id) { public FeedCategory findById(User user, Long id) {
return query().selectFrom(CATEGORY) return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne();
.where(CATEGORY.user.eq(user), CATEGORY.id.eq(id))
.fetchOne();
} }
public FeedCategory findByName(User user, String name, FeedCategory parent) { public FeedCategory findByName(User user, String name, FeedCategory parent) {

View File

@@ -36,10 +36,7 @@ public class FeedDAO extends GenericDAO<Feed> {
// join on subscriptions to only refresh feeds that have subscribers // join on subscriptions to only refresh feeds that have subscribers
.join(SUBSCRIPTION) .join(SUBSCRIPTION)
.on(SUBSCRIPTION.feed.eq(FEED)) .on(SUBSCRIPTION.feed.eq(FEED))
.where( .where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
FEED.disabledUntil
.isNull()
.or(FEED.disabledUntil.lt(Instant.now())));
if (lastLoginThreshold != null) { if (lastLoginThreshold != null) {
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold)); query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
@@ -53,11 +50,7 @@ public class FeedDAO extends GenericDAO<Feed> {
} }
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) { public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
return query() return query().selectFrom(FEED).where(FEED.normalizedUrlHash.eq(normalizedUrlHash)).fetch().stream()
.selectFrom(FEED)
.where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
.fetch()
.stream()
.filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl())) .filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl()))
.findFirst() .findFirst()
.orElse(null); .orElse(null);

View File

@@ -42,8 +42,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
} }
Set<String> result = new HashSet<>(); Set<String> result = new HashSet<>();
for (List<String> batch : for (List<String> batch : Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
result.addAll( result.addAll(
query().select(ENTRY.guidHash) query().select(ENTRY.guidHash)
.from(ENTRY) .from(ENTRY)
@@ -53,8 +52,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
return result; return result;
} }
public List<FeedCapacity> findFeedsExceedingCapacity( public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max, boolean keepStarredEntries) {
long maxCapacity, long max, boolean keepStarredEntries) {
NumberExpression<Long> count = ENTRY.id.count(); NumberExpression<Long> count = ENTRY.id.count();
JPAQuery<Tuple> query = query().select(ENTRY.feed.id, count).from(ENTRY); JPAQuery<Tuple> query = query().select(ENTRY.feed.id, count).from(ENTRY);
@@ -68,8 +66,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
} }
public int delete(Long feedId, long max) { public int delete(Long feedId, long max) {
List<FeedEntry> list = List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
return delete(list); return delete(list);
} }
@@ -91,10 +88,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
/** Delete the oldest entries of a feed */ /** Delete the oldest entries of a feed */
public int deleteOldEntries(Long feedId, long max, boolean keepStarredEntries) { public int deleteOldEntries(Long feedId, long max, boolean keepStarredEntries) {
JPAQuery<FeedEntry> query = JPAQuery<FeedEntry> query =
query().selectFrom(ENTRY) query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max);
.where(ENTRY.feed.id.eq(feedId))
.orderBy(ENTRY.published.asc())
.limit(max);
if (keepStarredEntries) { if (keepStarredEntries) {
query.where(Predicates.isNotStarred(ENTRY)); query.where(Predicates.isNotStarred(ENTRY));

View File

@@ -48,9 +48,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
public FeedEntryStatusDAO( public FeedEntryStatusDAO(
EntityManager entityManager, EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
FeedEntryTagDAO feedEntryTagDAO,
CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class, config); super(entityManager, FeedEntryStatus.class, config);
this.feedEntryTagDAO = feedEntryTagDAO; this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config; this.config = config;
@@ -58,22 +56,16 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) { public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
List<FeedEntryStatus> statuses = List<FeedEntryStatus> statuses =
query().selectFrom(STATUS) query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch();
.where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub))
.fetch();
FeedEntryStatus status = statuses.stream().findFirst().orElse(null); FeedEntryStatus status = statuses.stream().findFirst().orElse(null);
return handleStatus(user, status, sub, entry); return handleStatus(user, status, sub, entry);
} }
/** creates an artificial "unread" status if status is null */ /** creates an artificial "unread" status if status is null */
private FeedEntryStatus handleStatus( private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
if (status == null) { if (status == null) {
Instant statusesInstantThreshold = Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold();
config.database().cleanup().statusesInstantThreshold(); boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold);
boolean read =
statusesInstantThreshold != null
&& entry.getPublished().isBefore(statusesInstantThreshold);
status = new FeedEntryStatus(user, sub, entry); status = new FeedEntryStatus(user, sub, entry);
status.setRead(read); status.setRead(read);
status.setMarkable(!read); status.setMarkable(!read);
@@ -85,8 +77,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private void fetchTags(User user, List<FeedEntryStatus> statuses) { private void fetchTags(User user, List<FeedEntryStatus> statuses) {
Map<Long, List<FeedEntryTag>> tagsByEntryIds = Map<Long, List<FeedEntryTag>> tagsByEntryIds =
feedEntryTagDAO.findByEntries( feedEntryTagDAO.findByEntries(user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
for (FeedEntryStatus status : statuses) { for (FeedEntryStatus status : statuses) {
List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId()); List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId());
status.setTags(tags == null ? List.of() : tags); status.setTags(tags == null ? List.of() : tags);
@@ -258,9 +249,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
Long count = tuple.get(ENTRY.count()); Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max()); Instant published = tuple.get(ENTRY.published.max());
for (FeedSubscription sub : subsByFeedId.get(feedId)) { for (FeedSubscription sub : subsByFeedId.get(feedId)) {
result.put( result.put(sub.getId(), new UnreadCount(sub.getId(), count == null ? 0 : count, published));
sub.getId(),
new UnreadCount(sub.getId(), count == null ? 0 : count, published));
} }
} }
@@ -303,8 +292,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.gt(0)); where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.gt(0));
NumberExpression<Integer> daysDiff = NumberExpression<Integer> daysDiff =
Expressions.numberTemplate( Expressions.numberTemplate(Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
where.and(daysDiff.goe(SUBSCRIPTION.autoMarkAsReadAfterDays)); where.and(daysDiff.goe(SUBSCRIPTION.autoMarkAsReadAfterDays));
where.and(buildUnreadPredicate()); where.and(buildUnreadPredicate());
@@ -333,11 +321,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
.toList(); .toList();
if (!statusIdsToUpdate.isEmpty()) { if (!statusIdsToUpdate.isEmpty()) {
updated += updated += updateQuery(STATUS).where(STATUS.id.in(statusIdsToUpdate)).set(STATUS.read, true).execute();
updateQuery(STATUS)
.where(STATUS.id.in(statusIdsToUpdate))
.set(STATUS.read, true)
.execute();
} }
// Insert new statuses for entries without existing status // Insert new statuses for entries without existing status

View File

@@ -31,11 +31,7 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
} }
public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) { public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) {
return query() return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.in(entries)).fetch().stream()
.selectFrom(TAG)
.where(TAG.user.eq(user), TAG.entry.in(entries))
.fetch()
.stream()
.collect(Collectors.groupingBy(t -> t.getEntry().getId())); .collect(Collectors.groupingBy(t -> t.getEntry().getId()));
} }
} }

View File

@@ -84,9 +84,7 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
public FeedSubscription findByFeed(User user, Feed feed) { public FeedSubscription findByFeed(User user, Feed feed) {
List<FeedSubscription> subs = List<FeedSubscription> subs =
query().selectFrom(SUBSCRIPTION) query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed)).fetch();
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed))
.fetch();
FeedSubscription sub = subs.stream().findFirst().orElse(null); FeedSubscription sub = subs.stream().findFirst().orElse(null);
return initRelations(sub); return initRelations(sub);
} }
@@ -104,15 +102,11 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
} }
public Long count(User user) { public Long count(User user) {
return query().select(SUBSCRIPTION.count()) return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne();
.from(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user))
.fetchOne();
} }
public List<FeedSubscription> findByCategory(User user, FeedCategory category) { public List<FeedSubscription> findByCategory(User user, FeedCategory category) {
JPQLQuery<FeedSubscription> query = JPQLQuery<FeedSubscription> query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
if (category == null) { if (category == null) {
query.where(SUBSCRIPTION.category.isNull()); query.where(SUBSCRIPTION.category.isNull());
} else { } else {
@@ -122,11 +116,9 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
} }
public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) { public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) {
Set<Long> categoryIds = Set<Long> categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
return findAll(user).stream() return findAll(user).stream()
.filter(s -> .filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
.toList(); .toList();
} }

View File

@@ -37,9 +37,7 @@ public class FacebookFaviconFetcher implements FaviconFetcher {
return null; return null;
} }
String iconUrl = String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName);
String.format(
"https://graph.facebook.com/%s/picture?type=square&height=16", userName);
try { try {
log.debug("Getting Facebook user's icon, {}", url); log.debug("Getting Facebook user's icon, {}", url);

View File

@@ -17,8 +17,7 @@ public record Favicon(byte[] icon, MediaType mediaType) {
try { try {
return MediaType.valueOf(contentType); return MediaType.valueOf(contentType);
} catch (Exception e) { } catch (Exception e) {
log.debug( log.debug("invalid content type '{}' received, returning default value", contentType, e);
"invalid content type '{}' received, returning default value", contentType, e);
return DEFAULT_MEDIA_TYPE; return DEFAULT_MEDIA_TYPE;
} }
} }

View File

@@ -32,10 +32,7 @@ public class RootFaviconFetcher implements FaviconFetcher {
URI uri = URI.create(url.trim()); URI uri = URI.create(url.trim());
String faviconUrl = String faviconUrl =
"%s://%s%s/favicon.ico" "%s://%s%s/favicon.ico"
.formatted( .formatted(uri.getScheme(), uri.getHost(), uri.getPort() > 0 ? ":" + uri.getPort() : "");
uri.getScheme(),
uri.getHost(),
uri.getPort() > 0 ? ":" + uri.getPort() : "");
log.debug("getting root icon at {}", faviconUrl); log.debug("getting root icon at {}", faviconUrl);
HttpResult result = getter.get(faviconUrl); HttpResult result = getter.get(faviconUrl);

View File

@@ -37,8 +37,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
private static final JsonPointer CHANNEL_THUMBNAIL_URL = private static final JsonPointer CHANNEL_THUMBNAIL_URL =
JsonPointer.compile("/items/0/snippet/thumbnails/default/url"); JsonPointer.compile("/items/0/snippet/thumbnails/default/url");
private static final JsonPointer PLAYLIST_CHANNEL_ID = private static final JsonPointer PLAYLIST_CHANNEL_ID = JsonPointer.compile("/items/0/snippet/channelId");
JsonPointer.compile("/items/0/snippet/channelId");
private final HttpGetter getter; private final HttpGetter getter;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
@@ -60,17 +59,11 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
try { try {
List<NameValuePair> params = new URIBuilder(url).getQueryParams(); List<NameValuePair> params = new URIBuilder(url).getQueryParams();
Optional<NameValuePair> userId = Optional<NameValuePair> userId =
params.stream() params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst();
.filter(nvp -> nvp.getName().equalsIgnoreCase("user"))
.findFirst();
Optional<NameValuePair> channelId = Optional<NameValuePair> channelId =
params.stream() params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst();
.filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id"))
.findFirst();
Optional<NameValuePair> playlistId = Optional<NameValuePair> playlistId =
params.stream() params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst();
.filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id"))
.findFirst();
byte[] response = null; byte[] response = null;
if (userId.isPresent()) { if (userId.isPresent()) {
@@ -103,10 +96,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
} }
private byte[] fetchForUser(String googleAuthKey, String userId) private byte[] fetchForUser(String googleAuthKey, String userId)
throws IOException, throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
URI uri = URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", PART_SNIPPET) .queryParam("part", PART_SNIPPET)
@@ -117,10 +107,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
} }
private byte[] fetchForChannel(String googleAuthKey, String channelId) private byte[] fetchForChannel(String googleAuthKey, String channelId)
throws IOException, throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
URI uri = URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", PART_SNIPPET) .queryParam("part", PART_SNIPPET)
@@ -131,10 +118,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
} }
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
throws IOException, throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
URI uri = URI uri =
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists") UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
.queryParam("part", PART_SNIPPET) .queryParam("part", PART_SNIPPET)

View File

@@ -36,8 +36,7 @@ public class FeedFetcher {
private final HttpGetter getter; private final HttpGetter getter;
private final List<FeedURLProvider> urlProviders; private final List<FeedURLProvider> urlProviders;
public FeedFetcher( public FeedFetcher(FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
this.parser = parser; this.parser = parser;
this.getter = getter; this.getter = getter;
this.urlProviders = urlProviders; this.urlProviders = urlProviders;
@@ -58,9 +57,7 @@ public class FeedFetcher {
NoFeedFoundException { NoFeedFoundException {
log.debug("Fetching feed {}", feedUrl); log.debug("Fetching feed {}", feedUrl);
HttpResult result = HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
getter.get(
HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
byte[] content = result.content(); byte[] content = result.content();
FeedParserResult parserResult; FeedParserResult parserResult;
@@ -69,19 +66,12 @@ public class FeedFetcher {
} catch (FeedParsingException e) { } catch (FeedParsingException e) {
if (extractFeedUrlFromHtml) { if (extractFeedUrlFromHtml) {
String extractedUrl = String extractedUrl =
extractFeedUrl( extractFeedUrl(urlProviders, feedUrl, new String(result.content(), StandardCharsets.UTF_8));
urlProviders,
feedUrl,
new String(result.content(), StandardCharsets.UTF_8));
if (StringUtils.isNotBlank(extractedUrl)) { if (StringUtils.isNotBlank(extractedUrl)) {
feedUrl = extractedUrl; feedUrl = extractedUrl;
result = result =
getter.get( getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build());
HttpRequest.builder(extractedUrl)
.lastModified(lastModified)
.eTag(eTag)
.build());
content = result.content(); content = result.content();
parserResult = parser.parse(result.urlAfterRedirect(), content); parserResult = parser.parse(result.urlAfterRedirect(), content);
} else { } else {
@@ -96,8 +86,7 @@ public class FeedFetcher {
throw new IOException("Feed content is empty."); throw new IOException("Feed content is empty.");
} }
boolean lastModifiedHeaderValueChanged = boolean lastModifiedHeaderValueChanged = !Strings.CS.equals(lastModified, result.lastModifiedSince());
!Strings.CS.equals(lastModified, result.lastModifiedSince());
boolean etagHeaderValueChanged = !Strings.CS.equals(eTag, result.eTag()); boolean etagHeaderValueChanged = !Strings.CS.equals(eTag, result.eTag());
String hash = Digests.sha1Hex(content); String hash = Digests.sha1Hex(content);
@@ -109,8 +98,7 @@ public class FeedFetcher {
etagHeaderValueChanged ? result.eTag() : null); etagHeaderValueChanged ? result.eTag() : null);
} }
if (lastPublishedDate != null if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) {
&& lastPublishedDate.equals(parserResult.lastPublishedDate())) {
log.debug("publishedDate not modified: {}", feedUrl); log.debug("publishedDate not modified: {}", feedUrl);
throw new NotModifiedException( throw new NotModifiedException(
"publishedDate not modified", "publishedDate not modified",
@@ -127,8 +115,7 @@ public class FeedFetcher {
result.validFor()); result.validFor());
} }
private static String extractFeedUrl( private static String extractFeedUrl(List<FeedURLProvider> urlProviders, String url, String urlContent) {
List<FeedURLProvider> urlProviders, String url, String urlContent) {
return urlProviders.stream() return urlProviders.stream()
.flatMap(provider -> provider.get(url, urlContent).stream()) .flatMap(provider -> provider.get(url, urlContent).stream())
.filter(StringUtils::isNotBlank) .filter(StringUtils::isNotBlank)

View File

@@ -67,8 +67,7 @@ public class FeedRefreshEngine {
this.queue = new LinkedBlockingDeque<>(); this.queue = new LinkedBlockingDeque<>();
metrics.register( metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register( metrics.register(
MetricRegistry.name(getClass(), "worker", "active"), MetricRegistry.name(getClass(), "worker", "active"),
(Gauge<Integer>) () -> workerExecutor.getActiveCount()); (Gauge<Integer>) () -> workerExecutor.getActiveCount());
@@ -88,12 +87,10 @@ public class FeedRefreshEngine {
this.refillLoopExecutor = Executors.newSingleThreadExecutor(); this.refillLoopExecutor = Executors.newSingleThreadExecutor();
this.refillExecutor = newDiscardingSingleThreadExecutorService(); this.refillExecutor = newDiscardingSingleThreadExecutorService();
this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads()); this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads());
this.databaseUpdaterExecutor = this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads());
newBlockingExecutorService(config.feedRefresh().databaseThreads());
this.notifierExecutor = this.notifierExecutor =
newDiscardingExecutorService( newDiscardingExecutorService(
config.pushNotifications().threads(), config.pushNotifications().threads(), config.pushNotifications().queueCapacity());
config.pushNotifications().queueCapacity());
} }
public void start() { public void start() {
@@ -169,9 +166,7 @@ public class FeedRefreshEngine {
refill.mark(); refill.mark();
List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize()); List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize());
log.debug( log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size());
"found {} feeds that are up for refresh",
nextUpdatableFeeds.size());
for (Feed feed : nextUpdatableFeeds) { for (Feed feed : nextUpdatableFeeds) {
// add the feed only if it was not already queued // add the feed only if it was not already queued
if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) { if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) {
@@ -199,8 +194,7 @@ public class FeedRefreshEngine {
notifier.notifyOverWebsocket(sub, entries); notifier.notifyOverWebsocket(sub, entries);
return CompletableFuture.runAsync( return CompletableFuture.runAsync(
() -> notifier.sendPushNotifications(sub, entries), () -> notifier.sendPushNotifications(sub, entries), notifierExecutor);
notifierExecutor);
}) })
.toList(); .toList();
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)); return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
@@ -222,8 +216,7 @@ public class FeedRefreshEngine {
// update disabledUntil to prevent feeds from being returned again by // update disabledUntil to prevent feeds from being returned again by
// feedDAO.findNextUpdatable() // feedDAO.findNextUpdatable()
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval()); Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil( feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
} }
return feeds; return feeds;
}); });
@@ -234,26 +227,19 @@ public class FeedRefreshEngine {
} }
public void stop() { public void stop() {
MoreExecutors.shutdownAndAwaitTermination( MoreExecutors.shutdownAndAwaitTermination(this.feedProcessingLoopExecutor, config.shutdownTimeout());
this.feedProcessingLoopExecutor, config.shutdownTimeout()); MoreExecutors.shutdownAndAwaitTermination(this.refillLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(
this.refillLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.refillExecutor, config.shutdownTimeout()); MoreExecutors.shutdownAndAwaitTermination(this.refillExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.workerExecutor, config.shutdownTimeout()); MoreExecutors.shutdownAndAwaitTermination(this.workerExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination( MoreExecutors.shutdownAndAwaitTermination(this.databaseUpdaterExecutor, config.shutdownTimeout());
this.databaseUpdaterExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.notifierExecutor, config.shutdownTimeout()); MoreExecutors.shutdownAndAwaitTermination(this.notifierExecutor, config.shutdownTimeout());
queue.clear(); queue.clear();
} }
/** /** returns an ExecutorService with a single thread that discards tasks if a task is already running */
* returns an ExecutorService with a single thread that discards tasks if a task is already
* running
*/
private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() { private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() {
ThreadPoolExecutor pool = ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return pool; return pool;
} }
@@ -262,11 +248,7 @@ public class FeedRefreshEngine {
private ThreadPoolExecutor newDiscardingExecutorService(int threads, int queueCapacity) { private ThreadPoolExecutor newDiscardingExecutorService(int threads, int queueCapacity) {
ThreadPoolExecutor pool = ThreadPoolExecutor pool =
new ThreadPoolExecutor( new ThreadPoolExecutor(
threads, threads, threads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(queueCapacity));
threads,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(queueCapacity));
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return pool; return pool;
} }
@@ -274,8 +256,7 @@ public class FeedRefreshEngine {
/** returns an ExecutorService that blocks submissions until a thread is available */ /** returns an ExecutorService that blocks submissions until a thread is available */
private ThreadPoolExecutor newBlockingExecutorService(int threads) { private ThreadPoolExecutor newBlockingExecutorService(int threads) {
ThreadPoolExecutor pool = ThreadPoolExecutor pool =
new ThreadPoolExecutor( new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler((r, e) -> { pool.setRejectedExecutionHandler((r, e) -> {
if (e.isShutdown()) { if (e.isShutdown()) {
return; return;

View File

@@ -22,8 +22,7 @@ public class FeedRefreshIntervalCalculator {
private final FeedRefreshErrorHandling errorHandling; private final FeedRefreshErrorHandling errorHandling;
private final InstantSource instantSource; private final InstantSource instantSource;
public FeedRefreshIntervalCalculator( public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) {
CommaFeedConfiguration config, InstantSource instantSource) {
this.interval = config.feedRefresh().interval(); this.interval = config.feedRefresh().interval();
this.maxInterval = config.feedRefresh().maxInterval(); this.maxInterval = config.feedRefresh().maxInterval();
this.empirical = config.feedRefresh().intervalEmpirical(); this.empirical = config.feedRefresh().intervalEmpirical();
@@ -31,8 +30,7 @@ public class FeedRefreshIntervalCalculator {
this.instantSource = instantSource; this.instantSource = instantSource;
} }
public Instant onFetchSuccess( public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) {
Instant publishedDate, Long averageEntryInterval, Duration validFor) {
Instant instant = Instant instant =
empirical empirical
? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval) ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval)
@@ -54,14 +52,11 @@ public class FeedRefreshIntervalCalculator {
} }
Duration retryInterval = Duration retryInterval =
errorHandling errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
.backoffInterval()
.multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
return constrainToBounds(instantSource.instant().plus(retryInterval)); return constrainToBounds(instantSource.instant().plus(retryInterval));
} }
private Instant computeEmpiricalRefreshInterval( private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) {
Instant publishedDate, Long averageEntryInterval) {
Instant now = instantSource.instant(); Instant now = instantSource.instant();
if (publishedDate == null) { if (publishedDate == null) {
@@ -80,9 +75,7 @@ public class FeedRefreshIntervalCalculator {
int factor = 2; int factor = 2;
long millis = long millis =
Longs.constrainToRange( Longs.constrainToRange(
averageEntryInterval / factor, averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis());
interval.toMillis(),
maxInterval.dividedBy(4).toMillis());
return now.plusMillis(millis); return now.plusMillis(millis);
} else { } else {
// unknown case // unknown case

View File

@@ -64,8 +64,7 @@ public class FeedRefreshUpdater {
entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted")); entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted"));
} }
private AddEntryResult addEntry( private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
boolean processed = false; boolean processed = false;
FeedEntry insertedEntry = null; FeedEntry insertedEntry = null;
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>(); Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
@@ -111,11 +110,7 @@ public class FeedRefreshUpdater {
log.error("lock timeout for {} - {}", feed.getUrl(), key1); log.error("lock timeout for {} - {}", feed.getUrl(), key1);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.error( log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e);
"interrupted while waiting for lock for {} : {}",
feed.getUrl(),
e.getMessage(),
e);
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} finally { } finally {
if (locked1) { if (locked1) {
@@ -131,13 +126,11 @@ public class FeedRefreshUpdater {
public FeedRefreshUpdaterResult update(Feed feed, List<Entry> entries) { public FeedRefreshUpdaterResult update(Feed feed, List<Entry> entries) {
boolean processed = true; boolean processed = true;
long inserted = 0; long inserted = 0;
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription = Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription = new HashMap<>();
new HashMap<>();
if (!entries.isEmpty()) { if (!entries.isEmpty()) {
List<FeedSubscription> subscriptions = null; List<FeedSubscription> subscriptions = null;
List<Entry> newEntries = List<Entry> newEntries = unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
for (Entry entry : newEntries) { for (Entry entry : newEntries) {
if (subscriptions == null) { if (subscriptions == null) {
subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed)); subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed));
@@ -176,9 +169,7 @@ public class FeedRefreshUpdater {
} }
private record AddEntryResult( private record AddEntryResult(
boolean processed, boolean processed, FeedEntry insertedEntry, Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
FeedEntry insertedEntry,
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
public record FeedRefreshUpdaterResult( public record FeedRefreshUpdaterResult(
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription) {} Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription) {}

View File

@@ -22,8 +22,8 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link * Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater}
* FeedRefreshUpdater} does that) * does that)
*/ */
@Slf4j @Slf4j
@Singleton @Singleton
@@ -68,10 +68,7 @@ public class FeedRefreshWorker {
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
if (!entriesMaxAge.isZero()) { if (!entriesMaxAge.isZero()) {
Instant threshold = Instant.now().minus(entriesMaxAge); Instant threshold = Instant.now().minus(entriesMaxAge);
entries = entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
entries.stream()
.filter(entry -> entry.published().isAfter(threshold))
.toList();
} }
String urlAfterRedirect = result.urlAfterRedirect(); String urlAfterRedirect = result.urlAfterRedirect();
@@ -121,9 +118,7 @@ public class FeedRefreshWorker {
feed.setErrorCount(feed.getErrorCount() + 1); feed.setErrorCount(feed.getErrorCount() + 1);
feed.setMessage("Server indicated that we are sending too many requests"); feed.setMessage("Server indicated that we are sending too many requests");
feed.setDisabledUntil( feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount()));
refreshIntervalCalculator.onTooManyRequests(
e.getRetryAfter(), feed.getErrorCount()));
return new FeedRefreshWorkerResult(feed, Collections.emptyList()); return new FeedRefreshWorkerResult(feed, Collections.emptyList());
} catch (Exception e) { } catch (Exception e) {

View File

@@ -30,15 +30,12 @@ public class FeedUpdateNotifier {
public void notifyOverWebsocket(FeedSubscription sub, List<FeedEntry> entries) { public void notifyOverWebsocket(FeedSubscription sub, List<FeedEntry> entries) {
if (!entries.isEmpty()) { if (!entries.isEmpty()) {
webSocketSessions.sendMessage( webSocketSessions.sendMessage(sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
} }
} }
public void sendPushNotifications(FeedSubscription sub, List<FeedEntry> entries) { public void sendPushNotifications(FeedSubscription sub, List<FeedEntry> entries) {
if (!config.pushNotifications().enabled() if (!config.pushNotifications().enabled() || !sub.isPushNotificationsEnabled() || entries.isEmpty()) {
|| !sub.isPushNotificationsEnabled()
|| entries.isEmpty()) {
return; return;
} }

View File

@@ -40,8 +40,7 @@ public class FeedUtils {
return false; return false;
} }
return TextDirectionDetector.detect(stripped) return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT;
== TextDirectionDetector.Direction.RIGHT_TO_LEFT;
} }
public static String getFaviconUrl(FeedSubscription subscription) { public static String getFaviconUrl(FeedSubscription subscription) {

View File

@@ -14,8 +14,8 @@ import java.nio.charset.Charset;
public class EncodingDetector { public class EncodingDetector {
/** /**
* Detect feed encoding by using the declared encoding in the xml processing instruction and by * Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the
* detecting the characters used in the feed * characters used in the feed
*/ */
public Charset getEncoding(byte[] bytes) { public Charset getEncoding(byte[] bytes) {
String extracted = extractDeclaredEncoding(bytes); String extracted = extractDeclaredEncoding(bytes);

View File

@@ -42,14 +42,12 @@ import java.util.stream.Collectors;
@Singleton @Singleton
public class FeedParser { public class FeedParser {
private static final Namespace ATOM_10_NS = private static final Namespace ATOM_10_NS = Namespace.getNamespace("http://www.w3.org/2005/Atom");
Namespace.getNamespace("http://www.w3.org/2005/Atom");
private static final Instant START = Instant.ofEpochMilli(86400000); private static final Instant START = Instant.ofEpochMilli(86400000);
private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000); private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000);
private static final Comparator<Entry> ENTRY_COMPARATOR = private static final Comparator<Entry> ENTRY_COMPARATOR = Comparator.comparing(Entry::published).reversed();
Comparator.comparing(Entry::published).reversed();
private final EncodingDetector encodingDetector; private final EncodingDetector encodingDetector;
private final XMLCleaner xmlCleaner; private final XMLCleaner xmlCleaner;
@@ -78,20 +76,13 @@ public class FeedParser {
List<Entry> entries = buildEntries(feed, feedUrl); List<Entry> entries = buildEntries(feed, feedUrl);
Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null); Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null);
Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false); Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false);
if (lastPublishedDate == null if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
|| lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
lastPublishedDate = lastEntryDate; lastPublishedDate = lastEntryDate;
} }
Long averageEntryInterval = averageTimeBetweenEntries(entries); Long averageEntryInterval = averageTimeBetweenEntries(entries);
return new FeedParserResult( return new FeedParserResult(
title, title, link, iconUrl, lastPublishedDate, averageEntryInterval, lastEntryDate, entries);
link,
iconUrl,
lastPublishedDate,
averageEntryInterval,
lastEntryDate,
entries);
} catch (FeedParsingException e) { } catch (FeedParsingException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
@@ -151,9 +142,7 @@ public class FeedParser {
String author = StringUtils.trimToNull(item.getAuthor()); String author = StringUtils.trimToNull(item.getAuthor());
String categories = String categories =
StringUtils.trimToNull( StringUtils.trimToNull(
item.getCategories().stream() item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", ")));
.map(SyndCategory::getName)
.collect(Collectors.joining(", ")));
Enclosure enclosure = buildEnclosure(item); Enclosure enclosure = buildEnclosure(item);
Media media = buildMedia(item); Media media = buildMedia(item);
@@ -281,9 +270,7 @@ public class FeedParser {
SummaryStatistics stats = new SummaryStatistics(); SummaryStatistics stats = new SummaryStatistics();
for (int i = 0; i < entries.size() - 1; i++) { for (int i = 0; i < entries.size() - 1; i++) {
long diff = long diff =
Math.abs( Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli());
entries.get(i).published().toEpochMilli()
- entries.get(i + 1).published().toEpochMilli());
stats.addValue(diff); stats.addValue(diff);
} }
return (long) stats.getMean(); return (long) stats.getMean();

View File

@@ -14,18 +14,9 @@ public record FeedParserResult(
public record Entry(String guid, String url, Instant published, Content content) {} public record Entry(String guid, String url, Instant published, Content content) {}
public record Content( public record Content(
String title, String title, String content, String author, String categories, Enclosure enclosure, Media media) {}
String content,
String author,
String categories,
Enclosure enclosure,
Media media) {}
public record Enclosure(String url, String type) {} public record Enclosure(String url, String type) {}
public record Media( public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) {}
String description,
String thumbnailUrl,
Integer thumbnailWidth,
Integer thumbnailHeight) {}
} }

View File

@@ -13,11 +13,9 @@ import java.util.regex.Pattern;
@Singleton @Singleton
public class XMLCleaner { public class XMLCleaner {
private static final Pattern DOCTYPE_PATTERN = private static final Pattern DOCTYPE_PATTERN = Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
private final Trie trie = private final Trie trie = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
public String clean(String xml) { public String clean(String xml) {
xml = removeCharactersBeforeFirstXmlTag(xml); xml = removeCharactersBeforeFirstXmlTag(xml);

View File

@@ -42,6 +42,9 @@ public class User extends AbstractModel {
@Column(nullable = false) @Column(nullable = false)
private boolean disabled; private boolean disabled;
@Column(nullable = false)
private int sessionVersion;
@Column private Instant lastLogin; @Column private Instant lastLogin;
@Column private Instant created; @Column private Instant created;

View File

@@ -155,8 +155,7 @@ public class UserSettings extends AbstractModel {
private boolean instapaper; private boolean instapaper;
private boolean buffer; private boolean buffer;
@Embedded @Embedded private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
@Embeddable @Embeddable
@SuppressWarnings("serial") @SuppressWarnings("serial")

View File

@@ -49,8 +49,7 @@ public class OPMLExporter {
} }
// export root subscriptions // export root subscriptions
for (FeedSubscription sub : for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
opml.getOutlines().add(buildSubscriptionOutline(sub)); opml.getOutlines().add(buildSubscriptionOutline(sub));
} }
@@ -65,17 +64,14 @@ public class OPMLExporter {
for (FeedCategory child : for (FeedCategory child :
categories.stream() categories.stream()
.filter(c -> .filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId()))
c.getParent() != null && c.getParent().getId().equals(cat.getId()))
.toList()) { .toList()) {
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions)); outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
} }
for (FeedSubscription sub : for (FeedSubscription sub :
subscriptions.stream() subscriptions.stream()
.filter(s -> .filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId()))
s.getCategory() != null
&& s.getCategory().getId().equals(cat.getId()))
.toList()) { .toList()) {
outline.getChildren().add(buildSubscriptionOutline(sub)); outline.getChildren().add(buildSubscriptionOutline(sub));
} }

View File

@@ -79,8 +79,7 @@ public class OPMLImporter {
} }
// make sure we continue with the import process even if a feed failed // make sure we continue with the import process even if a feed failed
try { try {
feedSubscriptionService.subscribe( feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position);
user, outline.getXmlUrl(), name, parent, position);
} catch (Exception e) { } catch (Exception e) {
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage()); log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
} }

View File

@@ -33,10 +33,8 @@ import java.util.List;
public class FeedEntryContentCleaningService { public class FeedEntryContentCleaningService {
private static final Safelist HTML_WHITELIST = buildWhiteList(); private static final Safelist HTML_WHITELIST = buildWhiteList();
private static final List<String> ALLOWED_IFRAME_CSS_RULES = private static final List<String> ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border");
Arrays.asList("height", "width", "border"); private static final List<String> ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height");
private static final List<String> ALLOWED_IMG_CSS_RULES =
Arrays.asList("display", "width", "height");
private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] {'(', ')'}; private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] {'(', ')'};
public String clean(String content, String baseUri, boolean keepTextOnly) { public String clean(String content, String baseUri, boolean keepTextOnly) {
@@ -59,8 +57,7 @@ public class FeedEntryContentCleaningService {
e.attr("style", escaped); e.attr("style", escaped);
} }
clean.outputSettings( clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
Element body = clean.body(); Element body = clean.body();
if (keepTextOnly) { if (keepTextOnly) {
content = body.text(); content = body.text();
@@ -126,24 +123,13 @@ public class FeedEntryContentCleaningService {
whitelist.addAttributes("blockquote", "cite"); whitelist.addAttributes("blockquote", "cite");
whitelist.addAttributes("col", "span", "width"); whitelist.addAttributes("col", "span", "width");
whitelist.addAttributes("colgroup", "span", "width"); whitelist.addAttributes("colgroup", "span", "width");
whitelist.addAttributes( whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
"iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style"); whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style");
whitelist.addAttributes("ol", "start", "type"); whitelist.addAttributes("ol", "start", "type");
whitelist.addAttributes("q", "cite"); whitelist.addAttributes("q", "cite");
whitelist.addAttributes("table", "border", "bordercolor", "summary", "width"); whitelist.addAttributes("table", "border", "bordercolor", "summary", "width");
whitelist.addAttributes( whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
"td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width"); whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width");
whitelist.addAttributes(
"th",
"border",
"bordercolor",
"abbr",
"axis",
"colspan",
"rowspan",
"scope",
"width");
whitelist.addAttributes("ul", "type"); whitelist.addAttributes("ul", "type");
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto"); whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
@@ -161,8 +147,7 @@ public class FeedEntryContentCleaningService {
String rule = ""; String rule = "";
try { try {
List<String> rules = new ArrayList<>(); List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl = CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) { for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i); String property = decl.item(i);
@@ -187,8 +172,7 @@ public class FeedEntryContentCleaningService {
String rule = ""; String rule = "";
try { try {
List<String> rules = new ArrayList<>(); List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl = CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) { for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i); String property = decl.item(i);
@@ -216,8 +200,7 @@ public class FeedEntryContentCleaningService {
new ErrorHandler() { new ErrorHandler() {
@Override @Override
public void warning(CSSParseException exception) throws CSSException { public void warning(CSSParseException exception) throws CSSException {
log.debug( log.debug("warning while parsing css: {}", exception.getMessage(), exception);
"warning while parsing css: {}", exception.getMessage(), exception);
} }
@Override @Override
@@ -227,10 +210,7 @@ public class FeedEntryContentCleaningService {
@Override @Override
public void fatalError(CSSParseException exception) throws CSSException { public void fatalError(CSSParseException exception) throws CSSException {
log.debug( log.debug("fatal error while parsing css: {}", exception.getMessage(), exception);
"fatal error while parsing css: {}",
exception.getMessage(),
exception);
} }
}); });

View File

@@ -27,9 +27,7 @@ public class FeedEntryContentService {
public FeedEntryContent findOrCreate(Content content, String baseUrl) { public FeedEntryContent findOrCreate(Content content, String baseUrl) {
FeedEntryContent entryContent = buildContent(content, baseUrl); FeedEntryContent entryContent = buildContent(content, baseUrl);
Optional<FeedEntryContent> existing = Optional<FeedEntryContent> existing =
feedEntryContentDAO feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash()).stream()
.findExisting(entryContent.getContentHash(), entryContent.getTitleHash())
.stream()
.filter(entryContent::equivalentTo) .filter(entryContent::equivalentTo)
.findFirst(); .findFirst();
if (existing.isPresent()) { if (existing.isPresent()) {
@@ -44,11 +42,9 @@ public class FeedEntryContentService {
FeedEntryContent entryContent = new FeedEntryContent(); FeedEntryContent entryContent = new FeedEntryContent();
entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title()))); entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title())));
entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content()))); entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content())));
entryContent.setTitle( entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false)); entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false));
entryContent.setAuthor( entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096)); entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096));
entryContent.setDirection( entryContent.setDirection(
FeedUtils.isRTL(content.title(), content.content()) FeedUtils.isRTL(content.title(), content.content())
@@ -63,8 +59,7 @@ public class FeedEntryContentService {
Media media = content.media(); Media media = content.media();
if (media != null) { if (media != null) {
entryContent.setMediaDescription( entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false));
cleaningService.clean(media.description(), baseUrl, false));
entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048)); entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048));
entryContent.setMediaThumbnailWidth(media.thumbnailWidth()); entryContent.setMediaThumbnailWidth(media.thumbnailWidth());
entryContent.setMediaThumbnailHeight(media.thumbnailHeight()); entryContent.setMediaThumbnailHeight(media.thumbnailHeight());

View File

@@ -50,27 +50,17 @@ public class FeedEntryFilteringService {
private final ExecutorService executor = Executors.newCachedThreadPool(); private final ExecutorService executor = Executors.newCachedThreadPool();
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
public boolean filterMatchesEntry(String filter, FeedEntry entry) public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException {
throws FeedEntryFilterException {
if (StringUtils.isBlank(filter)) { if (StringUtils.isBlank(filter)) {
return true; return true;
} }
String title = String title = entry.getContent().getTitle() == null ? "" : Jsoup.parse(entry.getContent().getTitle()).text();
entry.getContent().getTitle() == null String author = entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
? ""
: Jsoup.parse(entry.getContent().getTitle()).text();
String author =
entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
String content = String content =
entry.getContent().getContent() == null entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text();
? ""
: Jsoup.parse(entry.getContent().getContent()).text();
String url = entry.getUrl() == null ? "" : entry.getUrl(); String url = entry.getUrl() == null ? "" : entry.getUrl();
String categories = String categories = entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories();
entry.getContent().getCategories() == null
? ""
: entry.getContent().getCategories();
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("title", title); data.put("title", title);
@@ -97,11 +87,9 @@ public class FeedEntryFilteringService {
TimeUnit.MILLISECONDS); TimeUnit.MILLISECONDS);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new FeedEntryFilterException( throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e);
"interrupted while evaluating expression " + filter, e);
} catch (ExecutionException e) { } catch (ExecutionException e) {
throw new FeedEntryFilterException( throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e);
"Exception while evaluating expression " + filter, e);
} catch (TimeoutException e) { } catch (TimeoutException e) {
throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e); throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e);
} }

View File

@@ -55,12 +55,9 @@ public class FeedEntryService {
} }
public List<Entry> removeExistingEntries(Feed feed, List<Entry> entries) { public List<Entry> removeExistingEntries(Feed feed, List<Entry> entries) {
Set<String> guidHashes = Set<String> guidHashes = entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
Set<String> existingGuidHashes = feedEntryDAO.findExistingGuidHashes(guidHashes, feed); Set<String> existingGuidHashes = feedEntryDAO.findExistingGuidHashes(guidHashes, feed);
return entries.stream() return entries.stream().filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid()))).toList();
.filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid())))
.toList();
} }
public boolean applyFilter(FeedSubscription sub, FeedEntry entry) { public boolean applyFilter(FeedSubscription sub, FeedEntry entry) {
@@ -122,37 +119,22 @@ public class FeedEntryService {
List<FeedEntryKeyword> keywords) { List<FeedEntryKeyword> keywords) {
List<FeedEntryStatus> statuses = List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findBySubscriptions( feedEntryStatusDAO.findBySubscriptions(
user, user, subscriptions, true, keywords, null, -1, -1, null, false, null, null, null);
subscriptions,
true,
keywords,
null,
-1,
-1,
null,
false,
null,
null,
null);
markList(statuses, olderThan, insertedBefore); markList(statuses, olderThan, insertedBefore);
} }
public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) { public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statuses = List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
markList(statuses, olderThan, insertedBefore); markList(statuses, olderThan, insertedBefore);
} }
private void markList( private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statusesToMark = List<FeedEntryStatus> statusesToMark =
statuses.stream() statuses.stream()
.filter(FeedEntryStatus::isMarkable) .filter(FeedEntryStatus::isMarkable)
.filter(s -> { .filter(s -> {
Instant entryDate = s.getEntry().getPublished(); Instant entryDate = s.getEntry().getPublished();
return olderThan == null return olderThan == null || entryDate == null || entryDate.isBefore(olderThan);
|| entryDate == null
|| entryDate.isBefore(olderThan);
}) })
.filter(s -> { .filter(s -> {
Instant insertedDate = s.getEntry().getInserted(); Instant insertedDate = s.getEntry().getInserted();

View File

@@ -36,16 +36,14 @@ public class FeedEntryTagService {
} }
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry); List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
Set<String> existingTagNames = Set<String> existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
List<FeedEntryTag> addList = List<FeedEntryTag> addList =
tagNames.stream() tagNames.stream()
.filter(name -> !existingTagNames.contains(name)) .filter(name -> !existingTagNames.contains(name))
.map(name -> new FeedEntryTag(user, entry, name)) .map(name -> new FeedEntryTag(user, entry, name))
.toList(); .toList();
List<FeedEntryTag> removeList = List<FeedEntryTag> removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
addList.forEach(feedEntryTagDAO::persist); addList.forEach(feedEntryTagDAO::persist);
feedEntryTagDAO.delete(removeList); feedEntryTagDAO.delete(removeList);

View File

@@ -36,8 +36,7 @@ public class FeedFaviconService {
this.defaultFavicon = this.defaultFavicon =
new Favicon( new Favicon(
Resources.toByteArray( Resources.toByteArray(
Objects.requireNonNull( Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))),
getClass().getResource("/images/default_favicon.gif"))),
"image/gif"); "image/gif");
} }

View File

@@ -48,15 +48,13 @@ public class FeedSubscriptionService {
// by the feed engine // by the feed engine
feedSubscriptionDAO.onPostCommitInsert(sub -> { feedSubscriptionDAO.onPostCommitInsert(sub -> {
Feed feed = sub.getFeed(); Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) {
|| feed.getDisabledUntil().isBefore(Instant.now())) {
feedRefreshEngine.refreshImmediately(feed); feedRefreshEngine.refreshImmediately(feed);
} }
}); });
} }
public long subscribe( public long subscribe(User user, String url, String title, FeedCategory category, int position) {
User user, String url, String title, FeedCategory category, int position) {
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser(); Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) { if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
String message = String message =
@@ -98,9 +96,7 @@ public class FeedSubscriptionService {
public void refreshAll(User user) throws ForceFeedRefreshTooSoonException { public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
Instant lastForceRefresh = user.getLastForceRefresh(); Instant lastForceRefresh = user.getLastForceRefresh();
if (lastForceRefresh != null if (lastForceRefresh != null
&& lastForceRefresh && lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) {
.plus(config.feedRefresh().forceRefreshCooldownDuration())
.isAfter(Instant.now())) {
throw new ForceFeedRefreshTooSoonException(); throw new ForceFeedRefreshTooSoonException();
} }

View File

@@ -47,16 +47,13 @@ public class PushNotificationService {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
public PushNotificationService( public PushNotificationService(
HttpClientFactory httpClientFactory, HttpClientFactory httpClientFactory, MetricRegistry metrics, CommaFeedConfiguration config) {
MetricRegistry metrics,
CommaFeedConfiguration config) {
this.httpClient = httpClientFactory.newClient(config.pushNotifications().threads()); this.httpClient = httpClientFactory.newClient(config.pushNotifications().threads());
this.meter = metrics.meter(MetricRegistry.name(getClass(), "notify")); this.meter = metrics.meter(MetricRegistry.name(getClass(), "notify"));
this.config = config; this.config = config;
} }
public void notify( public void notify(PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
if (!config.pushNotifications().enabled() || settings.getType() == null) { if (!config.pushNotifications().enabled() || settings.getType() == null) {
return; return;
} }
@@ -79,9 +76,7 @@ public class PushNotificationService {
case NTFY -> sendNtfy(settings, feedTitle, entryTitle, entryUrl); case NTFY -> sendNtfy(settings, feedTitle, entryTitle, entryUrl);
case GOTIFY -> sendGotify(settings, feedTitle, entryTitle, entryUrl); case GOTIFY -> sendGotify(settings, feedTitle, entryTitle, entryUrl);
case PUSHOVER -> sendPushover(settings, feedTitle, entryTitle, entryUrl); case PUSHOVER -> sendPushover(settings, feedTitle, entryTitle, entryUrl);
default -> default -> throw new IllegalStateException("unsupported notification type: " + settings.getType());
throw new IllegalStateException(
"unsupported notification type: " + settings.getType());
} }
} catch (IOException e) { } catch (IOException e) {
throw new PushNotificationException("Failed to send external notification", e); throw new PushNotificationException("Failed to send external notification", e);
@@ -90,11 +85,7 @@ public class PushNotificationService {
meter.mark(); meter.mark();
} }
private void sendNtfy( private void sendNtfy(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
throws IOException { throws IOException {
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl()); String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
String topic = settings.getTopic(); String topic = settings.getTopic();
@@ -119,11 +110,7 @@ public class PushNotificationService {
send("ntfy", request.build()); send("ntfy", request.build());
} }
private void sendGotify( private void sendGotify(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
PushNotificationUserSettings settings,
String feedTitle,
String entryTitle,
String entryUrl)
throws IOException { throws IOException {
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl()); String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
String token = settings.getUserSecret(); String token = settings.getUserSecret();
@@ -143,8 +130,7 @@ public class PushNotificationService {
new JsonObject() new JsonObject()
.put( .put(
"client::notification", "client::notification",
new JsonObject() new JsonObject().put("click", new JsonObject().put("url", entryUrl))));
.put("click", new JsonObject().put("url", entryUrl))));
} }
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message"); SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
@@ -155,10 +141,7 @@ public class PushNotificationService {
} }
private void sendPushover( private void sendPushover(
PushNotificationUserSettings settings, PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
String feedTitle,
String entryTitle,
String entryUrl)
throws IOException { throws IOException {
String token = settings.getUserSecret(); String token = settings.getUserSecret();
String userKey = settings.getUserId(); String userKey = settings.getUserId();
@@ -177,8 +160,7 @@ public class PushNotificationService {
params.add(new BasicNameValuePair("url", entryUrl)); params.add(new BasicNameValuePair("url", entryUrl));
} }
SimpleRequestBuilder request = SimpleRequestBuilder request = SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
request.setBody( request.setBody(
WWWFormCodec.format(params, StandardCharsets.UTF_8), WWWFormCodec.format(params, StandardCharsets.UTF_8),
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8)); ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
@@ -194,11 +176,9 @@ public class PushNotificationService {
null); null);
try { try {
HttpResponse response = HttpResponse response =
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS) future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS).getHead();
.getHead();
if (response.getCode() >= 400) { if (response.getCode() >= 400) {
throw new PushNotificationException( throw new PushNotificationException(name + " notification failed with status " + response.getCode());
name + " notification failed with status " + response.getCode());
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
future.cancel(true); future.cancel(true);

View File

@@ -53,8 +53,7 @@ public class UserService {
user = userDAO.findByEmail(nameOrEmail); user = userDAO.findByEmail(nameOrEmail);
} }
if (user != null && !user.isDisabled()) { if (user != null && !user.isDisabled()) {
boolean authenticated = boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt());
encryptionService.authenticate(password, user.getPassword(), user.getSalt());
if (authenticated) { if (authenticated) {
performPostLoginActivities(user); performPostLoginActivities(user);
return Optional.of(user); return Optional.of(user);
@@ -107,16 +106,11 @@ public class UserService {
} }
public User register( public User register(
String name, String name, String password, String email, Collection<Role> roles, boolean forceRegistration) {
String password,
String email,
Collection<Role> roles,
boolean forceRegistration) {
if (!forceRegistration) { if (!forceRegistration) {
Preconditions.checkState( Preconditions.checkState(
config.users().allowRegistrations(), config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance");
"Registrations are closed on this CommaFeed instance");
} }
Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken"); Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken");
@@ -155,10 +149,19 @@ public class UserService {
userDAO.delete(user); userDAO.delete(user);
} }
public void changePassword(User user, String password) {
user.setPassword(encryptionService.getEncryptedPassword(password, user.getSalt()));
userDAO.merge(user);
invalidateSessions(user);
}
public void invalidateSessions(User user) {
user.setSessionVersion(user.getSessionVersion() + 1);
}
public String generateApiKey(User user) { public String generateApiKey(User user) {
byte[] key = byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt());
encryptionService.getEncryptedPassword(
UUID.randomUUID().toString(), user.getSalt());
return Digests.sha1Hex(key); return Digests.sha1Hex(key);
} }

View File

@@ -71,18 +71,15 @@ public class DatabaseCleaningService {
return; return;
} }
entriesDeleted = entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
entriesDeletedMeter.mark(entriesDeleted); entriesDeletedMeter.mark(entriesDeleted);
entriesTotal += entriesDeleted; entriesTotal += entriesDeleted;
log.debug("removed {} entries for feeds without subscriptions", entriesTotal); log.debug("removed {} entries for feeds without subscriptions", entriesTotal);
} while (entriesDeleted > 0); } while (entriesDeleted > 0);
} }
deleted = deleted =
unitOfWork.call(() -> unitOfWork.call(
feedDAO.delete( () -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList())));
feedDAO.findByIds(
feeds.stream().map(AbstractModel::getId).toList())));
total += deleted; total += deleted;
log.debug("removed {} feeds without subscriptions", total); log.debug("removed {} feeds without subscriptions", total);
} while (deleted != 0); } while (deleted != 0);
@@ -117,8 +114,7 @@ public class DatabaseCleaningService {
List<FeedCapacity> feeds = List<FeedCapacity> feeds =
unitOfWork.call(() -> unitOfWork.call(() ->
feedEntryDAO.findFeedsExceedingCapacity( feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize, keepStarredEntries));
maxFeedCapacity, batchSize, keepStarredEntries));
if (feeds.isEmpty()) { if (feeds.isEmpty()) {
break; break;
} }
@@ -128,8 +124,7 @@ public class DatabaseCleaningService {
int deleted; int deleted;
do { do {
if (Thread.currentThread().isInterrupted()) { if (Thread.currentThread().isInterrupted()) {
log.info( log.info("interrupted, stopping cleanup of entries exceeding feed capacity");
"interrupted, stopping cleanup of entries exceeding feed capacity");
return; return;
} }
@@ -137,9 +132,7 @@ public class DatabaseCleaningService {
deleted = deleted =
unitOfWork.call(() -> unitOfWork.call(() ->
feedEntryDAO.deleteOldEntries( feedEntryDAO.deleteOldEntries(
feed.id(), feed.id(), Math.min(batchSize, rem), keepStarredEntries));
Math.min(batchSize, rem),
keepStarredEntries));
entriesDeletedMeter.mark(deleted); entriesDeletedMeter.mark(deleted);
total += deleted; total += deleted;
remaining -= deleted; remaining -= deleted;
@@ -161,9 +154,8 @@ public class DatabaseCleaningService {
} }
deleted = deleted =
unitOfWork.call(() -> unitOfWork.call(
feedEntryDAO.deleteEntriesOlderThan( () -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize, keepStarredEntries));
olderThan, batchSize, keepStarredEntries));
entriesDeletedMeter.mark(deleted); entriesDeletedMeter.mark(deleted);
total += deleted; total += deleted;
log.debug("removed {} old entries", total); log.debug("removed {} old entries", total);
@@ -181,9 +173,7 @@ public class DatabaseCleaningService {
return; return;
} }
deleted = deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
unitOfWork.call(
() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
total += deleted; total += deleted;
log.debug("removed {} old read statuses", total); log.debug("removed {} old read statuses", total);
} while (deleted != 0); } while (deleted != 0);

View File

@@ -27,14 +27,12 @@ public class DatabaseStartupService {
} }
/** /**
* Register a postgresql database in liquibase that doesn't escape columns, so that we can use * Register a postgresql database in liquibase that doesn't escape columns, so that we can use lower case columns
* lower case columns
*/ */
@MetaInfServices(Database.class) @MetaInfServices(Database.class)
public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase { public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase {
@Override @Override
public String escapeObjectName( public String escapeObjectName(String objectName, Class<? extends DatabaseObject> objectType) {
String objectName, Class<? extends DatabaseObject> objectType) {
return objectName; return objectName;
} }

View File

@@ -16,8 +16,7 @@ import java.util.List;
public class YoutubeFeedURLProvider implements FeedURLProvider { public class YoutubeFeedURLProvider implements FeedURLProvider {
private static final String PREFIX = "https://www.youtube.com/channel/"; private static final String PREFIX = "https://www.youtube.com/channel/";
private static final String REPLACEMENT_PREFIX = private static final String REPLACEMENT_PREFIX = "https://www.youtube.com/feeds/videos.xml?channel_id=";
"https://www.youtube.com/feeds/videos.xml?channel_id=";
@Override @Override
public List<String> get(String url, String urlContent) { public List<String> get(String url, String urlContent) {

View File

@@ -67,10 +67,7 @@ public class Entry implements Serializable {
@Schema(description = "entry publication date", type = SchemaType.INTEGER, required = true) @Schema(description = "entry publication date", type = SchemaType.INTEGER, required = true)
private Instant date; private Instant date;
@Schema( @Schema(description = "entry insertion date in the database", type = SchemaType.INTEGER, required = true)
description = "entry insertion date in the database",
type = SchemaType.INTEGER,
required = true)
private Instant insertedDate; private Instant insertedDate;
@Schema(description = "feed id", required = true) @Schema(description = "feed id", required = true)
@@ -97,9 +94,7 @@ public class Entry implements Serializable {
@Schema(description = "starred status", required = true) @Schema(description = "starred status", required = true)
private boolean starred; private boolean starred;
@Schema( @Schema(description = "whether the entry is still markable (old entry statuses are discarded)", required = true)
description = "whether the entry is still markable (old entry statuses are discarded)",
required = true)
private boolean markable; private boolean markable;
@Schema(description = "tags", required = true) @Schema(description = "tags", required = true)
@@ -130,10 +125,7 @@ public class Entry implements Serializable {
if (content != null) { if (content != null) {
entry.setRtl(content.getDirection() == FeedEntryContent.Direction.RTL); entry.setRtl(content.getDirection() == FeedEntryContent.Direction.RTL);
entry.setTitle(content.getTitle()); entry.setTitle(content.getTitle());
entry.setContent( entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent());
proxyImages
? FeedUtils.proxyImages(content.getContent())
: content.getContent());
entry.setAuthor(content.getAuthor()); entry.setAuthor(content.getAuthor());
entry.setEnclosureType(content.getEnclosureType()); entry.setEnclosureType(content.getEnclosureType());

View File

@@ -29,14 +29,10 @@ public class Settings implements Serializable {
@Schema(description = "user reads entries in ascending or descending order", required = true) @Schema(description = "user reads entries in ascending or descending order", required = true)
private ReadingOrder readingOrder; private ReadingOrder readingOrder;
@Schema( @Schema(description = "user wants category and feeds with no unread entries shown", required = true)
description = "user wants category and feeds with no unread entries shown",
required = true)
private boolean showRead; private boolean showRead;
@Schema( @Schema(description = "In expanded view, scroll through entries mark them as read", required = true)
description = "In expanded view, scroll through entries mark them as read",
required = true)
private boolean scrollMarks; private boolean scrollMarks;
@Schema(description = "user's custom css for the website") @Schema(description = "user's custom css for the website")
@@ -45,42 +41,33 @@ public class Settings implements Serializable {
@Schema(description = "user's custom js for the website") @Schema(description = "user's custom js for the website")
private String customJs; private String customJs;
@Schema( @Schema(description = "user's preferred scroll speed when navigating between entries", required = true)
description = "user's preferred scroll speed when navigating between entries",
required = true)
private int scrollSpeed; private int scrollSpeed;
@Schema(description = "whether to scroll to the selected entry", required = true) @Schema(description = "whether to scroll to the selected entry", required = true)
private ScrollMode scrollMode; private ScrollMode scrollMode;
@Schema( @Schema(description = "number of entries to keep above the selected entry when scrolling", required = true)
description = "number of entries to keep above the selected entry when scrolling",
required = true)
private int entriesToKeepOnTopWhenScrolling; private int entriesToKeepOnTopWhenScrolling;
@Schema(description = "whether to show the star icon in the header of entries", required = true) @Schema(description = "whether to show the star icon in the header of entries", required = true)
private IconDisplayMode starIconDisplayMode; private IconDisplayMode starIconDisplayMode;
@Schema( @Schema(description = "whether to show the external link icon in the header of entries", required = true)
description = "whether to show the external link icon in the header of entries",
required = true)
private IconDisplayMode externalLinkIconDisplayMode; private IconDisplayMode externalLinkIconDisplayMode;
@Schema(description = "ask for confirmation when marking all entries as read", required = true) @Schema(description = "ask for confirmation when marking all entries as read", required = true)
private boolean markAllAsReadConfirmation; private boolean markAllAsReadConfirmation;
@Schema( @Schema(
description = description = "navigate to the next unread category or feed after marking all entries as read",
"navigate to the next unread category or feed after marking all entries as read",
required = true) required = true)
private boolean markAllAsReadNavigateToNextUnread; private boolean markAllAsReadNavigateToNextUnread;
@Schema(description = "show commafeed's own context menu on right click", required = true) @Schema(description = "show commafeed's own context menu on right click", required = true)
private boolean customContextMenu; private boolean customContextMenu;
@Schema( @Schema(description = "on mobile, show action buttons at the bottom of the screen", required = true)
description = "on mobile, show action buttons at the bottom of the screen",
required = true)
private boolean mobileFooter; private boolean mobileFooter;
@Schema(description = "show unread count in the title", required = true) @Schema(description = "show unread count in the title", required = true)

View File

@@ -37,8 +37,7 @@ public class Subscription implements Serializable {
private Instant lastRefresh; private Instant lastRefresh;
@Schema( @Schema(
description = description = "next time the feed refresh is planned, null if refresh is already queued",
"next time the feed refresh is planned, null if refresh is already queued",
type = SchemaType.INTEGER) type = SchemaType.INTEGER)
private Instant nextRefresh; private Instant nextRefresh;
@@ -63,22 +62,16 @@ public class Subscription implements Serializable {
@Schema(description = "date of the newest item", type = SchemaType.INTEGER) @Schema(description = "date of the newest item", type = SchemaType.INTEGER)
private Instant newestItemTime; private Instant newestItemTime;
@Schema( @Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
description =
"CEL string evaluated on new entries to mark them as read if they do not match")
private String filter; private String filter;
@Schema(description = "JEXL legacy filter") @Schema(description = "JEXL legacy filter")
private String filterLegacy; private String filterLegacy;
@Schema( @Schema(description = "whether to send push notifications for new entries of this feed", required = true)
description = "whether to send push notifications for new entries of this feed",
required = true)
private boolean pushNotificationsEnabled; private boolean pushNotificationsEnabled;
@Schema( @Schema(description = "automatically mark entries as read after this many days (null to disable)")
description =
"automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays; private Integer autoMarkAsReadAfterDays;
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) { public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {

View File

@@ -27,17 +27,13 @@ public class FeedModificationRequest implements Serializable {
@Schema(description = "new display position, null if not changed") @Schema(description = "new display position, null if not changed")
private Integer position; private Integer position;
@Schema( @Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
description =
"CEL string evaluated on new entries to mark them as read if they do not match")
@Size(max = 4096) @Size(max = 4096)
private String filter; private String filter;
@Schema(description = "whether to send push notifications for new entries of this feed") @Schema(description = "whether to send push notifications for new entries of this feed")
private boolean pushNotificationsEnabled; private boolean pushNotificationsEnabled;
@Schema( @Schema(description = "automatically mark entries as read after this many days (null to disable)")
description =
"automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays; private Integer autoMarkAsReadAfterDays;
} }

View File

@@ -35,8 +35,6 @@ public class MarkRequest implements Serializable {
@Size(max = 128) @Size(max = 128)
private String keywords; private String keywords;
@Schema( @Schema(description = "if marking a category or 'all', exclude those subscriptions from the marking")
description =
"if marking a category or 'all', exclude those subscriptions from the marking")
private List<Long> excludedSubscriptions; private List<Long> excludedSubscriptions;
} }

View File

@@ -6,7 +6,6 @@ import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole; import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role; import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.service.PasswordEncryptionService;
import com.commafeed.backend.service.UserService; import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.model.UserModel; import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.AdminSaveUserRequest; import com.commafeed.frontend.model.request.AdminSaveUserRequest;
@@ -56,7 +55,6 @@ public class AdminREST {
private final UserDAO userDAO; private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO; private final UserRoleDAO userRoleDAO;
private final UserService userService; private final UserService userService;
private final PasswordEncryptionService encryptionService;
private final MetricRegistry metrics; private final MetricRegistry metrics;
@Path("/user/save") @Path("/user/save")
@@ -64,8 +62,7 @@ public class AdminREST {
@Transactional @Transactional
@Operation( @Operation(
summary = "Save or update a user", summary = "Save or update a user",
description = description = "Save or update a user. If the id is not specified, a new user will be created")
"Save or update a user. If the id is not specified, a new user will be created")
public Response adminSaveUser(@Valid @Parameter(required = true) AdminSaveUserRequest req) { public Response adminSaveUser(@Valid @Parameter(required = true) AdminSaveUserRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName()); Preconditions.checkNotNull(req.getName());
@@ -79,33 +76,28 @@ public class AdminREST {
roles.add(Role.ADMIN); roles.add(Role.ADMIN);
} }
try { try {
id = id = userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true).getId();
userService
.register(
req.getName(),
req.getPassword(),
req.getEmail(),
roles,
true)
.getId();
} catch (Exception e) { } catch (Exception e) {
return Response.status(Status.CONFLICT).entity(e.getMessage()).build(); return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
} }
} else { } else {
User user = authenticationContext.getCurrentUser(); User user = authenticationContext.getCurrentUser();
if (req.getId().equals(user.getId()) && !req.isEnabled()) { if (req.getId().equals(user.getId()) && !req.isEnabled()) {
return Response.status(Status.FORBIDDEN) return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build();
.entity("You cannot disable your own account.")
.build();
} }
User u = userDAO.findById(id); User u = userDAO.findById(id);
u.setName(req.getName()); u.setName(req.getName());
if (StringUtils.isNotBlank(req.getPassword())) { if (StringUtils.isNotBlank(req.getPassword())) {
u.setPassword( userService.changePassword(u, req.getPassword());
encryptionService.getEncryptedPassword(req.getPassword(), u.getSalt())); if (StringUtils.isNotBlank(u.getApiKey())) {
u.setApiKey(userService.generateApiKey(u));
}
} }
u.setEmail(req.getEmail()); u.setEmail(req.getEmail());
if (!u.isDisabled() && !req.isEnabled()) {
userService.invalidateSessions(u);
}
u.setDisabled(!req.isEnabled()); u.setDisabled(!req.isEnabled());
Set<Role> roles = userRoleDAO.findRoles(u); Set<Role> roles = userRoleDAO.findRoles(u);
@@ -131,8 +123,7 @@ public class AdminREST {
@GET @GET
@Transactional @Transactional
@Operation(summary = "Get user information", description = "Get user information") @Operation(summary = "Get user information", description = "Get user information")
public UserModel adminGetUser( public UserModel adminGetUser(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id); Preconditions.checkNotNull(id);
User u = userDAO.findById(id); User u = userDAO.findById(id);
UserModel userModel = new UserModel(); UserModel userModel = new UserModel();
@@ -140,8 +131,7 @@ public class AdminREST {
userModel.setName(u.getName()); userModel.setName(u.getName());
userModel.setEmail(u.getEmail()); userModel.setEmail(u.getEmail());
userModel.setEnabled(!u.isDisabled()); userModel.setEnabled(!u.isDisabled());
userModel.setAdmin( userModel.setAdmin(userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
return userModel; return userModel;
} }
@@ -189,9 +179,7 @@ public class AdminREST {
User user = authenticationContext.getCurrentUser(); User user = authenticationContext.getCurrentUser();
if (user.getId().equals(u.getId())) { if (user.getId().equals(u.getId())) {
return Response.status(Status.FORBIDDEN) return Response.status(Status.FORBIDDEN).entity("You cannot delete your own user.").build();
.entity("You cannot delete your own user.")
.build();
} }
userService.unregister(u); userService.unregister(u);
return Response.ok().build(); return Response.ok().build();

View File

@@ -104,30 +104,22 @@ public class CategoryREST {
@Operation(summary = "Get category entries", description = "Get a list of category entries") @Operation(summary = "Get category entries", description = "Get a list of category entries")
@APIResponse( @APIResponse(
responseCode = "200", responseCode = "200",
content = { content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Entries.class))})
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Entries.class))
})
@APIResponse(responseCode = "404", description = "category not found") @APIResponse(responseCode = "404", description = "category not found")
public Response getCategoryEntries( public Response getCategoryEntries(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id")
@QueryParam("id")
String id, String id,
@Parameter(description = "all entries or only unread ones", required = true) @Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("unread") @DefaultValue("unread")
@QueryParam("readType") @QueryParam("readType")
ReadingMode readType, ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
Long newerThan, @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000") @Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20") @DefaultValue("20")
@QueryParam("limit") @QueryParam("limit")
int limit, int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") @Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
ReadingOrder order,
@Parameter( @Parameter(
description = description =
"search for keywords in either the title or the content of the entries, separated by spaces") "search for keywords in either the title or the content of the entries, separated by spaces")
@@ -136,13 +128,11 @@ public class CategoryREST {
@Parameter(description = "comma-separated list of excluded subscription ids") @Parameter(description = "comma-separated list of excluded subscription ids")
@QueryParam("excludedSubscriptionIds") @QueryParam("excludedSubscriptionIds")
String excludedSubscriptionIds, String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
String tag) {
Preconditions.checkNotNull(readType); Preconditions.checkNotNull(readType);
List<FeedEntryKeyword> entryKeywords = List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
limit = Math.min(limit, 1000); limit = Math.min(limit, 1000);
limit = Math.max(0, limit); limit = Math.max(0, limit);
@@ -159,8 +149,7 @@ public class CategoryREST {
List<Long> excludedIds = null; List<Long> excludedIds = null;
if (StringUtils.isNotEmpty(excludedSubscriptionIds)) { if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
excludedIds = excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
} }
User user = authenticationContext.getCurrentUser(); User user = authenticationContext.getCurrentUser();
@@ -191,18 +180,15 @@ public class CategoryREST {
} else if (STARRED.equals(id)) { } else if (STARRED.equals(id)) {
entries.setName("Starred"); entries.setName("Starred");
List<FeedEntryStatus> starred = List<FeedEntryStatus> starred =
feedEntryStatusDAO.findStarred( feedEntryStatusDAO.findStarred(user, entryKeywords, newerThanDate, offset, limit + 1, order, true);
user, entryKeywords, newerThanDate, offset, limit + 1, order, true);
for (FeedEntryStatus status : starred) { for (FeedEntryStatus status : starred) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
} }
} else { } else {
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id)); FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
if (parent != null) { if (parent != null) {
List<FeedCategory> categories = List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
feedCategoryDAO.findAllChildrenCategories(user, parent); List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
List<FeedSubscription> subs =
feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, excludedIds); removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = List<FeedEntryStatus> list =
feedEntryStatusDAO.findBySubscriptions( feedEntryStatusDAO.findBySubscriptions(
@@ -224,9 +210,7 @@ public class CategoryREST {
} }
entries.setName(parent.getName()); entries.setName(parent.getName());
} else { } else {
return Response.status(Status.NOT_FOUND) return Response.status(Status.NOT_FOUND).entity("<message>category not found</message>").build();
.entity("<message>category not found</message>")
.build();
} }
} }
@@ -244,28 +228,22 @@ public class CategoryREST {
@Path("/entriesAsFeed") @Path("/entriesAsFeed")
@GET @GET
@Transactional @Transactional
@Operation( @Operation(summary = "Get category entries as feed", description = "Get a feed of category entries")
summary = "Get category entries as feed",
description = "Get a feed of category entries")
@Produces(MediaType.APPLICATION_XML) @Produces(MediaType.APPLICATION_XML)
public Response getCategoryEntriesAsFeed( public Response getCategoryEntriesAsFeed(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id")
@QueryParam("id")
String id, String id,
@Parameter(description = "all entries or only unread ones", required = true) @Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("all") @DefaultValue("all")
@QueryParam("readType") @QueryParam("readType")
ReadingMode readType, ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
Long newerThan, @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000") @Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20") @DefaultValue("20")
@QueryParam("limit") @QueryParam("limit")
int limit, int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") @Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
ReadingOrder order,
@Parameter( @Parameter(
description = description =
"search for keywords in either the title or the content of the entries, separated by spaces") "search for keywords in either the title or the content of the entries, separated by spaces")
@@ -274,20 +252,11 @@ public class CategoryREST {
@Parameter(description = "comma-separated list of excluded subscription ids") @Parameter(description = "comma-separated list of excluded subscription ids")
@QueryParam("excludedSubscriptionIds") @QueryParam("excludedSubscriptionIds")
String excludedSubscriptionIds, String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
String tag) {
Response response = Response response =
getCategoryEntries( getCategoryEntries(
id, id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
readType,
newerThan,
offset,
limit,
order,
keywords,
excludedSubscriptionIds,
tag);
if (response.getStatus() != Status.OK.getStatusCode()) { if (response.getStatus() != Status.OK.getStatusCode()) {
return response; return response;
} }
@@ -314,21 +283,14 @@ public class CategoryREST {
@Path("/mark") @Path("/mark")
@POST @POST
@Transactional @Transactional
@Operation( @Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read")
summary = "Mark category entries",
description = "Mark feed entries of this category as read")
public Response markCategoryEntries( public Response markCategoryEntries(
@Valid @Parameter(description = "category id, or 'all'", required = true) @Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) {
MarkRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId()); Preconditions.checkNotNull(req.getId());
Instant olderThan = Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan()); Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore());
Instant insertedBefore =
req.getInsertedBefore() == null
? null
: Instant.ofEpochMilli(req.getInsertedBefore());
String keywords = req.getKeywords(); String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords); List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
@@ -336,8 +298,7 @@ public class CategoryREST {
if (ALL.equals(req.getId())) { if (ALL.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user); List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions()); removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries( feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
user, subs, olderThan, insertedBefore, entryKeywords);
} else if (STARRED.equals(req.getId())) { } else if (STARRED.equals(req.getId())) {
feedEntryService.markStarredEntries(user, olderThan, insertedBefore); feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
} else { } else {
@@ -345,8 +306,7 @@ public class CategoryREST {
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent); List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories); List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions()); removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries( feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
user, subs, olderThan, insertedBefore, entryKeywords);
} }
return Response.ok().build(); return Response.ok().build();
} }
@@ -400,8 +360,7 @@ public class CategoryREST {
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat); List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat);
for (FeedCategory child : categories) { for (FeedCategory child : categories) {
if (!child.getId().equals(cat.getId()) if (!child.getId().equals(cat.getId()) && child.getParent().getId().equals(cat.getId())) {
&& child.getParent().getId().equals(cat.getId())) {
child.setParent(null); child.setParent(null);
} }
} }
@@ -417,8 +376,7 @@ public class CategoryREST {
@Path("/modify") @Path("/modify")
@Transactional @Transactional
@Operation(summary = "Modify a category", description = "Modify an existing feed category") @Operation(summary = "Modify a category", description = "Modify an existing feed category")
public Response modifyCategory( public Response modifyCategory(@Valid @Parameter(required = true) CategoryModificationRequest req) {
@Valid @Parameter(required = true) CategoryModificationRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId()); Preconditions.checkNotNull(req.getId());
@@ -437,14 +395,11 @@ public class CategoryREST {
} }
if (parent != null) { if (parent != null) {
Long parentId = parent.getId(); Long parentId = parent.getId();
List<FeedCategory> descendants = List<FeedCategory> descendants = feedCategoryDAO.findAllChildrenCategories(user, category);
feedCategoryDAO.findAllChildrenCategories(user, category); boolean parentIsDescendant = descendants.stream().anyMatch(c -> c.getId().equals(parentId));
boolean parentIsDescendant =
descendants.stream().anyMatch(c -> c.getId().equals(parentId));
if (parentIsDescendant) { if (parentIsDescendant) {
return Response.status(Status.BAD_REQUEST) return Response.status(Status.BAD_REQUEST)
.entity( .entity("<message>cannot set a category's own descendant as its parent</message>")
"<message>cannot set a category's own descendant as its parent</message>")
.build(); .build();
} }
} }
@@ -476,9 +431,7 @@ public class CategoryREST {
@POST @POST
@Path("/collapse") @Path("/collapse")
@Transactional @Transactional
@Operation( @Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category")
summary = "Collapse a category",
description = "Save collapsed or expanded status for a category")
public Response collapseCategory(@Parameter(required = true) CollapseRequest req) { public Response collapseCategory(@Parameter(required = true) CollapseRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId()); Preconditions.checkNotNull(req.getId());
@@ -506,9 +459,7 @@ public class CategoryREST {
@GET @GET
@Path("/get") @Path("/get")
@Transactional @Transactional
@Operation( @Operation(summary = "Get root category", description = "Get all categories and subscriptions of the user")
summary = "Get root category",
description = "Get all categories and subscriptions of the user")
public Category getRootCategory() { public Category getRootCategory() {
User user = authenticationContext.getCurrentUser(); User user = authenticationContext.getCurrentUser();
@@ -551,8 +502,7 @@ public class CategoryREST {
for (FeedSubscription subscription : subscriptions) { for (FeedSubscription subscription : subscriptions) {
if (id == null && subscription.getCategory() == null if (id == null && subscription.getCategory() == null
|| subscription.getCategory() != null || subscription.getCategory() != null && Objects.equals(subscription.getCategory().getId(), id)) {
&& Objects.equals(subscription.getCategory().getId(), id)) {
UnreadCount uc = unreadCount.get(subscription.getId()); UnreadCount uc = unreadCount.get(subscription.getId());
Subscription sub = Subscription.build(subscription, uc); Subscription sub = Subscription.build(subscription, uc);
category.getFeeds().add(sub); category.getFeeds().add(sub);

View File

@@ -50,8 +50,7 @@ public class EntryREST {
@POST @POST
@Transactional @Transactional
@Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread") @Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread")
public Response markEntry( public Response markEntry(@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId()); Preconditions.checkNotNull(req.getId());
@@ -63,12 +62,9 @@ public class EntryREST {
@Path("/markMultiple") @Path("/markMultiple")
@POST @POST
@Transactional @Transactional
@Operation( @Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread")
summary = "Mark multiple feed entries",
description = "Mark feed entries as read/unread")
public Response markEntries( public Response markEntries(
@Valid @Parameter(description = "Multiple Mark Request", required = true) @Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) {
MultipleMarkRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getRequests()); Preconditions.checkNotNull(req.getRequests());
@@ -85,8 +81,7 @@ public class EntryREST {
@POST @POST
@Transactional @Transactional
@Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread") @Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread")
public Response starEntry( public Response starEntry(@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId()); Preconditions.checkNotNull(req.getId());
@@ -99,9 +94,7 @@ public class EntryREST {
@Path("/tags") @Path("/tags")
@GET @GET
@Transactional @Transactional
@Operation( @Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user")
summary = "Get list of tags for the user",
description = "Get list of tags for the user")
public Response getTags() { public Response getTags() {
User user = authenticationContext.getCurrentUser(); User user = authenticationContext.getCurrentUser();
List<String> tags = feedEntryTagDAO.findByUser(user); List<String> tags = feedEntryTagDAO.findByUser(user);
@@ -112,8 +105,7 @@ public class EntryREST {
@POST @POST
@Transactional @Transactional
@Operation(summary = "Set feed entry tags") @Operation(summary = "Set feed entry tags")
public Response tagEntry( public Response tagEntry(@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
Preconditions.checkNotNull(req); Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getEntryId()); Preconditions.checkNotNull(req.getEntryId());

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