Compare commits

..

293 Commits

Author SHA1 Message Date
Athou
ae707a2602 fix quadratic time when removing doctype declarations 2026-11-03 16:37:01 +01:00
Athou
69cf674a02 use 120 max line length for both server and client code (was 100 and 140) 2026-11-03 06:48:15 +01:00
Jérémie Panzer
29987a460d Merge pull request #2362 from Athou/renovate/org.jsoup-jsoup-1.x
Update dependency org.jsoup:jsoup to v1.24.1
2026-11-03 06:42:50 +01:00
renovate[bot]
fc8cd9c0a2 Update dependency org.jsoup:jsoup to v1.24.1 2026-11-03 04:47:00 +00:00
Jérémie Panzer
44c2893fff Merge pull request #2361 from Athou/renovate/com.diffplug.spotless-spotless-maven-plugin-3.x
Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.11.0
2026-11-03 02:35:16 +01:00
renovate[bot]
2e66fd4702 Update dependency com.diffplug.spotless:spotless-maven-plugin to v3.11.0 2026-11-03 00:12:34 +00:00
Athou
01ac8d6ebd block NAT64 local-use prefix in SSRF protection 2026-11-02 13:09:31 +01:00
Athou
ddff408afe escape username in password recovery email 2026-11-02 12:34:14 +01:00
Athou
0c280c4dc4 invalidate existing sessions on password change 2026-11-02 12:17:45 +01:00
renovate[bot]
ca4642f7c9 Lock file maintenance 2026-11-02 00:55:45 +00:00
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
Athou
689c20d367 release 7.3.2 2026-08-22 08:23:13 +02:00
Athou
43588df2b4 prevent users from starring/tagging entries that are not theirs 2026-08-22 08:14:21 +02:00
renovate[bot]
d2aaddea33 Update dependency vitest to v4.1.11 2026-08-21 18:52:50 +00:00
Jérémie Panzer
de233f8ae5 Merge pull request #2280 from Athou/renovate/patch-testing-library-monorepo
Update dependency @testing-library/user-event to v14.6.5
2026-08-21 15:06:31 +02:00
renovate[bot]
3179e74dc7 Update dependency @testing-library/user-event to v14.6.5 2026-08-21 04:47:20 +00:00
renovate[bot]
6f5e9f5d49 Update dependency @biomejs/biome to v2.5.9 2026-08-21 00:00:07 +00:00
Jérémie Panzer
8a43fdf200 Merge pull request #2279 from Athou/renovate/protobuf-monorepo
Update dependency com.google.protobuf:protobuf-java to v4.36.0
2026-08-21 01:59:09 +02:00
renovate[bot]
69051e5bbb Update dependency com.google.protobuf:protobuf-java to v4.36.0 2026-08-20 18:54:00 +00:00
renovate[bot]
c648e7e0ec Update dependency dayjs to v1.11.23 2026-08-20 15:32:41 +00:00
renovate[bot]
c91cf376ad Update ibm-semeru-runtimes:open-jdk-25.0.4.0-jre Docker digest to 524f0d0 2026-08-20 09:23:52 +00:00
Jérémie Panzer
f4e07597d0 Merge pull request #2278 from canoine/patch-11
Update fr/messages.po
2026-08-20 11:22:57 +02:00
CNE
64b64d3340 Update fr/messages.po
3 new fields
2026-08-20 10:47:45 +02:00
Jérémie Panzer
57ea87871e Merge pull request #2276 from Athou/renovate/querydsl.version
Update querydsl.version to v7.6
2026-08-20 09:06:03 +02:00
renovate[bot]
6154d80bb9 Update querydsl.version to v7.6 2026-08-20 06:17:16 +00:00
renovate[bot]
ad85745f85 Update quarkus.version to v3.38.3 2026-08-20 00:35:52 +00:00
renovate[bot]
41fc77322c Update dependency dayjs to v1.11.22 2026-08-19 20:44:22 +00:00
Jérémie Panzer
1b83c26417 Merge pull request #2274 from Athou/renovate/docker-setup-buildx-action-4.x
Update docker/setup-buildx-action action to v4.3.0
2026-08-19 22:43:38 +02:00
Athou
10ea5f0b93 move exception to its own package 2026-08-19 22:32:37 +02:00
Jérémie Panzer
032684de68 Merge pull request #2272 from n200534/fix/issue-2016-translate-backend-errors
Translate login authentication errors
2026-08-19 22:31:29 +02:00
n200534
3efec5ce58 Use typed backend errors for translations 2026-08-20 01:11:30 +05:30
renovate[bot]
4ed32964d9 Update docker/setup-buildx-action action to v4.3.0 2026-08-19 16:16:18 +00:00
renovate[bot]
05c1d0cc6e Update ibm-semeru-runtimes:open-jdk-25.0.4.0-jre Docker digest to 27d8480 2026-08-18 22:09:48 +00:00
Jérémie Panzer
3c54924971 Merge pull request #2273 from Athou/renovate/com.puppycrawl.tools-checkstyle-14.x
Update dependency com.puppycrawl.tools:checkstyle to v14
2026-08-18 21:48:54 +02:00
renovate[bot]
e57dfc0e3f Update dependency com.puppycrawl.tools:checkstyle to v14 2026-08-18 17:49:50 +00:00
n200534
eff8bb16a0 Translate login authentication errors 2026-08-18 15:33:42 +05:30
Jérémie Panzer
87e681b71e Merge pull request #2271 from Athou/renovate/dev.cel-cel-0.x
Update dependency dev.cel:cel to v0.14.0
2026-08-18 07:13:13 +02:00
renovate[bot]
ac73ef4ea7 Update dependency dev.cel:cel to v0.14.0 2026-08-18 02:44:30 +00:00
Jérémie Panzer
20945e7443 Merge pull request #2270 from Athou/renovate/react-querybuilder
Update react-querybuilder to v8.23.0
2026-08-17 23:16:27 +02:00
Jérémie Panzer
b7b1278111 Merge pull request #2269 from Athou/renovate/org.mock-server-mockserver-junit-jupiter-7.x
Update dependency org.mock-server:mockserver-junit-jupiter to v7.6.0
2026-08-17 23:14:40 +02:00
renovate[bot]
6f9378404f Update react-querybuilder to v8.23.0 2026-08-17 20:36:06 +00:00
renovate[bot]
79787aca6e Update dependency org.mock-server:mockserver-junit-jupiter to v7.6.0 2026-08-17 20:35:51 +00:00
220 changed files with 3833 additions and 3804 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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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,23 +1,44 @@
# 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]
- Prevent users from starring/tagging entries that are not theirs and subsequently reading them (GHSA-prfv-88mm-5gpg)
## [7.3.1] ## [7.3.1]
- Prevent Host header injection attacks on the password recovery endpoint. A new setting - Prevent Host header injection attacks on the password recovery endpoint. A new setting
`commafeed.password-recovery-public-base-url` has been added to specify the base URL to use in password recovery `commafeed.password-recovery-public-base-url` has been added to specify the base URL to use in password recovery
(GHSA-hp8h-jqfm-v7x5)
emails. This setting is only required when the password recovery feature is enabled, which is not the default. emails. This setting is only required when the password recovery feature is enabled, which is not the default.
## [7.3.0] ## [7.3.0]
- Add support for the Google Reader API for native mobile apps that don't support the Fever API - Add support for the Google Reader API for native mobile apps that don't support the Fever API
- The profile menu now works again on mobile when action buttons are at the bottom of the screen - The profile menu now works again on mobile when action buttons are at the bottom of the screen
- More SSRF protection regarding IPv6 - More SSRF protection regarding IPv6 (GHSA-q5qw-345w-55xg)
- Changed the default value of `blockLocalAddresses` to make CommaFeed more secure out-of-the-box. See the "Access to - Changed the default value of `blockLocalAddresses` to make CommaFeed more secure out-of-the-box. See the "Access to
local address blocked" section of the README if you subscribe to feeds that are only available on your local network. local address blocked" section of the README if you subscribe to feeds that are only available on your local network.
## [7.2.1] ## [7.2.1]
- correctly handle favicons hosted at the root of the domain for feeds with non-default ports - correctly handle favicons hosted at the root of the domain for feeds with non-default ports
- prevent XXS injections in third party apps. The CommaFeed React client was already stripping malicious "javascript:" URLs, they are now filtered during feed parsing and no longer stored in the database/served through the REST API. - prevent XXS injections in third party apps. The CommaFeed React client was already stripping malicious "javascript:"
URLs, they are now filtered during feed parsing and no longer stored in the database/served through the REST API
(GHSA-44pq-9929-f8mq)
## [7.2.0] ## [7.2.0]
@@ -27,26 +48,36 @@
- Small performance improvements to the feed refresh engine - Small performance improvements to the feed refresh engine
- Removed the Pocket sharing feature because the Pocket service has been discontinued - Removed the Pocket sharing feature because the Pocket service has been discontinued
- Prevent the image proxy feature to access any URL to avoid SSRF attacks - Prevent the image proxy feature to access any URL to avoid SSRF attacks
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges - Strengthened the SSRF protection by also blocking ULA and CGNAT ranges (GHSA-hgrr-mjfp-gmr6)
- Prevent DDOS attacks by filtering OPML files during imports - Prevent DDOS attacks by filtering OPML files during imports (GHSA-83jp-rww3-57rr)
## [7.1.0] ## [7.1.0]
- Display the unread count in the header on mobile, since the tree is hidden by default and the unread count is not visible otherwise (#2055) - Display the unread count in the header on mobile, since the tree is hidden by default and the unread count is not
visible otherwise (#2055)
- The feed refresh engine is now a little bit more performant (#2089) - The feed refresh engine is now a little bit more performant (#2089)
- On shutdown, give the feed refresh engine some time to finish refreshing feeds before killing it. This is controlled by the new `commafeed.shutdown-timeout` setting, which defaults to 2 seconds - On shutdown, give the feed refresh engine some time to finish refreshing feeds before killing it. This is controlled
by the new `commafeed.shutdown-timeout` setting, which defaults to 2 seconds
## [7.0.0] ## [7.0.0]
- Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by default. - Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are discovered (#1610) builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by
default.
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are
discovered (#1610)
- Added a per-feed setting for marking entries as read after a number of days (#2041) - Added a per-feed setting for marking entries as read after a number of days (#2041)
- The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled. - The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites redirecting to local ones. only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted
users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled.
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites
redirecting to local ones.
## [6.2.0] ## [6.2.0]
- Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new `commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want to keep deleting starred entries during normal entries cleanup (#1581) - Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new
`commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want
to keep deleting starred entries during normal entries cleanup (#1581)
## [6.1.1] ## [6.1.1]
@@ -54,16 +85,21 @@
## [6.1.0] ## [6.1.0]
- When clicking on the password reset link, a random password is no longer generated automatically. The user is now redirected to a page where they can set their own password (#2023) - When clicking on the password reset link, a random password is no longer generated automatically. The user is now
redirected to a page where they can set their own password (#2023)
- Use browser preferred language instead of English when using CommaFeed for the first time (#2018) - Use browser preferred language instead of English when using CommaFeed for the first time (#2018)
- The profile menu is now closed when scrolling the page (#2019) - The profile menu is now closed when scrolling the page (#2019)
- The "disable pull to refresh" feature is now disabled by default (#2030) - The "disable pull to refresh" feature is now disabled by default (#2030)
## [6.0.0] ## [6.0.0]
- When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup wizard will guide you through the creation of an admin account - When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup
- Default password complexity requirements have been lowered for local network deployments, where strict password rules are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by `commafeed.users.minimum-password-length` with a default value of `4` (#1916) wizard will guide you through the creation of an admin account
- Email addresses are no longer required when creating users and when they update their profile. The `commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914) - Default password complexity requirements have been lowered for local network deployments, where strict password rules
are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by
`commafeed.users.minimum-password-length` with a default value of `4` (#1916)
- Email addresses are no longer required when creating users and when they update their profile. The
`commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914)
- Java 25+ is now required to build and run CommaFeed - Java 25+ is now required to build and run CommaFeed
## [5.12.1] ## [5.12.1]
@@ -93,15 +129,18 @@
## [5.10.0] ## [5.10.0]
- Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is open (#1762) - Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is
open (#1762)
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again - Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795) - The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are displayed (#1087) - Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are
displayed (#1087)
## [5.9.0] ## [5.9.0]
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757) - A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the new CSS classes - Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the
new CSS classes
- Static resources are now cached for much longer (#1782) - Static resources are now cached for much longer (#1782)
## [5.8.0] ## [5.8.0]
@@ -110,7 +149,8 @@
- A font size slider is now available to change the size of the text of feed entries (#1462) - A font size slider is now available to change the size of the text of feed entries (#1462)
- The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744) - The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744)
- CommaFeed wil try to match the language of the browser before defaulting to english (#1767) - CommaFeed wil try to match the language of the browser before defaulting to english (#1767)
- The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to match what other feed readers do - The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to
match what other feed readers do
## [5.7.0] ## [5.7.0]
@@ -129,14 +169,22 @@
## [5.6.0] ## [5.6.0]
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677) - To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is
now true. This means feeds no longer refresh exactly every 5 minutes (the default value of
`commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new
`commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active
feeds refresh more often (#1677)
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677) - Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users - Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses`
setting if you subscribe to feeds only available on your local network and you trust all your users
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does
not contain a "Retry-After" header
## [5.5.0] ## [5.5.0]
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header - CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this
header
- Audio enclosures (e.g. podcasts) now fill available entry width - Audio enclosures (e.g. podcasts) now fill available entry width
- Fix an issue with some labels not correctly internationalized - Fix an issue with some labels not correctly internationalized
@@ -153,8 +201,10 @@
## [5.3.5] ## [5.3.5]
- Fixed an issue with the aspect ratio of images of some feeds (#1595) - Fixed an issue with the aspect ratio of images of some feeds (#1595)
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615) - CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618) property (#1615)
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may
need to update it to the most recent patch version to support `-proc:full` (#1618)
## [5.3.4] ## [5.3.4]
@@ -181,19 +231,22 @@
- Added an option to keep a number of entries above the selected entry when scrolling - Added an option to keep a number of entries above the selected entry when scrolling
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431) - Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour) - Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up
(every hour)
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557) - Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
## [5.1.1] ## [5.1.1]
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544) - Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543) - When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is
selected (#1543)
## [5.1.0] ## [5.1.0]
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518) - Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
- Fixed an issue that could prevent the app from starting on some systems (#1532) - Fixed an issue that could prevent the app from starting on some systems (#1532)
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date - Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to
date
- Reduced database cleanup log verbosity - Reduced database cleanup log verbosity
## [5.0.2] ## [5.0.2]
@@ -208,9 +261,9 @@
## [5.0.0] ## [5.0.0]
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
the [announcement](https://github.com/Athou/commafeed/discussions/1517). the [announcement](https://github.com/Athou/commafeed/discussions/1517). The gist of it is that CommaFeed can now be
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around compiled to a native binary, resulting in blazing fast startup times (around 0.3s) and very low memory footprint (<
0.3s) and very low memory footprint (< 50M). 50M).
- CommaFeed now has a different package for each supported database. - CommaFeed now has a different package for each supported database.
- If you are deploying CommaFeed with a precompiled package, please - If you are deploying CommaFeed with a precompiled package, please
@@ -266,8 +319,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
- add a button in the entry headers to open links in a new tab (#1333) - add a button in the entry headers to open links in a new tab (#1333)
- add two options in the settings to toggle those buttons - add two options in the settings to toggle those buttons
- accept .opml file extension when importing and export with the .opml extension - accept .opml file extension when importing and export with the .opml extension
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read ( - the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read
older than `keepStatusDays`) (#1303) (older than `keepStatusDays`) (#1303)
## [4.3.3] ## [4.3.3]
@@ -311,9 +364,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
- it is now possible to open the sidebar on mobile by swiping to the right (#1098) - it is now possible to open the sidebar on mobile by swiping to the right (#1098)
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar - swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
- the full hierarchy of categories are now displayed in the category dropdown (#1045) - the full hierarchy of categories are now displayed in the category dropdown (#1045)
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. - added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. The setting is
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set disabled by default for existing installations, except for the docker image where it is enabled and set to 365 days
to 365 days
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login - if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
page instead of the welcome page when not logged in (#1185) page instead of the welcome page when not logged in (#1185)
- the sidebar resizer is no longer shown in the middle of the screen on mobile - the sidebar resizer is no longer shown in the middle of the screen on mobile
@@ -339,8 +391,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
request, reducing CPU usage request, reducing CPU usage
- updated UI library Mantine to 7.0, improving performance - updated UI library Mantine to 7.0, improving performance
- the h2 embedded database is now compacted on shutdown to reclaim unused space - the h2 embedded database is now compacted on shutdown to reclaim unused space
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is - the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is recommended
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0) (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
- migrated documentation from swagger 2 to openapi 3 - migrated documentation from swagger 2 to openapi 3
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser - added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be - the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
@@ -582,7 +634,6 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
## [2.0.0] ## [2.0.0]
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory - The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
consumption and better overall performances. consumption and better overall performances. See the README on how to build CommaFeed from now on.
See the README on how to build CommaFeed from now on.
- CommaFeed should no longer fetch the same feed multiple times in a row - CommaFeed should no longer fetch the same feed multiple times in a row
- Users can use their username or email to log in - Users can use their username or email to log in

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,10 +1,10 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", "$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
"formatter": { "formatter": {
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 4, "indentWidth": 4,
"lineEnding": "lf", "lineEnding": "lf",
"lineWidth": 140 "lineWidth": 120
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {

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.22.5", "@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.2.4",
"axios": "1.19.0", "axios": "1.21.0",
"dayjs": "1.11.21", "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.22.5", "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.8", "@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.4", "@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.10", "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.1</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

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

View File

@@ -0,0 +1,36 @@
import { i18n } from "@lingui/core"
import type { AxiosError } from "axios"
import { afterEach, describe, expect, it, vi } from "vitest"
import { errorToStrings } from "./client"
const axiosError = (status: number, data: unknown) =>
({
isAxiosError: true,
response: { status, data },
}) as AxiosError
describe("errorToStrings", () => {
afterEach(() => vi.restoreAllMocks())
it("translates known application error types", () => {
vi.spyOn(i18n, "_").mockReturnValue("Translated authentication error")
const error = axiosError(401, {
type: "WRONG_USERNAME_OR_PASSWORD",
message: "wrong username or password",
})
expect(errorToStrings(error)).toEqual(["Translated authentication error"])
})
it("preserves backend messages for unexpected errors", () => {
const error = axiosError(500, { message: "unexpected error" })
expect(errorToStrings(error)).toEqual(["unexpected error"])
})
it("preserves backend messages for unknown application error types", () => {
const error = axiosError(400, { type: "UNKNOWN_ERROR", message: "unknown error" })
expect(errorToStrings(error)).toEqual(["unknown error"])
})
})

View File

@@ -1,3 +1,5 @@
import { i18n, type MessageDescriptor } from "@lingui/core"
import { msg } from "@lingui/core/macro"
import axios, { type AxiosError } from "axios" import axios, { type AxiosError } from "axios"
import type { import type {
AddCategoryRequest, AddCategoryRequest,
@@ -6,6 +8,8 @@ import type {
Category, Category,
CategoryModificationRequest, CategoryModificationRequest,
CollapseRequest, CollapseRequest,
CommaFeedApplicationError,
CommaFeedExceptionType,
Entries, Entries,
FeedInfo, FeedInfo,
FeedInfoRequest, FeedInfoRequest,
@@ -31,6 +35,10 @@ import type {
UserModel, UserModel,
} from "./types" } from "./types"
const applicationErrorMessages = {
WRONG_USERNAME_OR_PASSWORD: msg`Wrong username or password`,
} satisfies Record<CommaFeedExceptionType, MessageDescriptor>
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true }) const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
axiosInstance.interceptors.response.use( axiosInstance.interceptors.response.use(
response => response, response => response,
@@ -53,7 +61,8 @@ export const client = {
getRoot: async () => await axiosInstance.get<Category>("category/get"), getRoot: async () => await axiosInstance.get<Category>("category/get"),
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req), modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req), collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }), getEntries: async (req: GetEntriesPaginatedRequest) =>
await axiosInstance.get<Entries>("category/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req), markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req), add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req), delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
@@ -68,7 +77,8 @@ export const client = {
feed: { feed: {
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`), get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req), modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }), getEntries: async (req: GetEntriesPaginatedRequest) =>
await axiosInstance.get<Entries>("feed/entries", { params: req }),
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req), markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req), fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
refreshAll: async () => await axiosInstance.get("feed/refreshAll"), refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
@@ -99,7 +109,8 @@ export const client = {
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req), register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req), initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req), passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
passwordResetCallback: async (req: PasswordResetConfirmationRequest) => await axiosInstance.post("user/passwordResetCallback", req), passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
await axiosInstance.post("user/passwordResetCallback", req),
getSettings: async () => await axiosInstance.get<Settings>("user/settings"), getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings), saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
sendTestPushNotification: async (settings: PushNotificationSettings) => sendTestPushNotification: async (settings: PushNotificationSettings) =>
@@ -128,18 +139,33 @@ export const errorToStrings = (err: unknown) => {
let strings: string[] = [] let strings: string[] = []
if (axios.isAxiosError(err) && err.response) { if (axios.isAxiosError(err) && err.response) {
if (typeof err.response.data === "string") strings.push(err.response.data) if (isCommaFeedApplicationError(err)) {
if (isMessageError(err)) strings.push(err.response.data.message) strings.push(i18n._(applicationErrorMessages[err.response.data.type]))
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors] } else {
if (typeof err.response.data === "string") strings.push(err.response.data)
if (isMessageError(err)) strings.push(err.response.data.message)
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
}
} }
return strings return strings
} }
function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFeedApplicationError> {
const data = err.response?.data
if (!data || typeof data !== "object" || !("type" in data)) return false
const type = data.type
return typeof type === "string" && Object.hasOwn(applicationErrorMessages, type)
}
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> { function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data return (
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
)
} }
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> { function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data return (
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
)
} }

View File

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

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

View File

@@ -40,7 +40,9 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
const state = thunkApi.getState() const state = thunkApi.getState()
const { source } = state.entries const { source } = state.entries
const offset = const offset =
state.user.settings?.readingMode === "all" || (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 +139,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 +154,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
read: true, read: true,
olderThan: Date.now(), olderThan: Date.now(),
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
keywords: search,
}, },
}) })
) )
@@ -166,7 +170,6 @@ export const starEntry = createAppAsyncThunk(
(arg: { entry: Entry; starred: boolean }) => { (arg: { entry: Entry; starred: boolean }) => {
client.entry.star({ client.entry.star({
id: arg.entry.id, id: arg.entry.id,
feedId: +arg.entry.feedId,
starred: arg.starred, starred: arg.starred,
}) })
}, },
@@ -219,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
const entryIndex = state.entries.entries.indexOf(entry) const entryIndex = state.entries.entries.indexOf(entry)
const entriesToKeepOnTopWhenScrolling = const entriesToKeepOnTopWhenScrolling =
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex) viewMode === "expanded"
? 0
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling] const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
const entryElement = document.getElementById(Constants.dom.entryId(entry)) const entryElement = document.getElementById(Constants.dom.entryId(entry))
@@ -227,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
if (entryElement && entryElementToScrollTo) { if (entryElement && entryElementToScrollTo) {
const scrollMode = state.user.settings?.scrollMode const scrollMode = state.user.settings?.scrollMode
const entryEntirelyVisible = const entryEntirelyVisible =
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement) Constants.layout.isTopVisible(entryElementToScrollTo) &&
Constants.layout.isBottomVisible(entryElement)
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) { if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
const scrollSpeed = state.user.settings?.scrollSpeed const scrollSpeed = state.user.settings?.scrollSpeed
const margin = viewMode === "detailed" ? 8 : 3 const margin = viewMode === "detailed" ? 8 : 3
@@ -241,7 +247,12 @@ export const selectEntry = createAppAsyncThunk(
} }
) )
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => { const scrollToEntry = (
entryElement: HTMLElement,
margin: number,
scrollSpeed: number | undefined,
onScrollEnded: () => void
) => {
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect() const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
const offset = (header?.bottom ?? 0) + margin const offset = (header?.bottom ?? 0) + margin
scrollToWithCallback({ scrollToWithCallback({

View File

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

View File

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

View File

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

View File

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

View File

@@ -297,7 +297,6 @@ export interface LocalSettings {
export interface StarRequest { export interface StarRequest {
id: string id: string
feedId: number
starred: boolean starred: boolean
} }
@@ -338,3 +337,10 @@ export interface AuthenticationError {
message: string message: string
allowRegistrations: boolean allowRegistrations: boolean
} }
export type CommaFeedExceptionType = "WRONG_USERNAME_OR_PASSWORD"
export interface CommaFeedApplicationError {
type: CommaFeedExceptionType
message: string
}

View File

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

View File

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

View File

@@ -1,18 +1,27 @@
import type { MessageDescriptor } from "@lingui/core" import type { MessageDescriptor } from "@lingui/core"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { ActionIcon, type ActionIconVariant, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core" import {
ActionIcon,
type ActionIconVariant,
Box,
Button,
type ButtonVariant,
Tooltip,
useMantineTheme,
} from "@mantine/core"
import { forwardRef, type MouseEventHandler, type ReactNode } from "react" import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
import { Constants } from "@/app/constants" import { Constants } from "@/app/constants"
import { useActionButton } from "@/hooks/useActionButton" import { useActionButton } from "@/hooks/useActionButton"
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 +34,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,7 +13,10 @@ 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 markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread) const search = useAppSelector(state => state.entries.search)
const markAllAsReadNavigateToNextUnread = useAppSelector(
state => state.user.settings?.markAllAsReadNavigateToNextUnread
)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const onConfirm = async () => { const onConfirm = async () => {
@@ -26,12 +29,14 @@ 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,
}, },
}) })
) )
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected) await dispatch(selectNextUnreadTreeItem({ direction: "forward" })) if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
} }
return ( return (
@@ -49,7 +54,8 @@ export function MarkAllAsReadConfirmationDialog() {
)} )}
{threshold > 0 && ( {threshold > 0 && (
<Trans> <Trans>
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read? Are you sure you want to mark entries older than {threshold} days of{" "}
<Code>{sourceLabel}</Code> as read?
</Trans> </Trans>
)} )}
</Text> </Text>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1150,6 +1150,10 @@ msgstr "موقع الكتروني"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "مرحباً! يبدو أن هذه هي المرة الأولى التي تقوم فيها بتشغيل CommaFeed. يرجى إنشاء حساب مسؤول للبدء." msgstr "مرحباً! يبدو أن هذه هي المرة الأولى التي تقوم فيها بتشغيل CommaFeed. يرجى إنشاء حساب مسؤول للبدء."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "أصفر" msgstr "أصفر"

