Compare commits

..

253 Commits

Author SHA1 Message Date
renovate[bot]
5e382ed73e Update dependency @rolldown/plugin-babel to v0.3.0 2026-11-01 15:13:03 +00:00
Jérémie Panzer
93edb47dca Merge pull request #2360 from Athou/renovate/linguijs-monorepo
Update linguijs monorepo to v6.11.0
2026-11-01 16:02:26 +01:00
renovate[bot]
801680e373 Update linguijs monorepo to v6.11.0 2026-11-01 14:10:40 +00:00
renovate[bot]
5704e1e1fa Update dependency @biomejs/biome to v2.6.4 2026-11-01 08:42:11 +00:00
Jérémie Panzer
a2a6720cd1 Merge pull request #2358 from Athou/renovate/com.puppycrawl.tools-checkstyle-14.x
Update dependency com.puppycrawl.tools:checkstyle to v14.4.0
2026-11-01 09:41:16 +01:00
renovate[bot]
4f8cddeb54 Update dependency com.puppycrawl.tools:checkstyle to v14.4.0 2026-10-31 21:57:58 +00:00
Athou
90d0846bad apply timeout to all queries 2026-10-31 08:46:39 +01:00
renovate[bot]
698b2f4dfd Update dependency react-router-dom to v7.18.5 2026-10-31 00:27:01 +00:00
renovate[bot]
0053c54ae4 Update dependency react-redux to v9.3.1 2026-10-30 22:37:38 +00:00
Athou
16b6077496 keep using ubuntu-24.04 (#2357) 2026-10-30 23:36:23 +01:00
Athou
edb4d5e178 speed up tree building by looking up unread counts in batches 2026-10-30 23:15:22 +01:00
Athou
4b091fb90b fix "Mark all as read" ignoring active search filter (#2354) 2026-10-30 21:08:27 +01:00
Athou
30c049d10a fix infinite scroll not loading more search results when entries are already read (#2353) 2026-10-30 20:54:56 +01:00
Athou
fa8a447787 prevent moving a category into one of its own descendants (#2355) 2026-10-30 20:51:45 +01:00
Athou
20ec5d3eb9 use mandrel instead of graalvm for better compatibility with quarkus
(cherry picked from commit c020b0ae08936d6f57b16c1b6bdd8aea17981ede)
2026-10-30 20:07:18 +01:00
Jérémie Panzer
796dc88c33 Merge pull request #2356 from Athou/renovate/com.google.googlejavaformat-google-java-format-2.x
Update dependency com.google.googlejavaformat:google-java-format to v2
2026-10-30 19:55:46 +01:00
Athou
f979f5426f reformat using google-java-format 2.0 2026-10-30 19:39:53 +01:00
renovate[bot]
8f9be28002 Update dependency com.google.googlejavaformat:google-java-format to v2 2026-10-30 17:25:44 +00:00
Jérémie Panzer
89845681be Merge pull request #2352 from Athou/renovate/axios-1.x
Update dependency axios to v1.21.0
2026-10-30 13:33:33 +01:00
renovate[bot]
8fdf63dd73 Update dependency axios to v1.21.0 2026-10-30 11:17:25 +00:00
Jérémie Panzer
8f07454270 Merge pull request #2350 from dajiaohuang/feat/2268-feed-settings-icon
Use a settings icon for feed controls
2026-10-30 07:23:37 +01:00
Jérémie Panzer
946d82d8ee Merge pull request #2351 from Athou/renovate/react-querybuilder
Update react-querybuilder to v8.26.0
2026-10-30 07:18:05 +01:00
renovate[bot]
f8d625b651 Update react-querybuilder to v8.26.0 2026-10-30 05:54:28 +00:00
dajiaohuang
eefdd40a02 Use settings icon for feed controls 2026-10-29 19:34:22 +08:00
renovate[bot]
08f2889f1d Update debian:13.7 Docker digest to 2dc419c 2026-10-29 01:59:45 +00:00
renovate[bot]
45453fcf4f Update mantine monorepo to v9.7.4 (#2349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 01:59:27 +00:00
Jérémie Panzer
ef7bab4841 Merge pull request #2348 from Athou/renovate/node-26.x
Update Node.js to v26
2026-10-28 22:03:31 +01:00
renovate[bot]
ac277f9ca5 Update Node.js to v26 2026-10-28 20:44:59 +00:00
renovate[bot]
44dab0287b Update Node.js to v24.22.2 2026-10-28 19:20:32 +00:00
renovate[bot]
88b75ec0cb Update debian:13.7 Docker digest to 8c4733a 2026-10-28 05:34:10 +00:00
renovate[bot]
cbb45ce68c Update ibm-semeru-runtimes:open-jdk-25.0.4.15-jre Docker digest to c4f8cd5 2026-10-28 03:04:23 +00:00
renovate[bot]
b0c4dbf533 Update dependency @biomejs/biome to v2.6.3 2026-10-27 14:52:39 +00:00
renovate[bot]
1891b63a0e Update ibm-semeru-runtimes:open-jdk-25.0.4.15-jre Docker digest to 1e5bef9 2026-10-27 08:26:53 +00:00
Jérémie Panzer
c420fec8ee Merge pull request #2344 from Athou/renovate/dev.cel-cel-0.x
Update dependency dev.cel:cel to v0.15.0
2026-10-27 09:25:55 +01:00
Athou
2ee2a5dfbe standardCelRuntimeBuilder is deprecated 2026-10-27 07:23:50 +01:00
Jérémie Panzer
a5b98f9ea2 Merge pull request #2345 from Athou/renovate/org.mock-server-mockserver-junit-jupiter-no-dependencies-9.x
Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.2.0
2026-10-27 07:20:19 +01:00
renovate[bot]
baedab2960 Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.2.0 2026-10-27 02:09:09 +00:00
renovate[bot]
004e18bbb8 Update dependency dev.cel:cel to v0.15.0 2026-10-27 02:09:03 +00:00
Athou
72e364afe9 babel types are no longer needed now that linguijs does not require babel anymore 2026-10-26 20:15:32 +01:00
renovate[bot]
86ff2dfcb2 Update graalvm/setup-graalvm action to v1.6.8 2026-10-26 18:41:05 +00:00
renovate[bot]
69ce73012c Update dependency @babel/types to ^8.0.8 2026-10-26 17:46:09 +00:00
renovate[bot]
f3bd1a2e11 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.6.1 2026-10-26 06:38:33 +00:00
renovate[bot]
a819283661 Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.6.1 2026-10-26 04:33:45 +00:00
renovate[bot]
8255c1e929 Lock file maintenance 2026-10-26 00:53:26 +00:00
renovate[bot]
eb7990f8dc Update linguijs monorepo to v6.10.1 2026-10-25 16:24:22 +00:00
Jérémie Panzer
982476b2d0 Merge pull request #2343 from Athou/renovate/biomejs-biome-2.x
Update dependency @biomejs/biome to v2.6.2
2026-10-24 19:58:21 +02:00
renovate[bot]
3ec653c3b7 Update dependency @biomejs/biome to v2.6.2 2026-10-24 13:30:33 +00:00
renovate[bot]
5dc5b3795e Update dependency vite to v8.3.5 2026-10-24 08:49:46 +00:00
Jérémie Panzer
f5f77a80a3 Merge pull request #2341 from Athou/renovate/docker-login-action-4.x
Update docker/login-action action to v4.7.0
2026-10-23 20:36:02 +02:00
renovate[bot]
1d1099492e Update docker/login-action action to v4.7.0 2026-10-23 18:01:41 +00:00
Jérémie Panzer
ccc2564929 Merge pull request #2340 from Athou/renovate/com.ibm.icu-icu4j-79.x
Update dependency com.ibm.icu:icu4j to v79
2026-10-23 08:55:25 +02:00
renovate[bot]
084574ffd4 Update dependency com.ibm.icu:icu4j to v79 2026-10-22 20:43:24 +00:00
Athou
6a6978c491 release 7.4.1 2026-10-22 17:03:04 +02:00
Athou
0399375288 don't mark as read entries that the user explicitly marked as unread (#2337) 2026-10-22 09:04:57 +02:00
renovate[bot]
394346fbc1 Update dependency npm to v12.2.1 2026-10-22 01:00:28 +00:00
renovate[bot]
0feec109bd Update Node.js to v24.22.1 2026-10-21 12:22:26 +00:00
Jérémie Panzer
fdb5c9b536 Merge pull request #2339 from Athou/renovate/org.mock-server-mockserver-junit-jupiter-no-dependencies-9.x
Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.1.0
2026-10-21 14:21:44 +02:00
renovate[bot]
f7ad8aed60 Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.1.0 2026-10-21 11:48:07 +00:00
renovate[bot]
4621d7e79f Update debian:13.7 Docker digest to 4c25275 2026-10-21 05:25:57 +00:00
Jérémie Panzer
b64a49cfb0 Merge pull request #2338 from Athou/renovate/patch-mantine-monorepo
Update mantine monorepo to v9.7.3
2026-10-21 07:25:22 +02:00
renovate[bot]
aaddfbaf40 Update mantine monorepo to v9.7.3 2026-10-20 20:33:54 +00:00
Athou
994da5e1ee don't mark as read if we are still on the same entry (#2337) 2026-10-20 16:51:42 +02:00
Athou
811e282e28 mockserver already uses jackson3 2026-10-20 13:34:06 +02:00
Jérémie Panzer
892fcf8168 Merge pull request #2336 from Athou/renovate/linguijs-monorepo
Update linguijs monorepo to v6.10.0
2026-10-20 13:15:59 +02:00
renovate[bot]
032ee9243f Update linguijs monorepo to v6.10.0 2026-10-20 10:46:22 +00:00
renovate[bot]
30cdf34d56 Update quarkus.version to v3.40.1.1 2026-10-20 10:45:55 +00:00
Athou
fd52caa3fd use responseTimeout as the global timeout for the request 2026-10-20 08:47:59 +02:00
Athou
9788712808 add a global failsafe timeout to the feed fetching client 2026-10-20 08:28:15 +02:00
Jérémie Panzer
af9d086f05 Merge pull request #2335 from agross/add-oci-source-label
Add OCI source label for Renovate release-note discovery
2026-10-19 11:31:42 +02:00
Alexander Groß
fc7190868e Add OCI source label to container images 2026-10-19 10:35:18 +02:00
Athou
d3c1892c0b release 7.4.0 2026-10-19 09:07:03 +02:00
renovate[bot]
1fb4cdf3ca Lock file maintenance 2026-10-19 01:55:55 +00:00
renovate[bot]
7928183036 Update dependency vitest to v5.0.5 2026-10-18 16:41:22 +00:00
renovate[bot]
ffa276df9e Update dependency jsdom to v30.1.3 2026-10-18 13:36:44 +00:00
renovate[bot]
9f05e66e9d Update react monorepo to v19.3.1 2026-10-17 20:52:26 +00:00
renovate[bot]
8ba90ded85 Update ibm-semeru-runtimes:open-jdk-25.0.4.15-jre Docker digest to 630c738 2026-10-17 04:47:05 +00:00
renovate[bot]
4e5e2f06d3 Update dependency @testing-library/user-event to v14.6.8 2026-10-16 10:19:13 +00:00
Athou
72396254d2 Revert "prevent brotli4j from being on the classpath and extract its files in /tmp"
This reverts commit f9313006ba.
2026-10-16 12:18:09 +02:00
Athou
f9313006ba prevent brotli4j from being on the classpath and extract its files in /tmp 2026-10-16 12:05:56 +02:00
Jérémie Panzer
c894d701ff Merge pull request #2333 from Athou/renovate/react-querybuilder
Update react-querybuilder to v8.25.0
2026-10-16 06:12:26 +02:00
renovate[bot]
e9d0466018 Update react-querybuilder to v8.25.0 2026-10-16 02:45:03 +00:00
renovate[bot]
28b4f61f20 Update dependency vitest to v5.0.4 2026-10-15 11:14:46 +00:00
Athou
ab6b3b225d Revert "add support for zstd and brotli compression" because they use native libraries that need to be extracted in a temp folder which we want to avoid 2026-10-15 08:09:18 +02:00
Athou
ffb4a43e38 log what happens when we fail to compress 2026-10-15 03:07:48 +02:00
Jérémie Panzer
0653e2dc97 Merge pull request #2332 from Athou/renovate/node-24.x
Update Node.js to v24.22.0
2026-10-14 21:25:17 +02:00
renovate[bot]
912e03d2ce Update Node.js to v24.22.0 2026-10-14 19:05:35 +00:00
renovate[bot]
a8df765cda Update mantine monorepo to v9.7.2 2026-10-14 10:08:31 +00:00
Athou
b865b3ff84 add support for brotli compression 2026-10-14 07:37:31 +02:00
Athou
69c1e8a5b8 add support for zstd compression 2026-10-14 07:35:49 +02:00
Athou
c1522f1005 babel is no longer required for lingui 2026-10-14 02:49:30 +02:00
Athou
8144d24b06 Merge branch 'renovate/quarkus.version' 2026-10-14 02:46:27 +02:00
Athou
4c2e2efbb5 downgrade mockserver because 8.0.0 uses jackson 3 2026-10-14 02:46:09 +02:00
Jérémie Panzer
ad452c9a7a Merge pull request #2328 from Athou/renovate/mantine-monorepo
Update mantine monorepo to v9.7.1
2026-10-13 15:39:35 +02:00
Jérémie Panzer
a43da1304d Merge pull request #2325 from Athou/renovate/monaco-editor-0.x
Update dependency monaco-editor to v0.57.0
2026-10-13 15:39:28 +02:00
renovate[bot]
c2da59a92f Update quarkus.version to v3.40.1 2026-10-13 12:58:27 +00:00
renovate[bot]
a89166d1c4 Update mantine monorepo to v9.7.1 2026-10-13 12:58:16 +00:00
renovate[bot]
45039593bf Update dependency monaco-editor to v0.57.0 2026-10-13 12:58:02 +00:00
Athou
d56c35cdfb Merge remote-tracking branch 'origin/renovate/npm-12.x' 2026-10-13 14:56:19 +02:00
Athou
73836c6800 Merge remote-tracking branch 'origin/renovate/linguijs-monorepo' 2026-10-13 14:56:13 +02:00
Athou
f9a0440db8 Merge remote-tracking branch 'origin/renovate/graalvm-setup-graalvm-1.6.x' 2026-10-13 14:55:51 +02:00
Athou
6e7a8c1ef6 harden iframe protocols just in case 2026-10-13 13:24:42 +02:00
Athou
44147cc062 add more default headers (#2315) 2026-10-13 13:06:52 +02:00
renovate[bot]
352b756e22 Update linguijs monorepo to v6.9.1 2026-10-13 10:34:42 +00:00
renovate[bot]
a5d9eb757f Update dependency npm to v12.2.0 2026-10-13 10:34:20 +00:00
renovate[bot]
a19a73d675 Update graalvm/setup-graalvm action to v1.6.7 2026-10-13 10:34:13 +00:00
Athou
4a57f87284 Merge remote-tracking branch 'origin/renovate/patch-react-querybuilder' 2026-10-13 12:28:49 +02:00
Athou
b523fe4c58 Merge remote-tracking branch 'origin/renovate/patch-quarkus.version' 2026-10-13 12:28:44 +02:00
Athou
c208450abc Merge remote-tracking branch 'origin/renovate/patch-mantine-monorepo' 2026-10-13 12:28:39 +02:00
Athou
b73ab2a781 Merge remote-tracking branch 'origin/renovate/ibm-semeru-runtimes-25.0.x' 2026-10-13 12:28:34 +02:00
Athou
ec679cc23c Merge remote-tracking branch 'origin/renovate/patch-vitest-monorepo' 2026-10-13 12:28:22 +02:00
Athou
1dbbcc4d21 Merge remote-tracking branch 'origin/renovate/vite-8.3.x' 2026-10-13 12:28:17 +02:00
renovate[bot]
1573b61584 Update react-querybuilder to v8.24.4 2026-10-13 09:54:18 +00:00
renovate[bot]
1ac8cc7674 Update quarkus.version to v3.39.5 2026-10-13 09:54:10 +00:00
renovate[bot]
793db4e9b6 Update mantine monorepo to v9.6.3 2026-10-13 09:54:03 +00:00
renovate[bot]
12863e557a Update ibm-semeru-runtimes Docker tag to open-jdk-25.0.4.15-jre 2026-10-13 09:53:54 +00:00
renovate[bot]
d999fc1e06 Update dependency vitest to v5.0.3 2026-10-13 09:53:42 +00:00
renovate[bot]
6c288d160c Update dependency vite to v8.3.4 2026-10-13 09:53:32 +00:00
renovate[bot]
57d5dd8b20 Update dependency org.apache.httpcomponents.client5:httpclient5 to v5.6.5 2026-10-13 09:53:11 +00:00
Athou
0a8c0e0c7f don't stream the response at all if it exceeds the max size 2026-10-13 11:51:47 +02:00
Athou
f72bd4cc5c cancel requests if interrupted 2026-10-13 11:51:47 +02:00
Athou
26cce1faac we only need the response headers 2026-10-13 11:51:47 +02:00
Athou
3101b4f423 fix build 2026-10-13 11:51:47 +02:00
Jérémie Panzer
e8e30faf3e Merge pull request #2324 from Athou/renovate/maven-3.x
Update dependency maven to v3.10.0
2026-10-13 11:48:59 +02:00
Athou
3f6158fc47 add support for HTTP/2 (#2317) 2026-10-13 08:40:28 +02:00
renovate[bot]
3465645b70 Update dependency maven to v3.10.0 2026-10-13 04:41:08 +00:00
Jérémie Panzer
2dd397757d Merge pull request #2323 from Athou/renovate/com.puppycrawl.tools-checkstyle-14.x
Update dependency com.puppycrawl.tools:checkstyle to v14.3.0
2026-10-13 06:38:58 +02:00
renovate[bot]
5496419bb4 Update dependency com.puppycrawl.tools:checkstyle to v14.3.0 2026-10-13 02:18:41 +00:00
Jérémie Panzer
f1b950c38e Merge pull request #2321 from Athou/renovate/reduxjs-toolkit-2.x
Update dependency @reduxjs/toolkit to v2.13.0
2026-10-13 04:16:43 +02:00
renovate[bot]
4a3e97ad52 Update dependency @reduxjs/toolkit to v2.13.0 2026-10-12 19:20:48 +00:00
renovate[bot]
9fe17996b4 Update dependency jsdom to v30.1.2 2026-10-12 19:19:36 +00:00
Jérémie Panzer
c7a2f87148 Merge pull request #2320 from Athou/renovate/actions-setup-node-7.x
Update actions/setup-node action to v7.1.0
2026-10-12 21:18:01 +02:00
renovate[bot]
1125dd3fe7 Update actions/setup-node action to v7.1.0 2026-10-12 17:44:03 +00:00
renovate[bot]
adf48aee44 Update dependency @vitejs/plugin-react to v6.1.2 2026-10-12 15:41:38 +00:00
renovate[bot]
534d8a333e Update actions/upload-artifact action to v7.0.2 (#2319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 15:41:01 +00:00
renovate[bot]
03fc91c19c Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.10.4 2026-10-12 12:06:18 +00:00
renovate[bot]
d2429dada2 Update actions/download-artifact action to v8.0.2 2026-10-12 10:37:39 +00:00
renovate[bot]
0fae323647 Update debian:13.7 Docker digest to 913f670 2026-10-12 08:33:18 +00:00
renovate[bot]
fae58d56be Update dependency io.quarkiverse.playwright:quarkus-playwright to v2.3.10 2026-10-04 19:08:08 +00:00
renovate[bot]
fc56d5e7cb Update dependency @biomejs/biome to v2.5.15 2026-10-04 11:38:21 +00:00
Jérémie Panzer
04c85acae0 Merge pull request #2316 from Athou/renovate/querydsl.version
Update querydsl.version to v7.7
2026-09-25 07:52:23 +09:00
renovate[bot]
af2fd9bc09 Update querydsl.version to v7.7 2026-09-23 16:56:48 +00:00
renovate[bot]
d4ce2a213f Update dependency @babel/types to ^8.0.6 2026-09-22 00:13:21 +00:00
renovate[bot]
7d9ba805e4 Lock file maintenance 2026-09-21 01:27:17 +00:00
Jérémie Panzer
4c315ca3b8 Merge pull request #2314 from Athou/renovate/react-draggable-4.7.x
Update dependency react-draggable to v4.7.2
2026-09-20 22:56:52 +09:00
renovate[bot]
bcf812ef7a Update dependency react-draggable to v4.7.2 2026-09-20 13:21:11 +00:00
Jérémie Panzer
fd603aa76f Merge pull request #2313 from Athou/renovate/jsdom-30.x
Update dependency jsdom to v30.1.0
2026-09-20 22:20:07 +09:00
renovate[bot]
e34c08c803 Update dependency jsdom to v30.1.0 2026-09-20 04:40:09 +00:00
renovate[bot]
9252ef1531 Update dependency @biomejs/biome to v2.5.14 2026-09-19 13:24:53 +00:00
Jérémie Panzer
63153ca195 Merge pull request #2312 from Athou/renovate/debian-13.x
Update debian Docker tag to v13.7
2026-09-19 22:24:13 +09:00
Jérémie Panzer
a8c2f69251 Merge pull request #2311 from Athou/renovate/react-querybuilder
Update react-querybuilder to v8.24.0
2026-09-19 22:23:58 +09:00
Jérémie Panzer
91eaef05e1 Merge pull request #2310 from Athou/renovate/io.github.hakky54-ayza-for-apache5-10.x
Update dependency io.github.hakky54:ayza-for-apache5 to v10.1.0
2026-09-19 22:23:46 +09:00
renovate[bot]
4822642fd7 Update debian Docker tag to v13.7 2026-09-19 12:17:42 +00:00
renovate[bot]
a712d8f900 Update react-querybuilder to v8.24.0 2026-09-19 02:35:24 +00:00
renovate[bot]
86e507f93e Update dependency io.github.hakky54:ayza-for-apache5 to v10.1.0 2026-09-19 02:35:00 +00:00
renovate[bot]
86eba276c5 Update dependency vitest to v5.0.1 2026-09-19 02:34:54 +00:00
renovate[bot]
4f18a98a4a Update dependency react-router-dom to v7.18.4 2026-09-18 21:43:13 +00:00
renovate[bot]
547eff9f06 Update ibm-semeru-runtimes:open-jdk-25.0.4.10-jre Docker digest to a95258f 2026-09-18 06:56:02 +00:00
renovate[bot]
5bd1225d3f Update dependency com.google.protobuf:protobuf-java to v4.36.2 2026-09-17 23:33:37 +00:00
renovate[bot]
309a0aa94c Update quarkus.version to v3.39.4 2026-09-17 17:40:08 +00:00
renovate[bot]
f8286a6334 Update ibm-semeru-runtimes:open-jdk-25.0.4.10-jre Docker digest to 4498f13 2026-09-16 17:12:40 +00:00
renovate[bot]
916086d60e Update docker/setup-buildx-action action to v4.4.1 2026-09-16 12:28:22 +00:00
renovate[bot]
4fa0eb0a87 Update ibm-semeru-runtimes:open-jdk-25.0.4.10-jre Docker digest to 2b1e6d5 2026-09-16 11:03:21 +00:00
Athou
295eb57922 oxc-transform-react is not yet stable 2026-09-15 18:10:29 +02:00
Jérémie Panzer
2179cf57b6 Merge pull request #2308 from Athou/renovate/docker-setup-qemu-action-4.x
Update docker/setup-qemu-action action to v4.4.0
2026-09-15 18:10:20 +02:00
Jérémie Panzer
cca0f2d5db Merge pull request #2309 from Athou/renovate/org.mock-server-mockserver-junit-jupiter-8.x
Update dependency org.mock-server:mockserver-junit-jupiter to v8
2026-09-15 18:10:17 +02:00
renovate[bot]
d3c3ffb88d Update dependency org.mock-server:mockserver-junit-jupiter to v8 2026-09-15 15:41:27 +00:00
renovate[bot]
10c7196f9f Update docker/setup-qemu-action action to v4.4.0 2026-09-15 15:41:22 +00:00
Jérémie Panzer
254def6152 Merge pull request #2307 from Athou/renovate/docker-setup-buildx-action-4.x
Update docker/setup-buildx-action action to v4.4.0
2026-09-15 17:40:40 +02:00
Jérémie Panzer
09bae2565c Merge pull request #2306 from Athou/renovate/docker-build-push-action-7.x
Update docker/build-push-action action to v7.4.0
2026-09-15 17:40:37 +02:00
renovate[bot]
5572918750 Update docker/setup-buildx-action action to v4.4.0 2026-09-15 13:53:58 +00:00
renovate[bot]
4e91db09a6 Update docker/build-push-action action to v7.4.0 2026-09-15 13:53:52 +00:00
renovate[bot]
bbaee20097 Update dependency yaml to v2.9.1 2026-09-14 20:59:56 +00:00
Jérémie Panzer
7554e2b03e Merge pull request #2305 from Athou/renovate/linguijs-monorepo
Update linguijs monorepo to v6.7.0
2026-09-14 17:58:35 +02:00
renovate[bot]
3baac1138f Update linguijs monorepo to v6.7.0 2026-09-14 15:30:16 +00:00
renovate[bot]
8815cef66c Lock file maintenance 2026-09-14 04:36:54 +00:00
renovate[bot]
afa836c6df Update dependency @babel/types to ^8.0.5 2026-09-14 02:45:25 +00:00
renovate[bot]
bf513508e4 Update quarkus.version to v3.39.3 2026-09-13 19:21:03 +00:00
renovate[bot]
a0423f301d Update mantine monorepo to v9.6.1 2026-09-13 16:28:15 +00:00
renovate[bot]
302fa8f4dd Update dependency org.codehaus.mojo:properties-maven-plugin to v1.3.1 2026-09-13 15:27:55 +00:00
Jérémie Panzer
4a75a22a6c Merge pull request #2304 from Athou/renovate/vite-8.x
Update dependency vite to v8.3.0
2026-09-13 17:27:27 +02:00
renovate[bot]
c7d5cf1cc2 Update dependency vite to v8.3.0 2026-09-13 13:45:56 +00:00
renovate[bot]
e031ca3ef4 Update dependency @biomejs/biome to v2.5.13 2026-09-13 13:45:19 +00:00
renovate[bot]
8e0e926a68 Update dependency org.codehaus.mojo:exec-maven-plugin to v3.6.4 2026-09-13 09:44:51 +00:00
renovate[bot]
d4ed634c4e Update dependency @rolldown/plugin-babel to v0.2.4 2026-09-13 06:48:14 +00:00
Jérémie Panzer
18582f1a2d Merge pull request #2303 from Athou/renovate/react-monorepo
Update react monorepo to v19.3.0
2026-09-13 08:46:42 +02:00
Jérémie Panzer
3688b128e4 Merge pull request #2302 from Athou/renovate/node-24.x
Update Node.js to v24.21.0
2026-09-13 08:46:00 +02:00
renovate[bot]
4718b75f89 Update react monorepo to v19.3.0 2026-09-13 03:24:38 +00:00
renovate[bot]
aa7f8f79a5 Update Node.js to v24.21.0 2026-09-13 03:24:30 +00:00
renovate[bot]
c794a6aaf8 Update ibm-semeru-runtimes:open-jdk-25.0.4.10-jre Docker digest to c9f7958 2026-09-13 03:23:33 +00:00
renovate[bot]
0786a45995 Lock file maintenance 2026-09-07 01:08:40 +00:00
renovate[bot]
66fde65550 Update dependency @types/react-dom to v19.2.7 2026-09-06 15:08:44 +00:00
Athou
caf56aca94 speed up tests by reusing the jsdom env since we don't rely on global state 2026-09-06 16:33:14 +02:00
Jérémie Panzer
06581692e6 Merge pull request #2301 from Athou/renovate/major-vitest-monorepo
Update dependency vitest to v5
2026-09-06 16:33:06 +02:00
Athou
ca2fbd9fdd fix build 2026-09-06 16:26:04 +02:00
renovate[bot]
81ce6c9950 Update dependency vitest to v5 2026-09-06 13:27:15 +00:00
renovate[bot]
31b67f2217 Update dependency @biomejs/biome to v2.5.12 2026-09-06 09:25:21 +00:00
renovate[bot]
0ee76d39b2 Update dependency @testing-library/user-event to v14.6.7 2026-09-05 02:42:43 +00:00
renovate[bot]
6a3f4816d5 Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.10.2 2026-09-04 19:13:01 +00:00
Jérémie Panzer
1365754d62 Merge pull request #2299 from Athou/renovate/org.apache.maven.plugins-maven-failsafe-plugin-3.x
Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.6.0
2026-09-04 10:08:52 +02:00
Athou
ac4c98bead maven-failsafe-plugin 3.6.0 introduced skipITs 2026-09-04 08:21:57 +02:00
renovate[bot]
74306719ca Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.6.0 2026-09-04 05:18:08 +00:00
Jérémie Panzer
cc028dd37f Merge pull request #2300 from Athou/renovate/org.apache.maven.plugins-maven-surefire-plugin-3.x
Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.6.0
2026-09-04 06:20:05 +02:00
Jérémie Panzer
43184fff6d Merge pull request #2298 from Athou/renovate/mantine-monorepo
Update mantine monorepo to v9.6.0
2026-09-04 06:19:59 +02:00
renovate[bot]
d41f94f254 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.6.0 2026-09-04 01:52:42 +00:00
renovate[bot]
fb741a4e54 Update mantine monorepo to v9.6.0 2026-09-03 17:39:13 +00:00
renovate[bot]
4319dfd0fe Update quarkus.version to v3.39.2 2026-09-03 13:57:00 +00:00
Jérémie Panzer
a06a0e893c Merge pull request #2297 from Athou/renovate/org.apache.maven.plugins-maven-compiler-plugin-3.x
Update dependency org.apache.maven.plugins:maven-compiler-plugin to v3.16.0
2026-09-03 07:13:35 +02:00
renovate[bot]
8517092a88 Update dependency org.apache.maven.plugins:maven-compiler-plugin to v3.16.0 2026-09-03 02:03:10 +00:00
renovate[bot]
52e79dfaa7 Update graalvm/setup-graalvm action to v1.6.6 2026-09-02 17:42:08 +00:00
Athou
7fb19fd032 use exact version 2026-09-02 19:41:10 +02:00
Athou
6344657940 speed up client compilation by using oxc-transform-react as the react compiler 2026-09-02 19:17:03 +02:00
renovate[bot]
3f01a99b66 Update dependency org.projectlombok:lombok to v1.18.48 2026-09-02 06:05:10 +00:00
renovate[bot]
69d7350e89 Update actions/deploy-pages action to v5.0.1 2026-09-02 03:42:53 +00:00
renovate[bot]
9ee502f437 Update dependency io.github.git-commit-id:git-commit-id-maven-plugin to v10.0.1 2026-09-01 20:30:15 +00:00
Jérémie Panzer
b8b876535b Merge pull request #2295 from Athou/renovate/graalvm-setup-graalvm-1.6.x
Update graalvm/setup-graalvm action to v1.6.5
2026-09-01 22:29:25 +02:00
renovate[bot]
816edefe25 Update graalvm/setup-graalvm action to v1.6.5 2026-09-01 12:21:17 +00:00
Jérémie Panzer
c523ff608e Merge pull request #2294 from Athou/renovate/docker-setup-qemu-action-4.x
Update docker/setup-qemu-action action to v4.3.0
2026-09-01 14:20:18 +02:00
renovate[bot]
663f14c439 Update docker/setup-qemu-action action to v4.3.0 2026-09-01 11:32:29 +00:00
renovate[bot]
20c6ef6702 Update ibm-semeru-runtimes Docker tag to open-jdk-25.0.4.10-jre 2026-09-01 02:28:14 +00:00
renovate[bot]
91241f045f Update dependency com.google.protobuf:protobuf-java to v4.36.1 2026-08-31 22:47:46 +00:00
renovate[bot]
82e5c54cdc Update dependency react-router-dom to v7.18.3 2026-08-31 17:11:13 +00:00
Jérémie Panzer
4b17102e5a Merge pull request #2293 from dcelasun/chore/host-docs
Document Quarkus config for reverse proxies
2026-08-31 19:10:17 +02:00
D. Can Celasun
bc58324a56 Document Quarkus config for reverse proxies
Recent Quarkus versions started enforcing this. Let's document it so
users aren't caught unaware.
2026-08-31 17:14:45 +01:00
renovate[bot]
b43bcf244a Update dependency @vitejs/plugin-react to v6.1.1 2026-08-31 06:03:56 +00:00
renovate[bot]
befe1a0bdc Lock file maintenance 2026-08-31 03:01:31 +00:00
renovate[bot]
1aae5433f3 Update dependency @biomejs/biome to v2.5.11 2026-08-30 21:52:34 +00:00
renovate[bot]
afeb12686b Update dependency @testing-library/react to v16.3.3 2026-08-30 19:32:18 +00:00
Jérémie Panzer
75f4b94b26 Merge pull request #2290 from Athou/renovate/com.puppycrawl.tools-checkstyle-14.x
Update dependency com.puppycrawl.tools:checkstyle to v14.1.0
2026-08-30 21:31:18 +02:00
renovate[bot]
4a867904ad Update dependency com.puppycrawl.tools:checkstyle to v14.1.0 2026-08-30 17:54:42 +00:00
Jérémie Panzer
249b161243 Merge pull request #2288 from Athou/renovate/axios-1.x
Update dependency axios to v1.20.0
2026-08-29 11:35:03 +02:00
renovate[bot]
a709a3268a Update dependency axios to v1.20.0 2026-08-29 08:57:39 +00:00
renovate[bot]
50b8a06f63 Update dependency io.dropwizard.metrics:metrics-json to v4.2.40 2026-08-28 21:11:04 +00:00
renovate[bot]
29e6338b41 Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.10.1 2026-08-27 21:03:55 +00:00
renovate[bot]
82e9fd7f2e Update dependency @types/react-dom to v19.2.5 2026-08-27 01:04:14 +00:00
renovate[bot]
8eb41eec53 Update dependency io.github.hakky54:ayza-for-apache5 to v10.0.7 2026-08-26 19:26:48 +00:00
Jérémie Panzer
07f96019b1 Merge pull request #2285 from Athou/renovate/node-24.x
Update Node.js to v24.20.0
2026-08-26 21:25:29 +02:00
Jérémie Panzer
1a0d4cb8cd Merge pull request #2286 from Athou/renovate/quarkus.version
Update quarkus.version to v3.39.1
2026-08-26 21:25:26 +02:00
renovate[bot]
5ae34047ee Update quarkus.version to v3.39.1 2026-08-26 18:03:22 +00:00
renovate[bot]
260181ec7c Update Node.js to v24.20.0 2026-08-26 18:03:11 +00:00
renovate[bot]
21bc826eda Update dependency org.jsoup:jsoup to v1.23.2 2026-08-26 02:52:35 +00:00
renovate[bot]
e1d9125a5c Update mantine monorepo to v9.5.2 2026-08-25 19:38:44 +00:00
renovate[bot]
8d1b836f59 Update dependency @testing-library/user-event to v14.6.6 2026-08-25 18:44:45 +00:00
renovate[bot]
bd50399483 Update dependency @biomejs/biome to v2.5.10 2026-08-25 17:00:04 +00:00
renovate[bot]
d641d25392 Update debian:13.6 Docker digest to f324c7f 2026-08-25 13:14:52 +00:00
Athou
a8c9f41fce disable the pluginTimings check because it prints false positives (fixes #2283) 2026-08-24 16:10:40 +02:00
Athou
2ff9d84584 decrease npm log level to prevent "npm notice" from logging as ERROR (fixes #2282) 2026-08-24 16:01:40 +02:00
Athou
11cd61cd44 nowrap on parent is actually enough 2026-08-24 08:21:54 +02:00
Athou
cf43988fae force the header in mobile mode if it is too large even if above the mobile breakpoint (fixed #2080) 2026-08-24 07:44:04 +02:00
renovate[bot]
4ef27e3027 Lock file maintenance 2026-08-24 01:10:53 +00:00
renovate[bot]
5ffabb387b Update dependency vite to v8.2.2 2026-08-23 06:46:05 +00:00
Jérémie Panzer
d78a45b7e5 Merge pull request #2281 from Athou/renovate/vitejs-plugin-react-6.x
Update dependency @vitejs/plugin-react to v6.1.0
2026-08-23 08:45:02 +02:00
renovate[bot]
bd5a9c382d Update dependency @vitejs/plugin-react to v6.1.0 2026-08-23 05:07:19 +00:00
renovate[bot]
e238e829ad Update react-querybuilder to v8.23.1 2026-08-22 18:52:47 +00:00
64 changed files with 1869 additions and 1851 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
os: [ "ubuntu-24.04", "ubuntu-22.04-arm", "windows-latest" ]
database: [ "h2", "postgresql", "mysql", "mariadb" ]
runs-on: ${{ matrix.os }}
@@ -29,14 +29,14 @@ jobs:
# Setup
- name: Set up GraalVM
uses: graalvm/setup-graalvm@5298d94fb55a4f185c602eeac5de1b553882abe2 # v1.6.4
uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm"
distribution: "mandrel"
cache: "maven"
- name: Set up NodeJS cache
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
cache: "npm"
cache-dependency-path: 'commafeed-client/package-lock.json'
@@ -47,7 +47,7 @@ jobs:
# Build & Test
- name: Build with Maven
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipITs=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
# Build pages
- name: Create pages directory structure
@@ -73,21 +73,21 @@ jobs:
# Upload artifacts
- name: Upload cross-platform app
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: matrix.os == 'ubuntu-24.04' # we only need to upload the cross-platform artifact once per database
with:
name: commafeed-${{ matrix.database }}-jvm
path: commafeed-server/target/commafeed-*.zip
- name: Upload native executable
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
path: commafeed-server/target/commafeed-*-runner*
- name: Upload pages
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
if: matrix.os == 'ubuntu-24.04' && matrix.database == 'h2' # we only need to upload the pages once
with:
path: target/pages
@@ -110,17 +110,17 @@ jobs:
# Setup
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Install required packages
run: sudo apt-get install -y rename unzip
# Prepare artifacts
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: commafeed-${{ matrix.database }}-*
path: ./artifacts
@@ -141,7 +141,7 @@ jobs:
# Docker
- name: Login to Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
if: ${{ env.DOCKERHUB_USERNAME != '' }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
## build but don't push for PRs and renovate
- name: Docker build - native
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.native
@@ -157,7 +157,7 @@ jobs:
platforms: linux/amd64,linux/arm64/v8
- name: Docker build - jvm
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.jvm
@@ -166,7 +166,7 @@ jobs:
## build and push tag
- name: Docker build and push tag - native
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
if: ${{ github.ref_type == 'tag' }}
with:
context: .
@@ -178,7 +178,7 @@ jobs:
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
- name: Docker build and push tag - jvm
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
if: ${{ github.ref_type == 'tag' }}
with:
context: .
@@ -191,7 +191,7 @@ jobs:
## build and push master
- name: Docker build and push master - native
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
if: ${{ github.ref_name == 'master' }}
with:
context: .
@@ -201,7 +201,7 @@ jobs:
tags: athou/commafeed:master-${{ matrix.database }}
- name: Docker build and push master - jvm
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
if: ${{ github.ref_name == 'master' }}
with:
context: .
@@ -226,7 +226,7 @@ jobs:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: commafeed-*
path: ./artifacts
@@ -280,5 +280,5 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
id: deployment

View File

@@ -1,3 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip

View File

@@ -1,5 +1,19 @@
# Changelog
## [7.4.1]
- Fix entries being marked as read when scrolling back to an entry that was explicitly marked as unread (#2337)
- Add a global failsafe timeout to the new HTTP/2 client to prevent it from hanging indefinitely
- Add OCI source label to container images (#2335)
## [7.4.0]
- Add support for HTTP/2 when fetching feeds (#2317)
- Send more default headers when fetching feeds, to avoid being rejected by some servers (#2315)
- Harden the content cleaning of feed entries by only allowing `http`/`https` protocols in `iframe` `src` attributes
- Fix the header in mobile mode being wrapped/cut off when too wide, even above the mobile breakpoint (#2080)
- Document how to configure Quarkus host validation when running CommaFeed behind a reverse proxy
## [7.3.2]
- Prevent users from starring/tagging entries that are not theirs and subsequently reading them (GHSA-prfv-88mm-5gpg)

View File

@@ -167,6 +167,20 @@ CommaFeed blocks access to local resources by default to prevent [SSRF](https://
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `commafeed.http-client.block-local-addresses` variable to `false`.
Do this only if you trust all users of your CommaFeed instance not to access private resources.
### Listen on a single network interface
By default, CommaFeed listens on all interfaces. You can restrict it by setting `quarkus.http.host`.
Note that if you set it to a local name like `127.0.0.1` host validation is enabled automatically. This prevents
access if you're using a reverse proxy like Nginx. To fix, add your actual hostname to `allowed-hosts`:
```
quarkus.http.host=127.0.0.1
quarkus.http.proxy.proxy-address-forwarding=true
quarkus.http.proxy.allow-forwarded=true
quarkus.http.host-validation.allowed-hosts=commafeed.example.com
```
## Translation
Files for internationalization are

1
commafeed-client/.npmrc Normal file
View File

@@ -0,0 +1 @@
loglevel=warn

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.9/schema.json",
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 4,

File diff suppressed because it is too large Load Diff

View File

@@ -17,35 +17,35 @@
"dependencies": {
"@emotion/react": "11.14.0",
"@fontsource/open-sans": "5.3.0",
"@lingui/core": "6.6.0",
"@lingui/react": "6.6.0",
"@mantine/core": "9.5.1",
"@mantine/dates": "9.5.1",
"@mantine/form": "9.5.1",
"@mantine/hooks": "9.5.1",
"@mantine/modals": "9.5.1",
"@mantine/notifications": "9.5.1",
"@mantine/spotlight": "9.5.1",
"@lingui/core": "6.11.0",
"@lingui/react": "6.11.0",
"@mantine/core": "9.7.4",
"@mantine/dates": "9.7.4",
"@mantine/form": "9.7.4",
"@mantine/hooks": "9.7.4",
"@mantine/modals": "9.7.4",
"@mantine/notifications": "9.7.4",
"@mantine/spotlight": "9.7.4",
"@monaco-editor/react": "4.7.0",
"@react-querybuilder/mantine": "8.23.0",
"@reduxjs/toolkit": "2.12.0",
"@rolldown/plugin-babel": "0.2.3",
"axios": "1.19.0",
"@react-querybuilder/mantine": "8.26.0",
"@reduxjs/toolkit": "2.13.0",
"@rolldown/plugin-babel": "0.3.0",
"axios": "1.21.0",
"dayjs": "1.11.23",
"escape-string-regexp": "5.0.0",
"interweave": "13.1.1",
"monaco-editor": "0.56.0",
"monaco-editor": "0.57.0",
"mousetrap": "1.6.5",
"react": "19.2.8",
"react": "19.3.1",
"react-async-hook": "4.0.0",
"react-contexify": "6.0.0",
"react-dom": "19.2.8",
"react-draggable": "4.7.1",
"react-dom": "19.3.1",
"react-draggable": "4.7.2",
"react-icons": "5.7.0",
"react-infinite-scroller": "1.2.6",
"react-querybuilder": "8.23.0",
"react-redux": "9.3.0",
"react-router-dom": "7.18.2",
"react-querybuilder": "8.26.0",
"react-redux": "9.3.1",
"react-router-dom": "7.18.5",
"react-swipeable": "7.0.2",
"style-to-object": "2.0.2",
"throttle-debounce": "5.0.2",
@@ -54,32 +54,30 @@
"websocket-heartbeat-js": "1.1.3"
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@biomejs/biome": "2.5.9",
"@lingui/babel-plugin-lingui-macro": "6.6.0",
"@lingui/cli": "6.6.0",
"@lingui/vite-plugin": "6.6.0",
"@biomejs/biome": "2.6.4",
"@lingui/cli": "6.11.0",
"@lingui/vite-plugin": "6.11.0",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.5",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@types/mousetrap": "1.6.15",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/react-infinite-scroller": "1.2.5",
"@types/throttle-debounce": "5.0.2",
"@types/tinycon": "0.6.7",
"@vitejs/plugin-react": "6.0.5",
"babel-plugin-react-compiler": "1.0.0",
"jsdom": "30.0.1",
"@vitejs/plugin-react": "6.1.2",
"babel-plugin-react-compiler": "^1.0.0",
"jsdom": "30.1.3",
"typescript": "7.0.2",
"vite": "8.2.1",
"vite": "8.3.5",
"vite-plugin-checker": "0.14.5",
"vitest": "4.1.11",
"yaml": "2.9.0"
"vitest": "5.0.5",
"yaml": "2.9.1"
},
"overrides": {
"react-infinite-scroller": {
"react": "19.2.8"
"react": "19.3.1"
}
}
}

View File

@@ -6,16 +6,16 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>7.3.2</version>
<version>7.4.1</version>
</parent>
<artifactId>commafeed-client</artifactId>
<name>CommaFeed Client</name>
<properties>
<!-- renovate: datasource=node-version depName=node -->
<node.version>v24.19.0</node.version>
<node.version>v26.13.0</node.version>
<!-- renovate: datasource=npm depName=npm -->
<npm.version>12.0.2</npm.version>
<npm.version>12.2.1</npm.version>
</properties>
<build>
@@ -110,7 +110,7 @@
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<version>3.6.4</version>
<executions>
<execution>
<id>kill-biome</id>

View File

@@ -116,6 +116,30 @@ describe("entries", () => {
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
})
it("flags an entry marked as unread by the user until entries are reloaded", async () => {
vi.mocked(client.feed.getEntries).mockResolvedValue({
data: {
entries: [{ id: "3", read: true, markable: true } as Entry],
hasMore: false,
name: "my-feed",
errorCount: 0,
feedLink: "https://mysite.com/feed",
timestamp: 123,
ignoredReadStatus: false,
},
} as AxiosResponse<Entries>)
const store = configureStore({ reducer: reducers })
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
store.dispatch(markEntry({ entry: store.getState().entries.entries[0], read: false }))
expect(store.getState().entries.entries[0].keepUnread).toBe(true)
// reloading the entry list resets the flag
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: false }))
expect(store.getState().entries.entries[0].keepUnread).toBeUndefined()
})
it("marks all entries as read", () => {
const store = configureStore({
reducer: reducers,

View File

@@ -10,7 +10,11 @@ export interface EntrySource {
id: string
}
export type ExpendableEntry = Entry & { expanded?: boolean }
export type ExpendableEntry = Entry & {
expanded?: boolean
/** set when the user explicitly marks the entry as unread, prevents it from being marked as read again by scrolling in expanded mode until the entry list is reloaded */
keepUnread?: boolean
}
interface EntriesState {
/** selected source */
@@ -71,11 +75,13 @@ export const entriesSlice = createSlice({
builder.addCase(markEntry.pending, (state, action) => {
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
e.read = action.meta.arg.read
if (!action.meta.arg.read) e.keepUnread = true
}
})
builder.addCase(markMultipleEntries.pending, (state, action) => {
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
e.read = action.meta.arg.read
if (!action.meta.arg.read) e.keepUnread = true
}
})
builder.addCase(markAllEntries.pending, (state, action) => {

View File

@@ -40,7 +40,7 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
const state = thunkApi.getState()
const { source } = state.entries
const offset =
state.user.settings?.readingMode === "all" || (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.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type)
@@ -137,6 +137,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
const state = thunkApi.getState()
const source = state.entries.source
const entriesTimestamp = state.entries.timestamp ?? Date.now()
const search = state.entries.search
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
@@ -151,6 +152,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
read: true,
olderThan: Date.now(),
insertedBefore: entriesTimestamp,
keywords: search,
},
})
)

View File

@@ -5,14 +5,15 @@ import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
import { Constants } from "@/app/constants"
import { useActionButton } from "@/hooks/useActionButton"
export type Mode = "auto" | "mobile" | "desktop"
interface ActionButtonProps {
icon: ReactNode
className?: string
label?: string | MessageDescriptor
onClick?: MouseEventHandler
variant?: ActionIconVariant & ButtonVariant
hideLabelOnDesktop?: boolean
showLabelOnMobile?: boolean
mode?: Mode
}
/**
@@ -25,7 +26,9 @@ export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
const variant = props.variant ?? "subtle"
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
const mode: Mode = props.mode ?? "auto"
const iconOnly = mode === "mobile" || (mode === "auto" && mobile)
return (
<Box ref={ref} className="cf-action-button">

View File

@@ -13,6 +13,7 @@ export function MarkAllAsReadConfirmationDialog() {
const source = useAppSelector(state => state.entries.source)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
const search = useAppSelector(state => state.entries.search)
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
const dispatch = useAppDispatch()
@@ -26,6 +27,7 @@ export function MarkAllAsReadConfirmationDialog() {
read: true,
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
insertedBefore: entriesTimestamp,
keywords: search,
},
})
)

View File

@@ -111,12 +111,13 @@ export function FeedEntries() {
const el = document.getElementById(Constants.dom.entryId(e))
return el && !Constants.layout.isTopVisible(el)
})
if (currentEntry) {
if (currentEntry && currentEntry.id !== selectedEntryId) {
dispatch(
selectEntry({
entry: currentEntry,
expand: false,
markAsRead: !!scrollMarks,
// don't mark as read entries that the user explicitly marked as unread
markAsRead: !!scrollMarks && !currentEntry.keepUnread,
scrollToEntry: false,
})
)
@@ -124,7 +125,7 @@ export function FeedEntries() {
})
window.addEventListener("scroll", listener)
return () => window.removeEventListener("scroll", listener)
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry, selectedEntryId])
useMousetrap("r", async () => await dispatch(reloadEntries()))
useMousetrap(

View File

@@ -2,7 +2,7 @@ import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react"
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form"
import { useEffect } from "react"
import { type ReactNode, useEffect } from "react"
import {
TbArrowDown,
TbArrowUp,
@@ -31,7 +31,7 @@ function HeaderDivider() {
return <Divider orientation="vertical" />
}
function HeaderToolbar(props: { children: React.ReactNode }) {
function HeaderToolbar(props: { children: ReactNode }) {
const { spacing } = useActionButton()
const mobile = useMobile("480px")
return mobile ? (
@@ -47,7 +47,7 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
{props.children}
</Box>
) : (
<Group gap={spacing} className="cf-toolbar">
<Group gap={spacing} wrap="nowrap" className="cf-toolbar">
{props.children}
</Group>
)

View File

@@ -42,7 +42,7 @@ export function WelcomePage() {
icon={<TbClock size={iconSize} />}
variant="outline"
onClick={async () => await login.execute({ name: "demo", password: "demo" })}
showLabelOnMobile
mode="desktop"
/>
</Center>
)}
@@ -100,7 +100,7 @@ function Buttons() {
icon={<TbKey size={iconSize} />}
variant="outline"
onClick={async () => await dispatch(redirectToLogin())}
showLabelOnMobile
mode="desktop"
/>
{serverInfos?.allowRegistrations && (
<ActionButton
@@ -108,7 +108,7 @@ function Buttons() {
icon={<TbUserPlus size={iconSize} />}
variant="filled"
onClick={async () => await dispatch(redirectToRegistration())}
showLabelOnMobile
mode="desktop"
/>
)}
@@ -116,7 +116,7 @@ function Buttons() {
label={dark ? msg`Switch to light theme` : msg`Switch to dark theme`}
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
onClick={() => toggleColorScheme()}
hideLabelOnDesktop
mode="mobile"
/>
{isBrowserExtensionPopup && (
@@ -124,7 +124,7 @@ function Buttons() {
label={msg`Extension options`}
icon={<TbSettings size={iconSize} />}
onClick={() => openSettingsPage()}
hideLabelOnDesktop
mode="mobile"
/>
)}
</Group>

View File

@@ -37,6 +37,9 @@ export function CategoryDetailsPage() {
category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
}
// a category cannot be moved under itself or one of its own descendants
const excludedCategoryIds = category ? flattenCategoryTree(category).map(c => c.id) : [id]
const form = useForm<CategoryModificationRequest>()
const { setValues } = form
@@ -125,7 +128,7 @@ export function CategoryDetailsPage() {
label={<Trans>Parent Category</Trans>}
{...form.getInputProps("parentId")}
clearable
withoutCategoryIds={[id]}
withoutCategoryIds={excludedCategoryIds}
/>
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
</>

View File

@@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro"
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
import { useViewportSize } from "@mantine/hooks"
import { useEffect } from "react"
import { TbEdit } from "react-icons/tb"
import { TbSettings } from "react-icons/tb"
import { useLocation, useParams } from "react-router-dom"
import { Constants } from "@/app/constants"
import type { EntrySourceType } from "@/app/entries/slice"
@@ -138,7 +138,7 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
</Title>
)}
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
<TbEdit size={18} />
<TbSettings size={18} />
</ActionIcon>
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
</Group>

View File

@@ -227,7 +227,7 @@ export default function Layout(props: Readonly<LayoutProps>) {
)}
</OnMobile>
<OnDesktop>
<Group p="md">
<Group p="md" wrap="nowrap">
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
<Box>
<LogoAndTitle />

View File

@@ -1,4 +1,4 @@
import "@testing-library/jest-dom"
import "@testing-library/jest-dom/vitest"
import { vi } from "vitest"
import { Constants } from "@/app/constants"

View File

@@ -7,11 +7,8 @@ import checker from "vite-plugin-checker"
export default defineConfig(() => ({
plugins: [
react(),
babel({
presets: [reactCompilerPreset()],
plugins: ["@lingui/babel-plugin-lingui-macro"],
}),
lingui(),
lingui({ macroTransform: true }),
babel({ presets: [reactCompilerPreset()] }),
checker({
// temporary disabled until TypeScript 7 exposes a stable api
// typescript: true,
@@ -44,6 +41,7 @@ export default defineConfig(() => ({
inconsistentCjsInterop: true,
},
test: {
isolate: false,
environment: "jsdom",
globals: true,
setupFiles: "./src/setupTests.ts",
@@ -51,6 +49,9 @@ export default defineConfig(() => ({
build: {
chunkSizeWarningLimit: 4000,
rolldownOptions: {
checks: {
pluginTimings: false,
},
output: {
codeSplitting: {
groups: [

View File

@@ -6,18 +6,18 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>7.3.2</version>
<version>7.4.1</version>
</parent>
<artifactId>commafeed-server</artifactId>
<name>CommaFeed Server</name>
<packaging>quarkus</packaging>
<properties>
<quarkus.version>3.38.3</quarkus.version>
<querydsl.version>7.6</querydsl.version>
<quarkus.version>3.40.1.1</quarkus.version>
<querydsl.version>7.7</querydsl.version>
<rome.version>2.1.0</rome.version>
<!-- renovate: depName=com.google.googlejavaformat:google-java-format -->
<google-java-format.version>1.36.1</google-java-format.version>
<google-java-format.version>2.0.0</google-java-format.version>
<build.database>h2</build.database>
</properties>
@@ -35,7 +35,7 @@
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>4.36.0</version>
<version>4.36.2</version>
</dependency>
</dependencies>
</dependencyManagement>
@@ -64,7 +64,7 @@
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
<version>1.3.0</version>
<version>1.3.1</version>
<executions>
<execution>
<goals>
@@ -116,7 +116,7 @@
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<version>3.6.4</version>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
@@ -164,7 +164,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
<version>3.6.1</version>
<configuration>
<argLine>@{argLine}</argLine>
<systemPropertyVariables>
@@ -176,7 +176,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.6</version>
<version>3.6.1</version>
<executions>
<execution>
<goals>
@@ -221,7 +221,7 @@
<plugin>
<groupId>io.github.git-commit-id</groupId>
<artifactId>git-commit-id-maven-plugin</artifactId>
<version>10.0.0</version>
<version>10.0.1</version>
<executions>
<execution>
<goals>
@@ -245,7 +245,7 @@
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>14.0.0</version>
<version>14.4.0</version>
</dependency>
</dependencies>
<executions>
@@ -274,7 +274,7 @@
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>3.10.0</version>
<version>3.10.4</version>
<?m2e ignore?>
<executions>
<execution>
@@ -302,14 +302,14 @@
<dependency>
<groupId>com.commafeed</groupId>
<artifactId>commafeed-client</artifactId>
<version>7.3.2</version>
<version>7.4.1</version>
</dependency>
<!-- compile-time processors -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<version>1.18.48</version>
</dependency>
<dependency>
<groupId>org.kohsuke.metainf-services</groupId>
@@ -322,6 +322,11 @@
<artifactId>quarkus-extension-processor</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.graalvm.sdk</groupId>
<artifactId>nativeimage</artifactId>
<scope>provided</scope>
</dependency>
<!-- quarkus dependencies -->
<dependency>
@@ -368,7 +373,7 @@
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-json</artifactId>
<version>4.2.39</version>
<version>4.2.40</version>
</dependency>
<dependency>
<groupId>io.github.openfeign.querydsl</groupId>
@@ -399,7 +404,7 @@
<dependency>
<groupId>dev.cel</groupId>
<artifactId>cel</artifactId>
<version>0.14.0</version>
<version>0.15.0</version>
</dependency>
<dependency>
<groupId>org.passay</groupId>
@@ -429,12 +434,12 @@
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.23.1</version>
<version>1.23.2</version>
</dependency>
<dependency>
<groupId>com.ibm.icu</groupId>
<artifactId>icu4j</artifactId>
<version>78.3</version>
<version>79.1</version>
</dependency>
<dependency>
<groupId>net.sourceforge.cssparser</groupId>
@@ -449,17 +454,12 @@
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.6.4</version>
<version>5.6.5</version>
</dependency>
<dependency>
<groupId>io.github.hakky54</groupId>
<artifactId>ayza-for-apache5</artifactId>
<version>10.0.6</version>
</dependency>
<dependency>
<groupId>org.brotli</groupId>
<artifactId>dec</artifactId>
<version>0.1.2</version>
<version>10.1.0</version>
</dependency>
<dependency>
<groupId>com.github.seancfoley</groupId>
@@ -476,13 +476,13 @@
<dependency>
<groupId>io.quarkiverse.playwright</groupId>
<artifactId>quarkus-playwright</artifactId>
<version>2.3.8</version>
<version>2.3.10</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-junit-jupiter</artifactId>
<version>7.6.0</version>
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
<version>9.2.0</version>
<scope>test</scope>
</dependency>
<dependency>

View File

@@ -1,4 +1,6 @@
FROM ibm-semeru-runtimes:open-jdk-25.0.4.0-jre@sha256:524f0d04723aff42ed4142ab2621fa55f22cb691763cd75b59eee86749e2e872
FROM ibm-semeru-runtimes:open-jdk-25.0.4.15-jre@sha256:c4f8cd5ee9d607addbf957a5066144c46c853c6a81235819f6952e60237904ca
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
EXPOSE 8082
RUN mkdir -p /commafeed/data

View File

@@ -1,4 +1,6 @@
FROM debian:13.6@sha256:34cd9e9fd437c0a095ec39cb2e73422c9f30821b0d0848ed74fd0d43bae4d958
FROM debian:13.7@sha256:2dc419c6d7fe3dad0a2f9fd469c34cc90b0f9ba642d8fde837fe3d7d34a0e7f1
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
ARG TARGETARCH
EXPOSE 8082

View File

@@ -6,6 +6,8 @@ import com.codahale.metrics.Histogram;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer;
import com.oracle.svm.core.annotate.Substitute;
import com.oracle.svm.core.annotate.TargetClass;
import io.quarkus.runtime.annotations.RegisterForReflection;
@@ -346,4 +348,22 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
com.rometools.opml.feed.synd.impl.ConverterForOPML10.class,
com.rometools.opml.feed.synd.impl.ConverterForOPML20.class,
})
public class NativeImageClasses {}
public class NativeImageClasses {
// disable zstd and brotli runtime detection because it causes native image failure
@TargetClass(className = "org.apache.hc.client5.http.impl.ZstdRuntime")
static final class ZstdRuntime {
@Substitute
public static boolean available() {
return false;
}
}
@TargetClass(className = "org.apache.hc.client5.http.impl.Brotli4jRuntime")
static final class Brotli4jRuntime {
@Substitute
public static boolean available() {
return false;
}
}
}

View File

@@ -19,27 +19,27 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
import org.apache.hc.client5.http.async.methods.InflatingAsyncDataConsumer;
import org.apache.hc.client5.http.async.methods.InflatingGzipDataConsumer;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.TlsConfig;
import org.apache.hc.client5.http.entity.DeflateInputStream;
import org.apache.hc.client5.http.entity.InputStreamFactory;
import org.apache.hc.client5.http.entity.compress.ContentCoding;
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
import org.apache.hc.client5.http.protocol.RedirectStrategy;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.http.nio.AsyncDataConsumer;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http2.HttpVersionPolicy;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.brotli.dec.BrotliInputStream;
import java.net.InetAddress;
import java.net.URI;
@@ -48,7 +48,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.SequencedMap;
import java.util.zip.GZIPInputStream;
import java.util.function.UnaryOperator;
@Singleton
@RequiredArgsConstructor
@@ -60,56 +60,61 @@ public class HttpClientFactory {
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
public CloseableHttpClient newClient(int poolSize) {
PoolingHttpClientConnectionManager connectionManager =
public CloseableHttpAsyncClient newClient(int poolSize) {
PoolingAsyncClientConnectionManager connectionManager =
newConnectionManager(config, poolSize);
String userAgent =
config.httpClient()
.userAgent()
.orElseGet(
() ->
String.format(
"CommaFeed/%s (https://github.com/Athou/commafeed)",
version.getVersion()));
.orElseGet(() ->
String.format(
"CommaFeed/%s (https://github.com/Athou/commafeed)",
version.getVersion()));
return newClient(config, connectionManager, userAgent);
}
private CloseableHttpClient newClient(
private CloseableHttpAsyncClient newClient(
CommaFeedConfiguration config,
HttpClientConnectionManager connectionManager,
PoolingAsyncClientConnectionManager connectionManager,
String userAgent) {
List<Header> headers = new ArrayList<>();
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache"));
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
SequencedMap<String, InputStreamFactory> contentDecoderMap = new LinkedHashMap<>();
contentDecoderMap.put(ContentCoding.GZIP.token(), GZIPInputStream::new);
contentDecoderMap.put(ContentCoding.DEFLATE.token(), DeflateInputStream::new);
contentDecoderMap.put(ContentCoding.BROTLI.token(), BrotliInputStream::new);
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap =
new LinkedHashMap<>();
contentDecoderMap.put(
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
RedirectStrategy redirectStrategy =
config.httpClient().blockLocalAddresses()
? new BlockLocalAddressesRedirectStrategy(DNS_RESOLVER)
: new DefaultRedirectStrategy();
return HttpClientBuilder.create()
.disableConnectionState()
.useSystemProperties()
.disableAutomaticRetries()
.disableCookieManagement()
.setUserAgent(userAgent)
.setDefaultHeaders(headers)
.setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderRegistry(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy)
.build();
CloseableHttpAsyncClient client =
HttpAsyncClients.custom()
.disableConnectionState()
.useSystemProperties()
.disableAutomaticRetries()
.disableCookieManagement()
.setUserAgent(userAgent)
.setDefaultHeaders(headers)
.setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy)
.build();
client.start();
return client;
}
private PoolingHttpClientConnectionManager newConnectionManager(
private PoolingAsyncClientConnectionManager newConnectionManager(
CommaFeedConfiguration config, int poolSize) {
SSLFactory sslFactory =
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
@@ -118,8 +123,8 @@ public class HttpClientFactory {
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
: DNS_RESOLVER;
return PoolingHttpClientConnectionManagerBuilder.create()
.setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory))
return PoolingAsyncClientConnectionManagerBuilder.create()
.setTlsStrategy(Apache5SslUtils.toTlsStrategy(sslFactory))
.setDefaultConnectionConfig(
ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
@@ -129,6 +134,7 @@ public class HttpClientFactory {
.build())
.setDefaultTlsConfig(
TlsConfig.custom()
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
.setHandshakeTimeout(
Timeout.of(config.httpClient().sslHandshakeTimeout()))
.build())

View File

@@ -6,7 +6,6 @@ import com.commafeed.CommaFeedConfiguration.HttpClientCache;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Iterables;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders;
import jakarta.inject.Singleton;
@@ -17,39 +16,53 @@ import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Lombok;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Delegate;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.protocol.RedirectLocations;
import org.apache.hc.client5.http.utils.DateUtils;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.EntityDetails;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.Message;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.util.Timeout;
import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
import org.apache.hc.core5.http.nio.AsyncRequestProducer;
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
import org.apache.hc.core5.http.nio.entity.AbstractBinAsyncEntityConsumer;
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.Instant;
import java.time.InstantSource;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** Smart HTTP getter: handles gzip, ssl, last modified and etag headers */
/** Smart HTTP getter: handles gzip, ssl, last modified, and etag headers */
@Singleton
@Slf4j
public class HttpGetter {
private final CommaFeedConfiguration config;
private final InstantSource instantSource;
private final CloseableHttpClient client;
private final CloseableHttpAsyncClient client;
private final Cache<HttpRequest, HttpResponse> cache;
public HttpGetter(
@@ -150,70 +163,96 @@ public class HttpGetter {
HttpClientContext context = HttpClientContext.create();
context.setRequestConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
// causes issues with some feeds
// see https://github.com/Athou/commafeed/issues/1572
// and https://issues.apache.org/jira/browse/HTTPCLIENT-2344
.setProtocolUpgradeEnabled(false)
.build());
return client.execute(
request.toClassicHttpRequest(),
context,
resp -> {
byte[] content =
resp.getEntity() == null
? null
: toByteArray(
resp.getEntity(),
config.httpClient().maxResponseSize().asLongValue());
int code = resp.getCode();
String lastModifiedHeader =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
String eTagHeader =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
long maxResponseSize = config.httpClient().maxResponseSize().asLongValue();
AsyncRequestProducer req = SimpleRequestProducer.create(request.toSimpleHttpRequest());
AsyncResponseConsumer<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> resp =
new BasicResponseConsumer<>(
new LimitedContentLengthEntityConsumer(
new LimitedBytesEntityConsumer(maxResponseSize), maxResponseSize));
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
new FutureCallback<>() {
@Override
public void completed(
Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
CacheControl cacheControl =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(HttpGetter::toCacheControl)
.orElse(null);
@Override
public void failed(Exception ex) {}
Instant retryAfter =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(this::toInstant)
.orElse(null);
@Override
public void cancelled() {}
};
Future<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> future =
client.execute(req, resp, context, callback);
String contentType =
Optional.ofNullable(resp.getEntity())
.map(HttpEntity::getContentType)
.orElse(null);
String urlAfterRedirect =
Optional.ofNullable(context.getRedirectLocations())
.map(RedirectLocations::getAll)
.map(l -> Iterables.getLast(l, null))
.map(URI::toString)
.orElse(request.getUrl());
try {
Message<org.apache.hc.core5.http.HttpResponse, byte[]> message =
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS);
return new HttpResponse(
code,
lastModifiedHeader,
eTagHeader,
cacheControl,
retryAfter,
content,
contentType,
urlAfterRedirect);
});
byte[] content = message.getBody();
int code = message.getHead().getCode();
String lastModifiedHeader =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.LAST_MODIFIED))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
String eTagHeader =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.ETAG))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
CacheControl cacheControl =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CACHE_CONTROL))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(HttpGetter::toCacheControl)
.orElse(null);
Instant retryAfter =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.RETRY_AFTER))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(this::toInstant)
.orElse(null);
String contentType =
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CONTENT_TYPE))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
String urlAfterRedirect =
Optional.ofNullable(context.getRedirectLocations())
.map(RedirectLocations::getAll)
.map(l -> Iterables.getLast(l, null))
.map(URI::toString)
.orElse(request.getUrl());
return new HttpResponse(
code,
lastModifiedHeader,
eTagHeader,
cacheControl,
retryAfter,
content,
contentType,
urlAfterRedirect);
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new RuntimeException("interrupted while fetching " + request.getUrl(), e);
} catch (TimeoutException e) {
future.cancel(true);
throw new IOException("timed out while fetching " + request.getUrl(), e);
} catch (ExecutionException e) {
future.cancel(true);
throw Lombok.sneakyThrow(e.getCause());
}
}
private static CacheControl toCacheControl(String headerValue) {
@@ -237,28 +276,6 @@ public class HttpGetter {
return DateUtils.parseStandardDate(headerValue);
}
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
if (entity.getContentLength() > maxBytes) {
throw new IOException(
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
.formatted(entity.getContentLength(), maxBytes));
}
try (InputStream input = entity.getContent()) {
if (input == null) {
return null;
}
byte[] bytes = ByteStreams.limit(input, maxBytes + 1).readAllBytes();
if (bytes.length > maxBytes) {
throw new IOException(
"Response size exceeds the maximum allowed size (%s bytes)"
.formatted(maxBytes));
}
return bytes;
}
}
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
HttpClientCache cacheConfig = config.httpClient().cache();
if (!cacheConfig.enabled()) {
@@ -266,14 +283,67 @@ public class HttpGetter {
}
return CacheBuilder.newBuilder()
.weigher(
(HttpRequest key, HttpResponse value) ->
value.content() != null ? value.content().length : 0)
.weigher((HttpRequest key, HttpResponse value) ->
value.content() != null ? value.content().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration())
.build();
}
private record LimitedContentLengthEntityConsumer(
@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
implements AsyncEntityConsumer<byte[]> {
@Override
public void streamStart(EntityDetails entityDetails, FutureCallback<byte[]> resultCallback)
throws HttpException, IOException {
long contentLength = entityDetails.getContentLength();
if (contentLength > maxBytes) {
throw new IOException(
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
.formatted(contentLength, maxBytes));
}
delegate.streamStart(entityDetails, resultCallback);
}
}
@RequiredArgsConstructor
private static class LimitedBytesEntityConsumer extends AbstractBinAsyncEntityConsumer<byte[]> {
private final long maxBytes;
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
@Override
protected void streamStart(ContentType contentType) {}
@Override
protected int capacityIncrement() {
return Integer.MAX_VALUE;
}
@Override
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
if (out.size() + src.remaining() > maxBytes) {
throw new IOException(
"Response size exceeds the maximum allowed size (%s bytes)"
.formatted(maxBytes));
}
byte[] chunk = new byte[src.remaining()];
src.get(chunk);
out.write(chunk, 0, chunk.length);
}
@Override
protected byte[] generateContent() {
return out.toByteArray();
}
@Override
public void releaseResources() {
out.reset();
}
}
public static class SchemeNotAllowedException extends Exception {
private static final long serialVersionUID = 1L;
@@ -336,15 +406,15 @@ public class HttpGetter {
return new HttpRequestBuilder().url(url);
}
public ClassicHttpRequest toClassicHttpRequest() {
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build();
public SimpleHttpRequest toSimpleHttpRequest() {
SimpleRequestBuilder builder = SimpleRequestBuilder.get(url);
if (lastModified != null) {
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
builder.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
}
if (eTag != null) {
req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
builder.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
}
return req;
return builder.build();
}
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.QFeedCategory;
import com.commafeed.backend.model.QUser;
@@ -17,8 +18,8 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
public FeedCategoryDAO(EntityManager entityManager) {
super(entityManager, FeedCategory.class);
public FeedCategoryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedCategory.class, config);
}
public List<FeedCategory> findAll(User user) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.QFeed;
import com.commafeed.backend.model.QFeedSubscription;
@@ -20,8 +21,8 @@ public class FeedDAO extends GenericDAO<Feed> {
private static final QFeed FEED = QFeed.feed;
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
public FeedDAO(EntityManager entityManager) {
super(entityManager, Feed.class);
public FeedDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, Feed.class, config);
}
public List<Feed> findByIds(List<Long> id) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent;
@@ -15,8 +16,8 @@ public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
public FeedEntryContentDAO(EntityManager entityManager) {
super(entityManager, FeedEntryContent.class);
public FeedEntryContentDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntryContent.class, config);
}
public List<FeedEntryContent> findExisting(String contentHash, String titleHash) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.QFeedEntry;
@@ -23,8 +24,8 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
private static final int IN_CLAUSE_BATCH_SIZE = 1000;
public FeedEntryDAO(EntityManager entityManager) {
super(entityManager, FeedEntry.class);
public FeedEntryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntry.class, config);
}
public FeedEntry findExisting(String guidHash, Feed feed) {

View File

@@ -29,6 +29,7 @@ import org.apache.commons.collections4.CollectionUtils;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -50,7 +51,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
EntityManager entityManager,
FeedEntryTagDAO feedEntryTagDAO,
CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class);
super(entityManager, FeedEntryStatus.class, config);
this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config;
}
@@ -129,8 +130,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit);
}
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = query.fetch();
statuses.forEach(s -> s.setMarkable(true));
if (includeContent) {
@@ -207,8 +206,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit);
}
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = new ArrayList<>();
List<Tuple> tuples = query.fetch();
for (Tuple tuple : tuples) {
@@ -238,19 +235,41 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
}
}
public UnreadCount getUnreadCount(FeedSubscription sub) {
public Map<Long, UnreadCount> getUnreadCounts(List<FeedSubscription> subs) {
if (subs.isEmpty()) {
return Map.of();
}
Map<Long, List<FeedSubscription>> subsByFeedId =
subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId()));
JPAQuery<Tuple> query =
query().select(ENTRY.count(), ENTRY.published.max())
query().select(ENTRY.feed.id, ENTRY.count(), ENTRY.published.max())
.from(ENTRY)
.leftJoin(ENTRY.statuses, STATUS)
.on(STATUS.subscription.eq(sub))
.where(ENTRY.feed.eq(sub.getFeed()))
.where(buildUnreadPredicate());
.on(STATUS.subscription.in(subs))
.where(ENTRY.feed.id.in(subsByFeedId.keySet()))
.where(buildUnreadPredicate())
.groupBy(ENTRY.feed.id);
Tuple tuple = query.fetchOne();
Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max());
return new UnreadCount(sub.getId(), count == null ? 0 : count, published);
Map<Long, UnreadCount> result = new HashMap<>();
for (Tuple tuple : query.fetch()) {
Long feedId = tuple.get(ENTRY.feed.id);
Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max());
for (FeedSubscription sub : subsByFeedId.get(feedId)) {
result.put(
sub.getId(),
new UnreadCount(sub.getId(), count == null ? 0 : count, published));
}
}
// feeds with no unread entries are absent from the query result, default them to zero
for (FeedSubscription sub : subs) {
result.putIfAbsent(sub.getId(), new UnreadCount(sub.getId(), 0, null));
}
return result;
}
private BooleanBuilder buildUnreadPredicate() {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.QFeedEntryTag;
@@ -17,8 +18,8 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
public FeedEntryTagDAO(EntityManager entityManager) {
super(entityManager, FeedEntryTag.class);
public FeedEntryTagDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntryTag.class, config);
}
public List<String> findByUser(User user) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
@@ -31,8 +32,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
private final EntityManager entityManager;
public FeedSubscriptionDAO(EntityManager entityManager) {
super(entityManager, FeedSubscription.class);
public FeedSubscriptionDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedSubscription.class, config);
this.entityManager = entityManager;
}
@@ -124,10 +125,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
Set<Long> categoryIds =
categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
return findAll(user).stream()
.filter(
s ->
s.getCategory() != null
&& categoryIds.contains(s.getCategory().getId()))
.filter(s ->
s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
.toList();
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.AbstractModel;
import com.querydsl.core.types.EntityPath;
import com.querydsl.jpa.impl.JPADeleteClause;
@@ -21,9 +22,15 @@ public abstract class GenericDAO<T extends AbstractModel> {
private final EntityManager entityManager;
private final Class<T> entityClass;
private final CommaFeedConfiguration config;
protected JPAQueryFactory query() {
return new JPAQueryFactory(entityManager);
return new JPAQueryFactory(entityManager) {
@Override
public JPAQuery<?> query() {
return withTimeout(super.query(), config.database().queryTimeout());
}
};
}
protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) {
@@ -57,9 +64,10 @@ public abstract class GenericDAO<T extends AbstractModel> {
return objects.size();
}
protected void setTimeout(JPAQuery<?> query, Duration timeout) {
private JPAQuery<?> withTimeout(JPAQuery<?> query, Duration timeout) {
if (!timeout.isZero()) {
query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis()));
}
return query;
}
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUser;
import com.commafeed.backend.model.User;
@@ -11,8 +12,8 @@ public class UserDAO extends GenericDAO<User> {
private static final QUser USER = QUser.user;
public UserDAO(EntityManager entityManager) {
super(entityManager, User.class);
public UserDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, User.class, config);
}
public User findByName(String name) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUserRole;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
@@ -17,8 +18,8 @@ public class UserRoleDAO extends GenericDAO<UserRole> {
private static final QUserRole ROLE = QUserRole.userRole;
public UserRoleDAO(EntityManager entityManager) {
super(entityManager, UserRole.class);
public UserRoleDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, UserRole.class, config);
}
public List<UserRole> findAll() {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUserSettings;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
@@ -12,8 +13,8 @@ public class UserSettingsDAO extends GenericDAO<UserSettings> {
private static final QUserSettings SETTINGS = QUserSettings.userSettings;
public UserSettingsDAO(EntityManager entityManager) {
super(entityManager, UserSettings.class);
public UserSettingsDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, UserSettings.class, config);
}
public UserSettings findByUser(User user) {

View File

@@ -104,56 +104,52 @@ public class FeedRefreshEngine {
private void startFeedProcessingLoop() {
// take a feed from the queue, process it, rince, repeat
feedProcessingLoopExecutor.submit(
() -> {
while (!feedProcessingLoopExecutor.isShutdown()) {
try {
// take() is blocking until a feed is available from the queue
Feed feed = queue.take();
feedProcessingLoopExecutor.submit(() -> {
while (!feedProcessingLoopExecutor.isShutdown()) {
try {
// take() is blocking until a feed is available from the queue
Feed feed = queue.take();
// send the feed to be processed
log.debug(
"got feed {} from the queue, send it for processing",
feed.getId());
processFeedAsync(feed);
// send the feed to be processed
log.debug("got feed {} from the queue, send it for processing", feed.getId());
processFeedAsync(feed);
// we removed a feed from the queue, try to refill it as it may now be
// empty
if (queue.isEmpty()) {
log.debug("took the last feed from the queue, try to refill");
refillQueueAsync();
}
} catch (InterruptedException e) {
log.debug("interrupted while waiting for a feed in the queue");
Thread.currentThread().interrupt();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
// we removed a feed from the queue, try to refill it as it may now be
// empty
if (queue.isEmpty()) {
log.debug("took the last feed from the queue, try to refill");
refillQueueAsync();
}
});
} catch (InterruptedException e) {
log.debug("interrupted while waiting for a feed in the queue");
Thread.currentThread().interrupt();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
});
}
private void startRefillLoop() {
// refill the queue at regular intervals if it's empty
refillLoopExecutor.submit(
() -> {
while (!refillLoopExecutor.isShutdown()) {
try {
if (queue.isEmpty()) {
log.debug("refilling queue");
refillQueueAsync();
}
log.debug("sleeping for 15s");
TimeUnit.SECONDS.sleep(15);
} catch (InterruptedException e) {
log.debug("interrupted while sleeping");
Thread.currentThread().interrupt();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
refillLoopExecutor.submit(() -> {
while (!refillLoopExecutor.isShutdown()) {
try {
if (queue.isEmpty()) {
log.debug("refilling queue");
refillQueueAsync();
}
});
log.debug("sleeping for 15s");
TimeUnit.SECONDS.sleep(15);
} catch (InterruptedException e) {
log.debug("interrupted while sleeping");
Thread.currentThread().interrupt();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
});
}
public void refreshImmediately(Feed feed) {
@@ -184,65 +180,53 @@ public class FeedRefreshEngine {
}
},
refillExecutor)
.whenComplete(
(data, ex) -> {
if (ex != null) {
log.error("error while refilling the queue", ex);
}
});
.whenComplete((data, ex) -> {
if (ex != null) {
log.error("error while refilling the queue", ex);
}
});
}
private void processFeedAsync(Feed feed) {
CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor)
.thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor)
.thenCompose(
r -> {
List<CompletableFuture<Void>> futures =
r.insertedUnreadEntriesBySubscription().entrySet().stream()
.map(
e -> {
FeedSubscription sub = e.getKey();
List<FeedEntry> entries = e.getValue();
.thenCompose(r -> {
List<CompletableFuture<Void>> futures =
r.insertedUnreadEntriesBySubscription().entrySet().stream()
.map(e -> {
FeedSubscription sub = e.getKey();
List<FeedEntry> entries = e.getValue();
notifier.notifyOverWebsocket(sub, entries);
return CompletableFuture.runAsync(
() ->
notifier
.sendPushNotifications(
sub,
entries),
notifierExecutor);
})
.toList();
return CompletableFuture.allOf(
futures.toArray(CompletableFuture[]::new));
})
.exceptionally(
ex -> {
log.error("error while processing feed {}", feed.getUrl(), ex);
return null;
});
notifier.notifyOverWebsocket(sub, entries);
return CompletableFuture.runAsync(
() -> notifier.sendPushNotifications(sub, entries),
notifierExecutor);
})
.toList();
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
})
.exceptionally(ex -> {
log.error("error while processing feed {}", feed.getUrl(), ex);
return null;
});
}
private List<Feed> getNextUpdatableFeeds(int max) {
return unitOfWork.call(
() -> {
Instant lastLoginThreshold =
config.feedRefresh().userInactivityPeriod().isZero()
? null
: Instant.now()
.minus(config.feedRefresh().userInactivityPeriod());
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
if (!feeds.isEmpty()) {
// update disabledUntil to prevent feeds from being returned again by
// feedDAO.findNextUpdatable()
Instant nextUpdateDate =
Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil(
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
}
return feeds;
});
return unitOfWork.call(() -> {
Instant lastLoginThreshold =
config.feedRefresh().userInactivityPeriod().isZero()
? null
: Instant.now().minus(config.feedRefresh().userInactivityPeriod());
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
if (!feeds.isEmpty()) {
// update disabledUntil to prevent feeds from being returned again by
// feedDAO.findNextUpdatable()
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil(
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
}
return feeds;
});
}
private int getBatchSize() {
@@ -292,19 +276,18 @@ public class FeedRefreshEngine {
ThreadPoolExecutor pool =
new ThreadPoolExecutor(
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler(
(r, e) -> {
if (e.isShutdown()) {
return;
}
pool.setRejectedExecutionHandler((r, e) -> {
if (e.isShutdown()) {
return;
}
try {
e.getQueue().put(r);
} catch (InterruptedException ex) {
log.debug("interrupted while waiting for a slot in the queue.", ex);
Thread.currentThread().interrupt();
}
});
try {
e.getQueue().put(r);
} catch (InterruptedException ex) {
log.debug("interrupted while waiting for a slot in the queue.", ex);
Thread.currentThread().interrupt();
}
});
return pool;
}
}

View File

@@ -91,24 +91,22 @@ public class FeedRefreshUpdater {
if (locked1 && locked2) {
processed = true;
insertedEntry =
unitOfWork.call(
() -> {
if (feedEntryService.find(feed, entry) != null) {
// entry already exists, nothing to do
return null;
}
unitOfWork.call(() -> {
if (feedEntryService.find(feed, entry) != null) {
// entry already exists, nothing to do
return null;
}
FeedEntry feedEntry = feedEntryService.create(feed, entry);
entryInserted.mark();
for (FeedSubscription sub : subscriptions) {
boolean unread =
feedEntryService.applyFilter(sub, feedEntry);
if (unread) {
subscriptionsForWhichEntryIsUnread.add(sub);
}
}
return feedEntry;
});
FeedEntry feedEntry = feedEntryService.create(feed, entry);
entryInserted.mark();
for (FeedSubscription sub : subscriptions) {
boolean unread = feedEntryService.applyFilter(sub, feedEntry);
if (unread) {
subscriptionsForWhichEntryIsUnread.add(sub);
}
}
return feedEntry;
});
} else {
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
}
@@ -147,14 +145,13 @@ public class FeedRefreshUpdater {
AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions);
processed &= addEntryResult.processed;
inserted += addEntryResult.insertedEntry != null ? 1 : 0;
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(
sub -> {
if (addEntryResult.insertedEntry != null) {
insertedUnreadEntriesBySubscription
.computeIfAbsent(sub, k -> new ArrayList<>())
.add(addEntryResult.insertedEntry);
}
});
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> {
if (addEntryResult.insertedEntry != null) {
insertedUnreadEntriesBySubscription
.computeIfAbsent(sub, k -> new ArrayList<>())
.add(addEntryResult.insertedEntry);
}
});
}
if (inserted == 0) {

View File

@@ -65,20 +65,17 @@ public class OPMLExporter {
for (FeedCategory child :
categories.stream()
.filter(
c ->
c.getParent() != null
&& c.getParent().getId().equals(cat.getId()))
.filter(c ->
c.getParent() != null && c.getParent().getId().equals(cat.getId()))
.toList()) {
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
}
for (FeedSubscription sub :
subscriptions.stream()
.filter(
s ->
s.getCategory() != null
&& s.getCategory().getId().equals(cat.getId()))
.filter(s ->
s.getCategory() != null
&& s.getCategory().getId().equals(cat.getId()))
.toList()) {
outline.getChildren().add(buildSubscriptionOutline(sub));
}

View File

@@ -148,6 +148,7 @@ public class FeedEntryContentCleaningService {
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
whitelist.addProtocols("blockquote", "cite", "http", "https");
whitelist.addProtocols("iframe", "src", "http", "https");
whitelist.addProtocols("img", "src", "http", "https");
whitelist.addProtocols("q", "cite", "http", "https");

View File

@@ -45,8 +45,7 @@ public class FeedEntryFilteringService {
.addVar("categories", SimpleType.STRING)
.addVar("categoriesLower", SimpleType.STRING)
.build();
private static final CelRuntime CEL_RUNTIME =
CelRuntimeFactory.standardCelRuntimeBuilder().build();
private static final CelRuntime CEL_RUNTIME = CelRuntimeFactory.plannerRuntimeBuilder().build();
private final ExecutorService executor = Executors.newCachedThreadPool();
private final CommaFeedConfiguration config;

View File

@@ -148,26 +148,23 @@ public class FeedEntryService {
List<FeedEntryStatus> statusesToMark =
statuses.stream()
.filter(FeedEntryStatus::isMarkable)
.filter(
s -> {
Instant entryDate = s.getEntry().getPublished();
return olderThan == null
|| entryDate == null
|| entryDate.isBefore(olderThan);
})
.filter(
s -> {
Instant insertedDate = s.getEntry().getInserted();
return insertedBefore == null
|| insertedDate == null
|| insertedDate.isBefore(insertedBefore);
})
.filter(s -> {
Instant entryDate = s.getEntry().getPublished();
return olderThan == null
|| entryDate == null
|| entryDate.isBefore(olderThan);
})
.filter(s -> {
Instant insertedDate = s.getEntry().getInserted();
return insertedBefore == null
|| insertedDate == null
|| insertedDate.isBefore(insertedBefore);
})
.toList();
statusesToMark.forEach(
s -> {
s.setRead(true);
feedEntryStatusDAO.merge(s);
});
statusesToMark.forEach(s -> {
s.setRead(true);
feedEntryStatusDAO.merge(s);
});
}
}

View File

@@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Singleton
@@ -47,14 +46,13 @@ public class FeedSubscriptionService {
// we need to use this hook because the feed needs to have been persisted before being
// processed
// by the feed engine
feedSubscriptionDAO.onPostCommitInsert(
sub -> {
Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null
|| feed.getDisabledUntil().isBefore(Instant.now())) {
feedRefreshEngine.refreshImmediately(feed);
}
});
feedSubscriptionDAO.onPostCommitInsert(sub -> {
Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null
|| feed.getDisabledUntil().isBefore(Instant.now())) {
feedRefreshEngine.refreshImmediately(feed);
}
});
}
public long subscribe(
@@ -116,10 +114,7 @@ public class FeedSubscriptionService {
}
public Map<Long, UnreadCount> getUnreadCount(User user) {
return feedSubscriptionDAO.findAll(user).stream()
.collect(
Collectors.toMap(
FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount));
return feedEntryStatusDAO.getUnreadCounts(feedSubscriptionDAO.findAll(user));
}
@SuppressWarnings("serial")

View File

@@ -16,26 +16,33 @@ import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.Message;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.util.Timeout;
import org.apache.hc.core5.http.nio.entity.DiscardingEntityConsumer;
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
import org.apache.hc.core5.net.WWWFormCodec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@Singleton
@Slf4j
public class PushNotificationService {
private final CloseableHttpClient httpClient;
private final CloseableHttpAsyncClient httpClient;
private final Meter meter;
private final CommaFeedConfiguration config;
@@ -97,13 +104,9 @@ public class PushNotificationService {
return;
}
HttpPost request = new HttpPost(serverUrl + "/" + topic);
request.setConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/" + topic);
request.addHeader("Title", feedTitle);
request.setEntity(new StringEntity(entryTitle, StandardCharsets.UTF_8));
request.setBody(entryTitle, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8));
if (StringUtils.isNotBlank(entryUrl)) {
request.addHeader("Click", entryUrl);
@@ -113,15 +116,7 @@ public class PushNotificationService {
request.addHeader("Authorization", "Bearer " + settings.getUserSecret());
}
httpClient.execute(
request,
response -> {
if (response.getCode() >= 400) {
throw new PushNotificationException(
"ntfy notification failed with status " + response.getCode());
}
return null;
});
send("ntfy", request.build());
}
private void sendGotify(
@@ -152,23 +147,11 @@ public class PushNotificationService {
.put("click", new JsonObject().put("url", entryUrl))));
}
HttpPost request = new HttpPost(serverUrl + "/message");
request.setConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
request.addHeader("X-Gotify-Key", token);
request.setEntity(new StringEntity(json.toString(), ContentType.APPLICATION_JSON));
request.setBody(json.toString(), ContentType.APPLICATION_JSON);
httpClient.execute(
request,
response -> {
if (response.getCode() >= 400) {
throw new PushNotificationException(
"gotify notification failed with status " + response.getCode());
}
return null;
});
send("gotify", request.build());
}
private void sendPushover(
@@ -194,22 +177,40 @@ public class PushNotificationService {
params.add(new BasicNameValuePair("url", entryUrl));
}
HttpPost request = new HttpPost("https://api.pushover.net/1/messages.json");
request.setConfig(
RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
.build());
request.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
SimpleRequestBuilder request =
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
request.setBody(
WWWFormCodec.format(params, StandardCharsets.UTF_8),
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
httpClient.execute(
request,
response -> {
if (response.getCode() >= 400) {
throw new PushNotificationException(
"pushover notification failed with status " + response.getCode());
}
return null;
});
send("pushover", request.build());
}
private void send(String name, SimpleHttpRequest request) throws IOException {
Future<Message<HttpResponse, Object>> future =
httpClient.execute(
SimpleRequestProducer.create(request),
new BasicResponseConsumer<>(new DiscardingEntityConsumer<>()),
null);
try {
HttpResponse response =
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS)
.getHead();
if (response.getCode() >= 400) {
throw new PushNotificationException(
name + " notification failed with status " + response.getCode());
}
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new IOException("interrupted while sending " + name + " notification", e);
} catch (TimeoutException e) {
future.cancel(true);
throw new IOException("timed out while sending " + name + " notification", e);
} catch (ExecutionException e) {
future.cancel(true);
throw new IOException(e.getCause());
}
}
public static class PushNotificationException extends RuntimeException {

View File

@@ -79,13 +79,10 @@ public class DatabaseCleaningService {
} while (entriesDeleted > 0);
}
deleted =
unitOfWork.call(
() ->
feedDAO.delete(
feedDAO.findByIds(
feeds.stream()
.map(AbstractModel::getId)
.toList())));
unitOfWork.call(() ->
feedDAO.delete(
feedDAO.findByIds(
feeds.stream().map(AbstractModel::getId).toList())));
total += deleted;
log.debug("removed {} feeds without subscriptions", total);
} while (deleted != 0);
@@ -119,10 +116,9 @@ public class DatabaseCleaningService {
}
List<FeedCapacity> feeds =
unitOfWork.call(
() ->
feedEntryDAO.findFeedsExceedingCapacity(
maxFeedCapacity, batchSize, keepStarredEntries));
unitOfWork.call(() ->
feedEntryDAO.findFeedsExceedingCapacity(
maxFeedCapacity, batchSize, keepStarredEntries));
if (feeds.isEmpty()) {
break;
}
@@ -139,12 +135,11 @@ public class DatabaseCleaningService {
final long rem = remaining;
deleted =
unitOfWork.call(
() ->
feedEntryDAO.deleteOldEntries(
feed.id(),
Math.min(batchSize, rem),
keepStarredEntries));
unitOfWork.call(() ->
feedEntryDAO.deleteOldEntries(
feed.id(),
Math.min(batchSize, rem),
keepStarredEntries));
entriesDeletedMeter.mark(deleted);
total += deleted;
remaining -= deleted;
@@ -166,10 +161,9 @@ public class DatabaseCleaningService {
}
deleted =
unitOfWork.call(
() ->
feedEntryDAO.deleteEntriesOlderThan(
olderThan, batchSize, keepStarredEntries));
unitOfWork.call(() ->
feedEntryDAO.deleteEntriesOlderThan(
olderThan, batchSize, keepStarredEntries));
entriesDeletedMeter.mark(deleted);
total += deleted;
log.debug("removed {} old entries", total);

View File

@@ -31,16 +31,15 @@ public class DemoAccountCleanupTask extends ScheduledTask {
}
log.info("recreating demo user account");
unitOfWork.run(
() -> {
User demoUser = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
if (demoUser == null) {
return;
}
unitOfWork.run(() -> {
User demoUser = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
if (demoUser == null) {
return;
}
userService.unregister(demoUser);
userService.createDemoUser();
});
userService.unregister(demoUser);
userService.createDemoUser();
});
}
@Override

View File

@@ -435,6 +435,19 @@ public class CategoryREST {
&& !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId()));
}
if (parent != null) {
Long parentId = parent.getId();
List<FeedCategory> descendants =
feedCategoryDAO.findAllChildrenCategories(user, category);
boolean parentIsDescendant =
descendants.stream().anyMatch(c -> c.getId().equals(parentId));
if (parentIsDescendant) {
return Response.status(Status.BAD_REQUEST)
.entity(
"<message>cannot set a category's own descendant as its parent</message>")
.build();
}
}
category.setParent(parent);
if (req.getPosition() != null) {

View File

@@ -140,14 +140,9 @@ public class FeverREST {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
form.getValues()
.forEach(
(k, v) ->
params.put(
k,
v.stream()
.map(FormValue::getValue)
.findFirst()
.orElse(null)));
.forEach((k, v) ->
params.put(
k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
return handle(userId, params);
}
@@ -246,46 +241,42 @@ public class FeverREST {
s -> s.getCategory() == null ? 0 : s.getCategory().getId()))
.entrySet()
.stream()
.map(
e -> {
FeverFeedGroup fg = new FeverFeedGroup();
fg.setGroupId(e.getKey());
fg.setFeedIds(
e.getValue().stream().map(FeedSubscription::getId).toList());
return fg;
})
.map(e -> {
FeverFeedGroup fg = new FeverFeedGroup();
fg.setGroupId(e.getKey());
fg.setFeedIds(e.getValue().stream().map(FeedSubscription::getId).toList());
return fg;
})
.toList();
}
private List<FeverGroup> buildGroups(List<FeedCategory> categories) {
return categories.stream()
.map(
c -> {
FeverGroup g = new FeverGroup();
g.setId(c.getId());
g.setTitle(c.getName());
return g;
})
.map(c -> {
FeverGroup g = new FeverGroup();
g.setId(c.getId());
g.setTitle(c.getName());
return g;
})
.toList();
}
private List<FeverFeed> buildFeeds(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.map(
s -> {
FeverFeed f = new FeverFeed();
f.setId(s.getId());
f.setFaviconId(s.getId());
f.setTitle(s.getTitle());
f.setUrl(s.getFeed().getUrl());
f.setSiteUrl(s.getFeed().getLink());
f.setSpark(false);
f.setLastUpdatedOnTime(
s.getFeed().getLastUpdated() == null
? 0
: s.getFeed().getLastUpdated().getEpochSecond());
return f;
})
.map(s -> {
FeverFeed f = new FeverFeed();
f.setId(s.getId());
f.setFaviconId(s.getId());
f.setTitle(s.getTitle());
f.setUrl(s.getFeed().getUrl());
f.setSiteUrl(s.getFeed().getLink());
f.setSpark(false);
f.setLastUpdatedOnTime(
s.getFeed().getLastUpdated() == null
? 0
: s.getFeed().getLastUpdated().getEpochSecond());
return f;
})
.toList();
}
@@ -367,19 +358,18 @@ public class FeverREST {
private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.map(
s -> {
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
.map(s -> {
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
FeverFavicon f = new FeverFavicon();
f.setId(s.getFeed().getId());
f.setData(
String.format(
"data:%s;base64,%s",
favicon.mediaType(),
Base64.getEncoder().encodeToString(favicon.icon())));
return f;
})
FeverFavicon f = new FeverFavicon();
f.setId(s.getFeed().getId());
f.setData(
String.format(
"data:%s;base64,%s",
favicon.mediaType(),
Base64.getEncoder().encodeToString(favicon.icon())));
return f;
})
.toList();
}

View File

@@ -216,11 +216,10 @@ public class GoogleReaderREST {
List<GoogleReaderModel.Tag> tags = new ArrayList<>();
feedCategoryDAO
.findAll(user)
.forEach(
c ->
tags.add(
new GoogleReaderModel.Tag(
GoogleReaderStreamId.label(c.getName()))));
.forEach(c ->
tags.add(
new GoogleReaderModel.Tag(
GoogleReaderStreamId.label(c.getName()))));
tags.add(new GoogleReaderModel.Tag(GoogleReaderStreamId.STARRED));
GoogleReaderModel.TagList resp = new GoogleReaderModel.TagList();
@@ -259,21 +258,19 @@ public class GoogleReaderREST {
Map<Long, FeedCategory> categoriesById =
feedCategoryDAO.findAll(user).stream()
.collect(Collectors.toMap(FeedCategory::getId, c -> c));
unreadByCategory.forEach(
(categoryId, count) -> {
if (categoryId == 0) {
return;
}
FeedCategory category = categoriesById.get(categoryId);
if (category == null) {
return;
}
GoogleReaderModel.UnreadCountEntry entry =
new GoogleReaderModel.UnreadCountEntry();
entry.setId(GoogleReaderStreamId.label(category.getName()));
entry.setCount(count);
entries.add(entry);
});
unreadByCategory.forEach((categoryId, count) -> {
if (categoryId == 0) {
return;
}
FeedCategory category = categoriesById.get(categoryId);
if (category == null) {
return;
}
GoogleReaderModel.UnreadCountEntry entry = new GoogleReaderModel.UnreadCountEntry();
entry.setId(GoogleReaderStreamId.label(category.getName()));
entry.setCount(count);
entries.add(entry);
});
GoogleReaderModel.UnreadCountEntry readingList = new GoogleReaderModel.UnreadCountEntry();
readingList.setId(GoogleReaderStreamId.READING_LIST);
@@ -334,10 +331,8 @@ public class GoogleReaderREST {
GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs();
resp.setItemRefs(
paged.statuses().stream()
.map(
s ->
new GoogleReaderModel.ItemRef(
String.valueOf(s.getEntry().getId())))
.map(s ->
new GoogleReaderModel.ItemRef(String.valueOf(s.getEntry().getId())))
.toList());
resp.setContinuation(paged.continuation());
return resp;

View File

@@ -38,20 +38,18 @@ public class DatabaseApiKeyIdentityProvider
@Override
public Uni<SecurityIdentity> authenticate(
TokenAuthenticationRequest request, AuthenticationRequestContext context) {
return context.runBlocking(
() -> {
Optional<User> user =
unitOfWork.call(() -> userService.login(request.getToken().getToken()));
if (user.isEmpty()) {
throw new AuthenticationFailedException(
"could not find a user with this api key");
}
return context.runBlocking(() -> {
Optional<User> user =
unitOfWork.call(() -> userService.login(request.getToken().getToken()));
if (user.isEmpty()) {
throw new AuthenticationFailedException("could not find a user with this api key");
}
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
return QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
.build();
});
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
return QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
.build();
});
}
}

View File

@@ -39,25 +39,22 @@ public class DatabaseUsernamePasswordIdentityProvider
@Override
public Uni<SecurityIdentity> authenticate(
UsernamePasswordAuthenticationRequest request, AuthenticationRequestContext context) {
return context.runBlocking(
() -> {
Optional<User> user =
unitOfWork.call(
() ->
userService.login(
request.getUsername(),
new String(
request.getPassword().getPassword())));
if (user.isEmpty()) {
throw new CommaFeedApplicationException(
CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);
}
return context.runBlocking(() -> {
Optional<User> user =
unitOfWork.call(() ->
userService.login(
request.getUsername(),
new String(request.getPassword().getPassword())));
if (user.isEmpty()) {
throw new CommaFeedApplicationException(
CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);
}
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
return QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
.build();
});
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
return QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
.build();
});
}
}

View File

@@ -40,24 +40,23 @@ public class TrustedIdentityProvider implements IdentityProvider<TrustedAuthenti
@Override
public Uni<SecurityIdentity> authenticate(
TrustedAuthenticationRequest request, AuthenticationRequestContext context) {
return context.runBlocking(
() -> {
Long userId = Long.valueOf(request.getPrincipal());
User user = unitOfWork.call(() -> userDAO.findById(userId));
if (user == null) {
throw new AuthenticationFailedException("user not found");
}
return context.runBlocking(() -> {
Long userId = Long.valueOf(request.getPrincipal());
User user = unitOfWork.call(() -> userDAO.findById(userId));
if (user == null) {
throw new AuthenticationFailedException("user not found");
}
// execute post login activities manually because we didn't call login() since
// we received
// a trusted authentication request
unitOfWork.run(() -> postLoginActivities.executeFor(user));
// execute post login activities manually because we didn't call login() since
// we received
// a trusted authentication request
unitOfWork.run(() -> postLoginActivities.executeFor(user));
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user));
return QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(String.valueOf(userId)))
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
.build();
});
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user));
return QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(String.valueOf(userId)))
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
.build();
});
}
}

View File

@@ -41,11 +41,10 @@ class NativeImageClassesTest {
WireFeedGenerator.class)) {
Set<Class<?>> moduleClasses =
new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass()));
moduleClasses.removeIf(
c ->
c.isInterface()
|| Modifier.isAbstract(c.getModifiers())
|| !Modifier.isPublic(c.getModifiers()));
moduleClasses.removeIf(c ->
c.isInterface()
|| Modifier.isAbstract(c.getModifiers())
|| !Modifier.isPublic(c.getModifiers()));
moduleClasses.removeAll(classesInAnnotation);
missingClasses.addAll(moduleClasses);
}

