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

View File

@@ -1,3 +1,3 @@
wrapperVersion=3.3.4 wrapperVersion=3.3.4
distributionType=only-script 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 # 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] ## [7.3.2]
- Prevent users from starring/tagging entries that are not theirs and subsequently reading them (GHSA-prfv-88mm-5gpg) - 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`. 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. 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 ## Translation
Files for internationalization are 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": { "formatter": {
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 4, "indentWidth": 4,

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -116,6 +116,30 @@ describe("entries", () => {
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true }) 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", () => { it("marks all entries as read", () => {
const store = configureStore({ const store = configureStore({
reducer: reducers, reducer: reducers,

View File

@@ -10,7 +10,11 @@ export interface EntrySource {
id: string 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 { interface EntriesState {
/** selected source */ /** selected source */
@@ -71,11 +75,13 @@ export const entriesSlice = createSlice({
builder.addCase(markEntry.pending, (state, action) => { builder.addCase(markEntry.pending, (state, action) => {
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) { for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
e.read = action.meta.arg.read e.read = action.meta.arg.read
if (!action.meta.arg.read) e.keepUnread = true
} }
}) })
builder.addCase(markMultipleEntries.pending, (state, action) => { builder.addCase(markMultipleEntries.pending, (state, action) => {
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) { for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
e.read = action.meta.arg.read e.read = action.meta.arg.read
if (!action.meta.arg.read) e.keepUnread = true
} }
}) })
builder.addCase(markAllEntries.pending, (state, action) => { builder.addCase(markAllEntries.pending, (state, action) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,9 @@ export function CategoryDetailsPage() {
category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id) 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 form = useForm<CategoryModificationRequest>()
const { setValues } = form const { setValues } = form
@@ -125,7 +128,7 @@ export function CategoryDetailsPage() {
label={<Trans>Parent Category</Trans>} label={<Trans>Parent Category</Trans>}
{...form.getInputProps("parentId")} {...form.getInputProps("parentId")}
clearable clearable
withoutCategoryIds={[id]} withoutCategoryIds={excludedCategoryIds}
/> />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} /> <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 { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
import { useViewportSize } from "@mantine/hooks" import { useViewportSize } from "@mantine/hooks"
import { useEffect } from "react" import { useEffect } from "react"
import { TbEdit } from "react-icons/tb" import { TbSettings } from "react-icons/tb"
import { useLocation, useParams } from "react-router-dom" import { useLocation, useParams } from "react-router-dom"
import { Constants } from "@/app/constants" import { Constants } from "@/app/constants"
import type { EntrySourceType } from "@/app/entries/slice" import type { EntrySourceType } from "@/app/entries/slice"
@@ -138,7 +138,7 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
</Title> </Title>
)} )}
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}> <ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
<TbEdit size={18} /> <TbSettings size={18} />
</ActionIcon> </ActionIcon>
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />} {showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
</Group> </Group>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import com.commafeed.CommaFeedConfiguration.HttpClientCache;
import com.google.common.cache.Cache; import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders; import com.google.common.net.HttpHeaders;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
@@ -17,39 +16,53 @@ import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Lombok; import lombok.Lombok;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.experimental.Delegate;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils; 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.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.HttpClientContext;
import org.apache.hc.client5.http.protocol.RedirectLocations; import org.apache.hc.client5.http.protocol.RedirectLocations;
import org.apache.hc.client5.http.utils.DateUtils; import org.apache.hc.client5.http.utils.DateUtils;
import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.http.HttpEntity; 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.HttpStatus;
import org.apache.hc.core5.http.Message;
import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
import org.apache.hc.core5.util.Timeout; 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 org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.nio.ByteBuffer;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.InstantSource; import java.time.InstantSource;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutionException; 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 @Singleton
@Slf4j @Slf4j
public class HttpGetter { public class HttpGetter {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final InstantSource instantSource; private final InstantSource instantSource;
private final CloseableHttpClient client; private final CloseableHttpAsyncClient client;
private final Cache<HttpRequest, HttpResponse> cache; private final Cache<HttpRequest, HttpResponse> cache;
public HttpGetter( public HttpGetter(
@@ -150,70 +163,96 @@ public class HttpGetter {
HttpClientContext context = HttpClientContext.create(); HttpClientContext context = HttpClientContext.create();
context.setRequestConfig( context.setRequestConfig(
RequestConfig.custom() RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
// causes issues with some feeds // causes issues with some feeds
// see https://github.com/Athou/commafeed/issues/1572 // see https://github.com/Athou/commafeed/issues/1572
// and https://issues.apache.org/jira/browse/HTTPCLIENT-2344 // and https://issues.apache.org/jira/browse/HTTPCLIENT-2344
.setProtocolUpgradeEnabled(false) .setProtocolUpgradeEnabled(false)
.build()); .build());
return client.execute( long maxResponseSize = config.httpClient().maxResponseSize().asLongValue();
request.toClassicHttpRequest(), AsyncRequestProducer req = SimpleRequestProducer.create(request.toSimpleHttpRequest());
context, AsyncResponseConsumer<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> resp =
resp -> { new BasicResponseConsumer<>(
byte[] content = new LimitedContentLengthEntityConsumer(
resp.getEntity() == null new LimitedBytesEntityConsumer(maxResponseSize), maxResponseSize));
? null FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
: toByteArray( new FutureCallback<>() {
resp.getEntity(), @Override
config.httpClient().maxResponseSize().asLongValue()); public void completed(
int code = resp.getCode(); Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
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);
CacheControl cacheControl = @Override
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL)) public void failed(Exception ex) {}
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.map(HttpGetter::toCacheControl)
.orElse(null);
Instant retryAfter = @Override
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER)) public void cancelled() {}
.map(NameValuePair::getValue) };
.map(StringUtils::trimToNull) Future<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> future =
.map(this::toInstant) client.execute(req, resp, context, callback);
.orElse(null);
String contentType = try {
Optional.ofNullable(resp.getEntity()) Message<org.apache.hc.core5.http.HttpResponse, byte[]> message =
.map(HttpEntity::getContentType) future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS);
.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( byte[] content = message.getBody();
code, int code = message.getHead().getCode();
lastModifiedHeader, String lastModifiedHeader =
eTagHeader, Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.LAST_MODIFIED))
cacheControl, .map(NameValuePair::getValue)
retryAfter, .map(StringUtils::trimToNull)
content, .orElse(null);
contentType, String eTagHeader =
urlAfterRedirect); 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) { private static CacheControl toCacheControl(String headerValue) {
@@ -237,28 +276,6 @@ public class HttpGetter {
return DateUtils.parseStandardDate(headerValue); 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) { private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
HttpClientCache cacheConfig = config.httpClient().cache(); HttpClientCache cacheConfig = config.httpClient().cache();
if (!cacheConfig.enabled()) { if (!cacheConfig.enabled()) {
@@ -266,14 +283,67 @@ public class HttpGetter {
} }
return CacheBuilder.newBuilder() return CacheBuilder.newBuilder()
.weigher( .weigher((HttpRequest key, HttpResponse value) ->
(HttpRequest key, HttpResponse value) -> value.content() != null ? value.content().length : 0)
value.content() != null ? value.content().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue()) .maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration()) .expireAfterWrite(cacheConfig.expiration())
.build(); .build();
} }
private record LimitedContentLengthEntityConsumer(
@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 { public static class SchemeNotAllowedException extends Exception {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@@ -336,15 +406,15 @@ public class HttpGetter {
return new HttpRequestBuilder().url(url); return new HttpRequestBuilder().url(url);
} }
public ClassicHttpRequest toClassicHttpRequest() { public SimpleHttpRequest toSimpleHttpRequest() {
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build(); SimpleRequestBuilder builder = SimpleRequestBuilder.get(url);
if (lastModified != null) { if (lastModified != null) {
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified); builder.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
} }
if (eTag != null) { 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; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.QFeedCategory; import com.commafeed.backend.model.QFeedCategory;
import com.commafeed.backend.model.QUser; import com.commafeed.backend.model.QUser;
@@ -17,8 +18,8 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory; private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
public FeedCategoryDAO(EntityManager entityManager) { public FeedCategoryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedCategory.class); super(entityManager, FeedCategory.class, config);
} }
public List<FeedCategory> findAll(User user) { public List<FeedCategory> findAll(User user) {

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.QFeedEntry; 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 QFeedEntry ENTRY = QFeedEntry.feedEntry;
private static final int IN_CLAUSE_BATCH_SIZE = 1000; private static final int IN_CLAUSE_BATCH_SIZE = 1000;
public FeedEntryDAO(EntityManager entityManager) { public FeedEntryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntry.class); super(entityManager, FeedEntry.class, config);
} }
public FeedEntry findExisting(String guidHash, Feed feed) { 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.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -50,7 +51,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
EntityManager entityManager, EntityManager entityManager,
FeedEntryTagDAO feedEntryTagDAO, FeedEntryTagDAO feedEntryTagDAO,
CommaFeedConfiguration config) { CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class); super(entityManager, FeedEntryStatus.class, config);
this.feedEntryTagDAO = feedEntryTagDAO; this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config; this.config = config;
} }
@@ -129,8 +130,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit); query.limit(limit);
} }
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = query.fetch(); List<FeedEntryStatus> statuses = query.fetch();
statuses.forEach(s -> s.setMarkable(true)); statuses.forEach(s -> s.setMarkable(true));
if (includeContent) { if (includeContent) {
@@ -207,8 +206,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit); query.limit(limit);
} }
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = new ArrayList<>(); List<FeedEntryStatus> statuses = new ArrayList<>();
List<Tuple> tuples = query.fetch(); List<Tuple> tuples = query.fetch();
for (Tuple tuple : tuples) { 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 = JPAQuery<Tuple> query =
query().select(ENTRY.count(), ENTRY.published.max()) query().select(ENTRY.feed.id, ENTRY.count(), ENTRY.published.max())
.from(ENTRY) .from(ENTRY)
.leftJoin(ENTRY.statuses, STATUS) .leftJoin(ENTRY.statuses, STATUS)
.on(STATUS.subscription.eq(sub)) .on(STATUS.subscription.in(subs))
.where(ENTRY.feed.eq(sub.getFeed())) .where(ENTRY.feed.id.in(subsByFeedId.keySet()))
.where(buildUnreadPredicate()); .where(buildUnreadPredicate())
.groupBy(ENTRY.feed.id);
Tuple tuple = query.fetchOne(); Map<Long, UnreadCount> result = new HashMap<>();
Long count = tuple.get(ENTRY.count()); for (Tuple tuple : query.fetch()) {
Instant published = tuple.get(ENTRY.published.max()); Long feedId = tuple.get(ENTRY.feed.id);
return new UnreadCount(sub.getId(), count == null ? 0 : count, published); 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() { private BooleanBuilder buildUnreadPredicate() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,20 +65,17 @@ public class OPMLExporter {
for (FeedCategory child : for (FeedCategory child :
categories.stream() categories.stream()
.filter( .filter(c ->
c -> c.getParent() != null && c.getParent().getId().equals(cat.getId()))
c.getParent() != null
&& c.getParent().getId().equals(cat.getId()))
.toList()) { .toList()) {
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions)); outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
} }
for (FeedSubscription sub : for (FeedSubscription sub :
subscriptions.stream() subscriptions.stream()
.filter( .filter(s ->
s -> s.getCategory() != null
s.getCategory() != null && s.getCategory().getId().equals(cat.getId()))
&& s.getCategory().getId().equals(cat.getId()))
.toList()) { .toList()) {
outline.getChildren().add(buildSubscriptionOutline(sub)); 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("a", "href", "ftp", "http", "https", "magnet", "mailto");
whitelist.addProtocols("blockquote", "cite", "http", "https"); whitelist.addProtocols("blockquote", "cite", "http", "https");
whitelist.addProtocols("iframe", "src", "http", "https");
whitelist.addProtocols("img", "src", "http", "https"); whitelist.addProtocols("img", "src", "http", "https");
whitelist.addProtocols("q", "cite", "http", "https"); whitelist.addProtocols("q", "cite", "http", "https");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -435,6 +435,19 @@ public class CategoryREST {
&& !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) { && !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId())); 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); category.setParent(parent);
if (req.getPosition() != null) { if (req.getPosition() != null) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,23 +12,23 @@ class FeedEntryContentCleaningServiceTest {
void testClean() { void testClean() {
String content = String content =
""" """
<p> <p>
Some text 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"> <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> <iframe src="https://localhost/an-iframe" style="width: 100%; opacity: 0"></iframe>
<forbidden-element>aaa</forbidden-element> <forbidden-element>aaa</forbidden-element>
"""; """;
String result = feedEntryContentCleaningService.clean(content, "baseUri", false); String result = feedEntryContentCleaningService.clean(content, "baseUri", false);
Assertions.assertLinesMatch( Assertions.assertLinesMatch(
""" """
<p> <p>
Some text Some text
<img width="965" height="320" src="https://localhost/an-image.png" alt="alt-desc" style="width:100%;"> <img width="965" height="320" src="https://localhost/an-image.png" alt="alt-desc" style="width:100%;">
<iframe src="url" style="width:100%;"></iframe> <iframe src="https://localhost/an-iframe" style="width:100%;"></iframe>
aaa aaa
</p> </p>
""" """
.lines(), .lines(),
result.lines()); result.lines());
} }

View File

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

View File

@@ -14,14 +14,15 @@ class InPageReferenceFeedURLProviderTest {
String url = "http://example.com"; String url = "http://example.com";
String html = String html =
""" """
<html> <html>
<head> <head>
<link type="application/atom+xml" href="/feed.atom"> <link type="application/atom+xml" href="/feed.atom">
<link type="application/rss+xml" href="/feed.rss"> <link type="application/rss+xml" href="/feed.rss">
</head> </head>
<body> <body>
</body> </body>
</html>"""; </html>\
""";
Assertions.assertIterableEquals( Assertions.assertIterableEquals(
List.of("http://example.com/feed.atom", "http://example.com/feed.rss"), List.of("http://example.com/feed.atom", "http://example.com/feed.rss"),
@@ -33,9 +34,10 @@ class InPageReferenceFeedURLProviderTest {
String url = "http://example.com"; String url = "http://example.com";
String html = String html =
""" """
<?xml version="1.0"?> <?xml version="1.0"?>
<feed></feed> <feed></feed>
</xml>"""; </xml>\
""";
Assertions.assertTrue(provider.get(url, html).isEmpty()); Assertions.assertTrue(provider.get(url, html).isEmpty());
} }
@@ -45,13 +47,14 @@ class InPageReferenceFeedURLProviderTest {
String url = "http://example.com"; String url = "http://example.com";
String html = String html =
""" """
<html> <html>
<head> <head>
<link type="text/css" href="/style.css"> <link type="text/css" href="/style.css">
</head> </head>
<body> <body>
</body> </body>
</html>"""; </html>\
""";
Assertions.assertTrue(provider.get(url, html).isEmpty()); 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); client.get(BASE + "/reader/api/0/unread-count").as(UnreadCounts.class);
Assertions.assertTrue( Assertions.assertTrue(
counts.getUnreadCounts().stream() counts.getUnreadCounts().stream()
.anyMatch( .anyMatch(c ->
c -> c.getId().equals("user/-/state/com.google/reading-list")
c.getId().equals("user/-/state/com.google/reading-list") && c.getCount() == 2));
&& c.getCount() == 2));
} }
@Test @Test

View File

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