View File

@@ -1150,6 +1150,10 @@ msgstr "Lloc web"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Benvingut! Sembla que aquesta és la primera vegada que executeu CommaFeed. Creeu un compte d'administrador per començar." msgstr "Benvingut! Sembla que aquesta és la primera vegada que executeu CommaFeed. Creeu un compte d'administrador per començar."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Groc" msgstr "Groc"

View File

@@ -1150,6 +1150,10 @@ msgstr "Webová stránka"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Vítejte! Zdá se, že je to poprvé, co spouštíte CommaFeed. Chcete-li začít, vytvořte si účet správce." msgstr "Vítejte! Zdá se, že je to poprvé, co spouštíte CommaFeed. Chcete-li začít, vytvořte si účet správce."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Žlutá" msgstr "Žlutá"

View File

@@ -1150,6 +1150,10 @@ msgstr "Gwefan"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Croeso! Ymddengys mai dyma'r tro cyntaf i chi redeg CommaFeed. Creuwch gyfrif gweinyddwr i ddechrau." msgstr "Croeso! Ymddengys mai dyma'r tro cyntaf i chi redeg CommaFeed. Creuwch gyfrif gweinyddwr i ddechrau."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Melyn" msgstr "Melyn"

View File

@@ -1150,6 +1150,10 @@ msgstr "Hjemmeside"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Velkommen! Det ser ud til at være første gang, du kører CommaFeed. Opret venligst en administratorkonto for at komme i gang." msgstr "Velkommen! Det ser ud til at være første gang, du kører CommaFeed. Opret venligst en administratorkonto for at komme i gang."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Gul" msgstr "Gul"