View File

@@ -11,6 +11,8 @@ import com.google.common.net.HttpHeaders;
import io.quarkus.runtime.configuration.MemorySize;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.hc.client5.http.ConnectTimeoutException;
import org.apache.hc.core5.http.HttpStatus;
@@ -29,12 +31,12 @@ import org.mockserver.model.Delay;
import org.mockserver.model.HttpRequest;
import org.mockserver.model.HttpResponse;
import org.mockserver.model.MediaType;
import org.mockserver.model.Protocol;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.time.Duration;
import java.time.Instant;
@@ -45,6 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.GZIPOutputStream;
@Slf4j
class HttpGetterTest {
private static final Instant NOW = Instant.now();
@@ -237,7 +240,7 @@ class HttpGetterTest {
.when(HttpRequest.request().withMethod("GET"))
.respond(HttpResponse.response().withDelay(Delay.milliseconds(1000)));
Assertions.assertThrows(SocketTimeoutException.class, () -> getter.get(this.feedUrl));
Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
}
@Test
@@ -314,18 +317,17 @@ class HttpGetterTest {
this.mockServerClient
.when(HttpRequest.request().withMethod("GET"))
.respond(
req -> {
calls.incrementAndGet();
.respond(req -> {
calls.incrementAndGet();
if (req.containsHeader(HttpHeaders.COOKIE)) {
throw new Exception("cookie should not be sent by the client");
}
if (req.containsHeader(HttpHeaders.COOKIE)) {
throw new Exception("cookie should not be sent by the client");
}
return HttpResponse.response()
.withBody("ok")
.withHeader(HttpHeaders.SET_COOKIE, "foo=bar");
});
return HttpResponse.response()
.withBody("ok")
.withHeader(HttpHeaders.SET_COOKIE, "foo=bar");
});
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl));
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl + "?foo=bar"));
@@ -338,11 +340,10 @@ class HttpGetterTest {
this.mockServerClient
.when(HttpRequest.request().withMethod("GET"))
.respond(
req -> {
calls.incrementAndGet();
return HttpResponse.response().withBody("ok");
});
.respond(req -> {
calls.incrementAndGet();
return HttpResponse.response().withBody("ok");
});
HttpResult result = getter.get(this.feedUrl);
Assertions.assertEquals(result, getter.get(this.feedUrl));
@@ -397,17 +398,15 @@ class HttpGetterTest {
this.mockServerClient
.when(HttpRequest.request().withMethod("GET"))
.respond(
req -> {
calls.incrementAndGet();
.respond(req -> {
calls.incrementAndGet();
if (req.containsHeader(HttpHeaders.UPGRADE)) {
throw new Exception(
"upgrade header should not be sent by the client");
}
if (req.containsHeader(HttpHeaders.UPGRADE)) {
throw new Exception("upgrade header should not be sent by the client");
}
return HttpResponse.response().withBody("ok");
});
return HttpResponse.response().withBody("ok");
});
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl));
Assertions.assertEquals(1, calls.get());
@@ -416,7 +415,7 @@ class HttpGetterTest {
@Nested
class Compression {
private static final String ACCEPT_ENCODING = "gzip, deflate, br";
private static final String ACCEPT_ENCODING = "deflate, gzip";
@Test
void gzip() throws Exception {
@@ -435,27 +434,30 @@ class HttpGetterTest {
HttpGetterTest.this
.mockServerClient
.when(HttpRequest.request().withMethod("GET"))
.respond(
req -> {
String acceptEncodingHeader =
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
throw new Exception(
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
.formatted(
ACCEPT_ENCODING, acceptEncodingHeader));
}
.respond(req -> {
try {
String acceptEncodingHeader =
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
throw new Exception(
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
.formatted(ACCEPT_ENCODING, acceptEncodingHeader));
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (OutputStream compressionOutputStream =
compressionFunction.apply(output)) {
compressionOutputStream.write(body.getBytes());
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (OutputStream compressionOutputStream =
compressionFunction.apply(output)) {
compressionOutputStream.write(body.getBytes());
}
return HttpResponse.response()
.withBody(output.toByteArray())
.withHeader(HttpHeaders.CONTENT_ENCODING, encoding);
});
return HttpResponse.response()
.withBody(output.toByteArray())
.withHeader(HttpHeaders.CONTENT_ENCODING, encoding);
} catch (Exception e) {
log.error("Error in compression callback", e);
throw e;
}
});
HttpResult result = getter.get(HttpGetterTest.this.feedUrl);
Assertions.assertEquals(body, new String(result.content()));
@@ -590,4 +592,30 @@ class HttpGetterTest {
UnknownHostException.class, () -> getter.get("http://[::a00:1]")); // 10.0.0.1
}
}
@Nested
class Protocols {
@Test
void usesHttp2OverTls() throws Exception {
mockServerClient
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_2))
.respond(HttpResponse.response().withBody("h2"));
mockServerClient
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
.respond(HttpResponse.response().withBody("h1"));
HttpResult result = getter.get("https://localhost:" + mockServerClient.getPort());
Assertions.assertEquals("h2", new String(result.content()));
}
@Test
void fallsBackToHttp1OverCleartext() throws Exception {
mockServerClient
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
.respond(HttpResponse.response().withBody("h1"));
HttpResult result = getter.get("http://localhost:" + mockServerClient.getPort());
Assertions.assertEquals("h1", new String(result.content()));
}
}
}