View File

@@ -1150,6 +1150,10 @@ msgstr "Webseite"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Willkommen! Dies scheint das erste Mal zu sein, dass Sie CommaFeed ausführen. Bitte erstellen Sie ein Administrator-Konto, um zu beginnen." msgstr "Willkommen! Dies scheint das erste Mal zu sein, dass Sie CommaFeed ausführen. Bitte erstellen Sie ein Administrator-Konto, um zu beginnen."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Gelb" msgstr "Gelb"

View File

@@ -1150,6 +1150,10 @@ msgstr "Website"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgstr "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr "Wrong username or password"
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Yellow" msgstr "Yellow"

View File

@@ -1151,6 +1151,10 @@ msgstr "Sitio web"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "¡Bienvenido! Parece que esta es la primera vez que ejecutas CommaFeed. Por favor, crea una cuenta de administrador para empezar." msgstr "¡Bienvenido! Parece que esta es la primera vez que ejecutas CommaFeed. Por favor, crea una cuenta de administrador para empezar."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Amarillo" msgstr "Amarillo"

View File

@@ -1150,6 +1150,10 @@ msgstr "وب سایت"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "خوش آمدید! به نظر می‌رسد این اولین بار است که CommaFeed را اجرا می‌کنید. لطفاً برای شروع یک حساب مدیر ایجاد کنید." msgstr "خوش آمدید! به نظر می‌رسد این اولین بار است که CommaFeed را اجرا می‌کنید. لطفاً برای شروع یک حساب مدیر ایجاد کنید."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "زرد" msgstr "زرد"

View File

@@ -1150,6 +1150,10 @@ msgstr "Verkkosivusto"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Tervetuloa! Näyttää siltä, että suoritat CommaFeediä ensimmäistä kertaa. Luo ylläpitäjän tili aloittaaksesi." msgstr "Tervetuloa! Näyttää siltä, että suoritat CommaFeediä ensimmäistä kertaa. Luo ylläpitäjän tili aloittaaksesi."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Keltainen" msgstr "Keltainen"

View File

@@ -460,11 +460,11 @@ msgstr "Extensions"
#: src/components/settings/ProfileSettings.tsx #: src/components/settings/ProfileSettings.tsx
msgid "Google Reader API" msgid "Google Reader API"
msgstr "" msgstr "API Google Reader"
#: src/components/settings/ProfileSettings.tsx #: src/components/settings/ProfileSettings.tsx
msgid "Google Reader API URL" msgid "Google Reader API URL"
msgstr "" msgstr "URL de l'API Google Reader"
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Grape" msgid "Grape"
@@ -1150,6 +1150,10 @@ msgstr "Site web"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Bienvenue ! Il semble que ce soit le premier démarrage de Commafeed. Avant tout, vous devez créer un compte administrateur." msgstr "Bienvenue ! Il semble que ce soit le premier démarrage de Commafeed. Avant tout, vous devez créer un compte administrateur."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr "Utilisateur ou mot de passe incorrect"
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Jaune" msgstr "Jaune"