View File

@@ -68,7 +68,8 @@ class YoutubeFaviconFetcherTest {
byte[] apiResponse =
"""
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes();
HttpResult apiHttpResult =
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
@@ -102,7 +103,8 @@ class YoutubeFaviconFetcherTest {
byte[] apiResponse =
"""
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes();
HttpResult apiHttpResult =
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
@@ -136,7 +138,8 @@ class YoutubeFaviconFetcherTest {
byte[] playlistResponse =
"""
{"items":[{"snippet":{"channelId":"testChannelId"}}]}"""
{"items":[{"snippet":{"channelId":"testChannelId"}}]}\
"""
.getBytes();
HttpResult playlistHttpResult =
new HttpResult(
@@ -151,7 +154,8 @@ class YoutubeFaviconFetcherTest {
byte[] channelResponse =
"""
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes();
HttpResult channelHttpResult =
new HttpResult(

View File

@@ -226,14 +226,16 @@ class XMLCleanerTest {
void testRemoveMultilineDoctype() {
String source =
"""
<!DOCTYPE
html
>
<html><head></head><body></body></html>""";
<!DOCTYPE
html
>
<html><head></head><body></body></html>\
""";
Assertions.assertEquals(
"""
<html><head></head><body></body></html>""",
<html><head></head><body></body></html>\
""",
xmlCleaner.removeDoctypeDeclarations(source));
}

View File

@@ -12,23 +12,23 @@ class FeedEntryContentCleaningServiceTest {
void testClean() {
String content =
"""
<p>
Some text
<img width="965" height="320" src="https://localhost/an-image.png" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="alt-desc" decoding="async" sizes="(max-width: 965px) 100vw, 965px" style="width: 100%; opacity: 0">
<iframe src="url" style="width: 100%; opacity: 0"></iframe>
<forbidden-element>aaa</forbidden-element>
""";
<p>
Some text
<img width="965" height="320" src="https://localhost/an-image.png" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="alt-desc" decoding="async" sizes="(max-width: 965px) 100vw, 965px" style="width: 100%; opacity: 0">
<iframe src="https://localhost/an-iframe" style="width: 100%; opacity: 0"></iframe>
<forbidden-element>aaa</forbidden-element>
""";
String result = feedEntryContentCleaningService.clean(content, "baseUri", false);
Assertions.assertLinesMatch(
"""
<p>
Some text
<img width="965" height="320" src="https://localhost/an-image.png" alt="alt-desc" style="width:100%;">
<iframe src="url" style="width:100%;"></iframe>
aaa
</p>
"""
<p>
Some text
<img width="965" height="320" src="https://localhost/an-image.png" alt="alt-desc" style="width:100%;">
<iframe src="https://localhost/an-iframe" style="width:100%;"></iframe>
aaa
</p>
"""
.lines(),
result.lines());
}

View File

@@ -98,19 +98,19 @@ class PushNotificationServiceTest {
.withBody(
JsonBody.json(
"""
{
"title": "Test Feed",
"message": "Test Entry",
"priority": 5,
"extras": {
"client::notification": {
"click": {
"url": "http://example.com/entry"
}
}
}
}
""")))
{
"title": "Test Feed",
"message": "Test Entry",
"priority": 5,
"extras": {
"client::notification": {
"click": {
"url": "http://example.com/entry"
}
}
}
}
""")))
.respond(HttpResponse.response().withStatusCode(200));
Assertions.assertDoesNotThrow(

View File

@@ -14,14 +14,15 @@ class InPageReferenceFeedURLProviderTest {
String url = "http://example.com";
String html =
"""
<html>
<head>
<link type="application/atom+xml" href="/feed.atom">
<link type="application/rss+xml" href="/feed.rss">
</head>
<body>
</body>
</html>""";
<html>
<head>
<link type="application/atom+xml" href="/feed.atom">
<link type="application/rss+xml" href="/feed.rss">
</head>
<body>
</body>
</html>\
""";
Assertions.assertIterableEquals(
List.of("http://example.com/feed.atom", "http://example.com/feed.rss"),
@@ -33,9 +34,10 @@ class InPageReferenceFeedURLProviderTest {
String url = "http://example.com";
String html =
"""
<?xml version="1.0"?>
<feed></feed>
</xml>""";
<?xml version="1.0"?>
<feed></feed>
</xml>\
""";
Assertions.assertTrue(provider.get(url, html).isEmpty());
}
@@ -45,13 +47,14 @@ class InPageReferenceFeedURLProviderTest {
String url = "http://example.com";
String html =
"""
<html>
<head>
<link type="text/css" href="/style.css">
</head>
<body>
</body>
</html>""";
<html>
<head>
<link type="text/css" href="/style.css">
</head>
<body>
</body>
</html>\
""";
Assertions.assertTrue(provider.get(url, html).isEmpty());
}

View File

@@ -170,10 +170,9 @@ class GoogleReaderIT extends BaseIT {
client.get(BASE + "/reader/api/0/unread-count").as(UnreadCounts.class);
Assertions.assertTrue(
counts.getUnreadCounts().stream()
.anyMatch(
c ->
c.getId().equals("user/-/state/com.google/reading-list")
&& c.getCount() == 2));
.anyMatch(c ->
c.getId().equals("user/-/state/com.google/reading-list")
&& c.getCount() == 2));
}
@Test

View File

@@ -5,7 +5,7 @@
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>7.3.2</version>
<version>7.4.1</version>
<name>CommaFeed</name>
<packaging>pom</packaging>
@@ -19,7 +19,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<version>3.16.0</version>
<configuration>
<parameters>true</parameters>