View File

@@ -1151,6 +1151,10 @@ msgstr "Páxina web"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Benvida! Semella que é a primeira vez que executas CommaFeed. Para comezar, crea unha conta de administración." msgstr "Benvida! Semella que é a primeira vez que executas CommaFeed. Para comezar, crea unha conta de administración."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Amarelo" msgstr "Amarelo"

View File

@@ -1150,6 +1150,10 @@ msgstr "Webhely"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Üdvözöljük! Úgy tűnik, ez az első alkalom, hogy a CommaFeedet futtatja. Kérjük, hozzon létre egy rendszergazdai fiókot a kezdéshez." msgstr "Üdvözöljük! Úgy tűnik, ez az első alkalom, hogy a CommaFeedet futtatja. Kérjük, hozzon létre egy rendszergazdai fiókot a kezdéshez."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Sárga" msgstr "Sárga"

View File

@@ -1150,6 +1150,10 @@ msgstr "Situs Web"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Selamat datang! Ini sepertinya pertama kalinya Anda menjalankan CommaFeed. Silakan buat akun administrator untuk memulai." msgstr "Selamat datang! Ini sepertinya pertama kalinya Anda menjalankan CommaFeed. Silakan buat akun administrator untuk memulai."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Kuning" msgstr "Kuning"

View File

@@ -1150,6 +1150,10 @@ msgstr "Sito web"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Benvenuto! Sembra che sia la prima volta che esegui CommaFeed. Crea un account amministratore per iniziare." msgstr "Benvenuto! Sembra che sia la prima volta che esegui CommaFeed. Crea un account amministratore per iniziare."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Giallo" msgstr "Giallo"

View File

@@ -1150,6 +1150,10 @@ msgstr "ウェブサイト"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "ようこそ!CommaFeedを初めて実行するようです。開始するには管理者アカウントを作成してください。" msgstr "ようこそ!CommaFeedを初めて実行するようです。開始するには管理者アカウントを作成してください。"
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "イエロー" msgstr "イエロー"

View File

@@ -1150,6 +1150,10 @@ msgstr "웹사이트"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "환영합니다! CommaFeed를 처음 실행하는 것 같습니다. 시작하려면 관리자 계정을 만드십시오." msgstr "환영합니다! CommaFeed를 처음 실행하는 것 같습니다. 시작하려면 관리자 계정을 만드십시오."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "옐로우" msgstr "옐로우"

View File

@@ -1150,6 +1150,10 @@ msgstr "Laman web"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Selamat datang! Ini nampaknya kali pertama anda menjalankan CommaFeed. Sila cipta akaun pentadbir untuk bermula." msgstr "Selamat datang! Ini nampaknya kali pertama anda menjalankan CommaFeed. Sila cipta akaun pentadbir untuk bermula."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Kuning" msgstr "Kuning"

View File

@@ -1150,6 +1150,10 @@ msgstr "Nettsted"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Velkommen! Det ser ut til å være første gang du kjører CommaFeed. Vennligst opprett en administratorkonto for å komme i gang." msgstr "Velkommen! Det ser ut til å være første gang du kjører CommaFeed. Vennligst opprett en administratorkonto for å komme i gang."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Gul" msgstr "Gul"

View File

@@ -1150,6 +1150,10 @@ msgstr "Website"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Welkom! Dit lijkt de eerste keer te zijn dat je CommaFeed draait. Maak een beheerdersaccount aan om aan de slag te gaan." msgstr "Welkom! Dit lijkt de eerste keer te zijn dat je CommaFeed draait. Maak een beheerdersaccount aan om aan de slag te gaan."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Geel" msgstr "Geel"

View File

@@ -1150,6 +1150,10 @@ msgstr "Nettstad"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Velkomen! Det ser ut til å vera fyrste gongen du køyrer CommaFeed. Ver venleg og opprett ein administratorkonto for å koma i gang." msgstr "Velkomen! Det ser ut til å vera fyrste gongen du køyrer CommaFeed. Ver venleg og opprett ein administratorkonto for å koma i gang."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Gul" msgstr "Gul"

View File

@@ -1150,6 +1150,10 @@ msgstr "Strona internetowa"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Witaj! Wygląda na to, że uruchamiasz CommaFeed po raz pierwszy. Aby rozpocząć, utwórz konto administratora." msgstr "Witaj! Wygląda na to, że uruchamiasz CommaFeed po raz pierwszy. Aby rozpocząć, utwórz konto administratora."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Żółty" msgstr "Żółty"

View File

@@ -1150,6 +1150,10 @@ msgstr "Site"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Bem-vindo! Esta parece ser a primeira vez que você está executando o CommaFeed. Crie uma conta de administrador para começar." msgstr "Bem-vindo! Esta parece ser a primeira vez que você está executando o CommaFeed. Crie uma conta de administrador para começar."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Amarelo" msgstr "Amarelo"

View File

@@ -1150,6 +1150,10 @@ msgstr "Веб-сайт"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Добро пожаловать! Похоже, вы запускаете CommaFeed в первый раз. Пожалуйста, создайте учетную запись администратора, чтобы начать работу." msgstr "Добро пожаловать! Похоже, вы запускаете CommaFeed в первый раз. Пожалуйста, создайте учетную запись администратора, чтобы начать работу."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Желтый" msgstr "Желтый"

View File

@@ -1150,6 +1150,10 @@ msgstr "Webová stránka"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Vitajte! Zdá sa, že CommaFeed spúšťate prvýkrát. Ak chcete začať, vytvorte si účet správcu." msgstr "Vitajte! Zdá sa, že CommaFeed spúšťate prvýkrát. Ak chcete začať, vytvorte si účet správcu."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Žltá" msgstr "Žltá"

View File

@@ -1150,6 +1150,10 @@ msgstr "Webbplats"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Välkommen! Det verkar vara första gången du kör CommaFeed. Skapa ett administratörskonto för att komma igång." msgstr "Välkommen! Det verkar vara första gången du kör CommaFeed. Skapa ett administratörskonto för att komma igång."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Gul" msgstr "Gul"

View File

@@ -1150,6 +1150,10 @@ msgstr "Web sitesi"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "Hoş geldiniz! Görünüşe göre CommaFeed'i ilk kez çalıştırıyorsunuz. Başlamak için lütfen bir yönetici hesabı oluşturun." msgstr "Hoş geldiniz! Görünüşe göre CommaFeed'i ilk kez çalıştırıyorsunuz. Başlamak için lütfen bir yönetici hesabı oluşturun."
#: src/app/client.ts
msgid "Wrong username or password"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "Sarı" msgstr "Sarı"

View File

@@ -1150,6 +1150,10 @@ msgstr "网站"
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started." msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
msgstr "欢迎!当前页仅当您第一次使用CommaFeed时出现,请创建一个管理员帐号以开始使用。" msgstr "欢迎!当前页仅当您第一次使用CommaFeed时出现,请创建一个管理员帐号以开始使用。"
#: src/app/client.ts
msgid "Wrong username or password"
msgstr "用户名或密码错误"
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Yellow" msgid "Yellow"
msgstr "黄" msgstr "黄"

View File

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

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

View File

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

View File

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

View File

@@ -1,7 +1,21 @@
import { msg } from "@lingui/core/macro" import { msg } from "@lingui/core/macro"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core" import {
Anchor,
Box,
Button,
Code,
Container,
Divider,
Group,
Input,
NumberInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals" import { openConfirmModal } from "@mantine/modals"
import { useEffect } from "react" import { useEffect } from "react"
@@ -37,6 +51,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,9 +142,14 @@ 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} />
</> </>
)} )}
@@ -137,7 +159,11 @@ export function CategoryDetailsPage() {
</Button> </Button>
{editable && ( {editable && (
<> <>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}> <Button
type="submit"
leftSection={<TbDeviceFloppy size={16} />}
loading={modifyCategory.loading}
>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
<Divider orientation="vertical" /> <Divider orientation="vertical" />

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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.1</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.2</quarkus.version> <quarkus.version>3.40.1.1</quarkus.version>
<querydsl.version>7.5</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.35.1</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>13.11.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.11.0</version>
<?m2e ignore?> <?m2e ignore?>
<executions> <executions>
<execution> <execution>
@@ -290,6 +290,7 @@
<googleJavaFormat> <googleJavaFormat>
<version>${google-java-format.version}</version> <version>${google-java-format.version}</version>
<style>AOSP</style> <style>AOSP</style>
<maxLineLength>120</maxLineLength>
<reorderImports>true</reorderImports> <reorderImports>true</reorderImports>
</googleJavaFormat> </googleJavaFormat>
</java> </java>
@@ -302,14 +303,14 @@
<dependency> <dependency>
<groupId>com.commafeed</groupId> <groupId>com.commafeed</groupId>
<artifactId>commafeed-client</artifactId> <artifactId>commafeed-client</artifactId>
<version>7.3.1</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 +323,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 +374,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 +405,7 @@
<dependency> <dependency>
<groupId>dev.cel</groupId> <groupId>dev.cel</groupId>
<artifactId>cel</artifactId> <artifactId>cel</artifactId>
<version>0.13.1</version> <version>0.15.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.passay</groupId> <groupId>org.passay</groupId>
@@ -429,12 +435,12 @@
<dependency> <dependency>
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.23.1</version> <version>1.24.1</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 +455,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 +477,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.5.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:02888a2306c2cb8af22a05b2c41be90fdc9df5f4611861ddf6ea90c874de65e6 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

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

View File

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

View File

@@ -1,5 +1,7 @@
package com.commafeed; package com.commafeed;
import com.commafeed.frontend.exception.CommaFeedApplicationException;
import com.commafeed.frontend.exception.CommaFeedExceptionType;
import com.commafeed.security.CookieService; import com.commafeed.security.CookieService;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
@@ -8,7 +10,10 @@ import io.quarkus.security.UnauthorizedException;
import jakarta.annotation.Priority; import jakarta.annotation.Priority;
import jakarta.validation.ValidationException; import jakarta.validation.ValidationException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie; import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -26,17 +31,37 @@ public class ExceptionMappers {
private final CookieService cookieService; private final CookieService cookieService;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
@ServerExceptionMapper(CommaFeedApplicationException.class)
public RestResponse<CommaFeedApplicationError> applicationError(CommaFeedApplicationException e) {
ResponseBuilder<CommaFeedApplicationError> response =
ResponseBuilder.create(e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
if (e.type().status() == Status.UNAUTHORIZED) {
response.cookie(cookieService.buildLogoutCookie());
}
return response.build();
}
@ServerExceptionMapper(UnauthorizedException.class) @ServerExceptionMapper(UnauthorizedException.class)
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) { public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
return RestResponse.status( return RestResponse.status(
Status.UNAUTHORIZED, Status.UNAUTHORIZED, new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
} }
@ServerExceptionMapper(AuthenticationFailedException.class) @ServerExceptionMapper(AuthenticationFailedException.class)
public RestResponse<AuthenticationFailed> authenticationFailed( public RestResponse<AuthenticationFailed> authenticationFailed(
AuthenticationFailedException e) { AuthenticationFailedException e, HttpHeaders headers, UriInfo uriInfo) {
NewCookie logoutCookie = cookieService.buildLogoutCookie(); NewCookie logoutCookie = cookieService.buildLogoutCookie();
// a browser navigating with an invalidated session would otherwise display the error
// instead of the page, retry the request now that the cookie is removed
if (headers.getAcceptableMediaTypes().stream()
.anyMatch(m -> MediaType.TEXT_HTML_TYPE.isCompatible(m) && !m.isWildcardType())) {
return ResponseBuilder.<AuthenticationFailed>create(Status.TEMPORARY_REDIRECT)
.location(uriInfo.getRequestUri())
.cookie(logoutCookie)
.build();
}
return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage())) return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()))
.cookie(logoutCookie) .cookie(logoutCookie)
.build(); .build();
@@ -55,4 +80,7 @@ public class ExceptionMappers {
@RegisterForReflection @RegisterForReflection
public record ValidationFailed(String message) {} public record ValidationFailed(String message) {}
@RegisterForReflection
public record CommaFeedApplicationError(CommaFeedExceptionType type, String message) {}
} }

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
@@ -56,81 +56,77 @@ public class HttpClientFactory {
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE; private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress(); private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
private static final IPAddress NAT64_LOCAL_USE_RANGE = new IPAddressString("64:ff9b:1::/48").getAddress();
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final CommaFeedVersion version; private final CommaFeedVersion version;
public 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)", version.getVersion()));
"CommaFeed/%s (https://github.com/Athou/commafeed)",
version.getVersion()));
return newClient(config, connectionManager, userAgent); return newClient(config, connectionManager, userAgent);
} }
private CloseableHttpClient newClient( private CloseableHttpAsyncClient newClient(
CommaFeedConfiguration config, CommaFeedConfiguration config, PoolingAsyncClientConnectionManager connectionManager, String userAgent) {
HttpClientConnectionManager connectionManager,
String userAgent) {
List<Header> headers = new ArrayList<>(); List<Header> headers = new ArrayList<>();
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en")); headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache")); headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
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 = new LinkedHashMap<>();
contentDecoderMap.put(ContentCoding.GZIP.token(), GZIPInputStream::new); contentDecoderMap.put(ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
contentDecoderMap.put(ContentCoding.DEFLATE.token(), DeflateInputStream::new); contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
contentDecoderMap.put(ContentCoding.BROTLI.token(), BrotliInputStream::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(TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
.setContentDecoderRegistry(new LinkedHashMap<>(contentDecoderMap)) .setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
.setRedirectStrategy(redirectStrategy) .setRedirectStrategy(redirectStrategy)
.build(); .build();
client.start();
return client;
} }
private PoolingHttpClientConnectionManager newConnectionManager( private PoolingAsyncClientConnectionManager newConnectionManager(CommaFeedConfiguration config, int poolSize) {
CommaFeedConfiguration config, int poolSize) { SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
SSLFactory sslFactory =
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
DnsResolver dnsResolver = DnsResolver dnsResolver =
config.httpClient().blockLocalAddresses() config.httpClient().blockLocalAddresses()
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER) ? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
: 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()))
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout())) .setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
.setTimeToLive( .setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive()))
Timeout.of(config.httpClient().connectionTimeToLive()))
.build()) .build())
.setDefaultTlsConfig( .setDefaultTlsConfig(
TlsConfig.custom() TlsConfig.custom()
.setHandshakeTimeout( .setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
Timeout.of(config.httpClient().sslHandshakeTimeout())) .setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout()))
.build()) .build())
.setMaxConnPerRoute(poolSize) .setMaxConnPerRoute(poolSize)
.setMaxConnTotal(poolSize) .setMaxConnTotal(poolSize)
@@ -143,7 +139,11 @@ public class HttpClientFactory {
} }
private static boolean isLocalAddress(IPAddress ip) { private static boolean isLocalAddress(IPAddress ip) {
if (ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip)) { if (ip.isLocal()
|| ip.isLoopback()
|| ip.isMulticast()
|| CGNAT_RANGE.contains(ip)
|| NAT64_LOCAL_USE_RANGE.contains(ip)) {
return true; return true;
} }
@@ -179,8 +179,7 @@ public class HttpClientFactory {
InetAddress[] addresses = delegate.resolve(host); InetAddress[] addresses = delegate.resolve(host);
for (InetAddress addr : addresses) { for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) { if (isLocalAddress(addr)) {
throw new UnknownHostException( throw new UnknownHostException("Access to local address blocked: " + addr.getHostAddress());
"Access to local address blocked: " + addr.getHostAddress());
} }
} }
return addresses; return addresses;
@@ -216,8 +215,7 @@ public class HttpClientFactory {
for (InetAddress addr : addresses) { for (InetAddress addr : addresses) {
if (isLocalAddress(addr)) { if (isLocalAddress(addr)) {
throw new HttpException( throw new HttpException("Access to local address blocked: " + addr.getHostAddress());
"Access to local address blocked: " + addr.getHostAddress());
} }
} }

View File

@@ -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(
@@ -62,32 +75,22 @@ public class HttpGetter {
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads()); this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
this.cache = newCache(config); this.cache = newCache(config);
metrics.registerGauge( metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
MetricRegistry.name(getClass(), "cache", "size"),
() -> cache == null ? 0 : cache.size());
metrics.registerGauge( metrics.registerGauge(
MetricRegistry.name(getClass(), "cache", "memoryUsage"), MetricRegistry.name(getClass(), "cache", "memoryUsage"),
() -> () ->
cache == null cache == null
? 0 ? 0
: cache.asMap().values().stream() : cache.asMap().values().stream().mapToInt(e -> ArrayUtils.getLength(e.content)).sum());
.mapToInt(e -> ArrayUtils.getLength(e.content))
.sum());
} }
public HttpResult get(String url) public HttpResult get(String url)
throws IOException, throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
return get(HttpRequest.builder(url).build()); return get(HttpRequest.builder(url).build());
} }
public HttpResult get(HttpRequest request) public HttpResult get(HttpRequest request)
throws IOException, throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
NotModifiedException,
TooManyRequestsException,
SchemeNotAllowedException {
URI uri = URI.create(request.getUrl()); URI uri = URI.create(request.getUrl());
ensureHttpScheme(uri.getScheme()); ensureHttpScheme(uri.getScheme());
@@ -150,70 +153,95 @@ 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(Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
int code = resp.getCode();
String lastModifiedHeader =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
String eTagHeader =
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG))
.map(NameValuePair::getValue)
.map(StringUtils::trimToNull)
.orElse(null);
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 +265,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 +272,63 @@ public class HttpGetter {
} }
return CacheBuilder.newBuilder() return CacheBuilder.newBuilder()
.weigher( .weigher((HttpRequest key, HttpResponse value) -> value.content() != null ? value.content().length : 0)
(HttpRequest key, HttpResponse value) ->
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;
@@ -296,8 +351,7 @@ public class HttpGetter {
this(message, null, null); this(message, null, null);
} }
public NotModifiedException( public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) {
String message, String newLastModifiedHeader, String newEtagHeader) {
super(message); super(message);
this.newLastModifiedHeader = newLastModifiedHeader; this.newLastModifiedHeader = newLastModifiedHeader;
this.newEtagHeader = newEtagHeader; this.newEtagHeader = newEtagHeader;
@@ -336,15 +390,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

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

View File

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

View File

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

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