mirror of
https://github.com/Athou/commafeed.git
synced 2026-11-02 18:34:48 +00:00
Compare commits
284 Commits
7.3.1
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e382ed73e | ||
|
|
93edb47dca | ||
|
|
801680e373 | ||
|
|
5704e1e1fa | ||
|
|
a2a6720cd1 | ||
|
|
4f8cddeb54 | ||
|
|
90d0846bad | ||
|
|
698b2f4dfd | ||
|
|
0053c54ae4 | ||
|
|
16b6077496 | ||
|
|
edb4d5e178 | ||
|
|
4b091fb90b | ||
|
|
30c049d10a | ||
|
|
fa8a447787 | ||
|
|
20ec5d3eb9 | ||
|
|
796dc88c33 | ||
|
|
f979f5426f | ||
|
|
8f9be28002 | ||
|
|
89845681be | ||
|
|
8fdf63dd73 | ||
|
|
8f07454270 | ||
|
|
946d82d8ee | ||
|
|
f8d625b651 | ||
|
|
eefdd40a02 | ||
|
|
08f2889f1d | ||
|
|
45453fcf4f | ||
|
|
ef7bab4841 | ||
|
|
ac277f9ca5 | ||
|
|
44dab0287b | ||
|
|
88b75ec0cb | ||
|
|
cbb45ce68c | ||
|
|
b0c4dbf533 | ||
|
|
1891b63a0e | ||
|
|
c420fec8ee | ||
|
|
2ee2a5dfbe | ||
|
|
a5b98f9ea2 | ||
|
|
baedab2960 | ||
|
|
004e18bbb8 | ||
|
|
72e364afe9 | ||
|
|
86ff2dfcb2 | ||
|
|
69ce73012c | ||
|
|
f3bd1a2e11 | ||
|
|
a819283661 | ||
|
|
8255c1e929 | ||
|
|
eb7990f8dc | ||
|
|
982476b2d0 | ||
|
|
3ec653c3b7 | ||
|
|
5dc5b3795e | ||
|
|
f5f77a80a3 | ||
|
|
1d1099492e | ||
|
|
ccc2564929 | ||
|
|
084574ffd4 | ||
|
|
6a6978c491 | ||
|
|
0399375288 | ||
|
|
394346fbc1 | ||
|
|
0feec109bd | ||
|
|
fdb5c9b536 | ||
|
|
f7ad8aed60 | ||
|
|
4621d7e79f | ||
|
|
b64a49cfb0 | ||
|
|
aaddfbaf40 | ||
|
|
994da5e1ee | ||
|
|
811e282e28 | ||
|
|
892fcf8168 | ||
|
|
032ee9243f | ||
|
|
30cdf34d56 | ||
|
|
fd52caa3fd | ||
|
|
9788712808 | ||
|
|
af9d086f05 | ||
|
|
fc7190868e | ||
|
|
d3c1892c0b | ||
|
|
1fb4cdf3ca | ||
|
|
7928183036 | ||
|
|
ffa276df9e | ||
|
|
9f05e66e9d | ||
|
|
8ba90ded85 | ||
|
|
4e5e2f06d3 | ||
|
|
72396254d2 | ||
|
|
f9313006ba | ||
|
|
c894d701ff | ||
|
|
e9d0466018 | ||
|
|
28b4f61f20 | ||
|
|
ab6b3b225d | ||
|
|
ffb4a43e38 | ||
|
|
0653e2dc97 | ||
|
|
912e03d2ce | ||
|
|
a8df765cda | ||
|
|
b865b3ff84 | ||
|
|
69c1e8a5b8 | ||
|
|
c1522f1005 | ||
|
|
8144d24b06 | ||
|
|
4c2e2efbb5 | ||
|
|
ad452c9a7a | ||
|
|
a43da1304d | ||
|
|
c2da59a92f | ||
|
|
a89166d1c4 | ||
|
|
45039593bf | ||
|
|
d56c35cdfb | ||
|
|
73836c6800 | ||
|
|
f9a0440db8 | ||
|
|
6e7a8c1ef6 | ||
|
|
44147cc062 | ||
|
|
352b756e22 | ||
|
|
a5d9eb757f | ||
|
|
a19a73d675 | ||
|
|
4a57f87284 | ||
|
|
b523fe4c58 | ||
|
|
c208450abc | ||
|
|
b73ab2a781 | ||
|
|
ec679cc23c | ||
|
|
1dbbcc4d21 | ||
|
|
1573b61584 | ||
|
|
1ac8cc7674 | ||
|
|
793db4e9b6 | ||
|
|
12863e557a | ||
|
|
d999fc1e06 | ||
|
|
6c288d160c | ||
|
|
57d5dd8b20 | ||
|
|
0a8c0e0c7f | ||
|
|
f72bd4cc5c | ||
|
|
26cce1faac | ||
|
|
3101b4f423 | ||
|
|
e8e30faf3e | ||
|
|
3f6158fc47 | ||
|
|
3465645b70 | ||
|
|
2dd397757d | ||
|
|
5496419bb4 | ||
|
|
f1b950c38e | ||
|
|
4a3e97ad52 | ||
|
|
9fe17996b4 | ||
|
|
c7a2f87148 | ||
|
|
1125dd3fe7 | ||
|
|
adf48aee44 | ||
|
|
534d8a333e | ||
|
|
03fc91c19c | ||
|
|
d2429dada2 | ||
|
|
0fae323647 | ||
|
|
fae58d56be | ||
|
|
fc56d5e7cb | ||
|
|
04c85acae0 | ||
|
|
af2fd9bc09 | ||
|
|
d4ce2a213f | ||
|
|
7d9ba805e4 | ||
|
|
4c315ca3b8 | ||
|
|
bcf812ef7a | ||
|
|
fd603aa76f | ||
|
|
e34c08c803 | ||
|
|
9252ef1531 | ||
|
|
63153ca195 | ||
|
|
a8c2f69251 | ||
|
|
91eaef05e1 | ||
|
|
4822642fd7 | ||
|
|
a712d8f900 | ||
|
|
86e507f93e | ||
|
|
86eba276c5 | ||
|
|
4f18a98a4a | ||
|
|
547eff9f06 | ||
|
|
5bd1225d3f | ||
|
|
309a0aa94c | ||
|
|
f8286a6334 | ||
|
|
916086d60e | ||
|
|
4fa0eb0a87 | ||
|
|
295eb57922 | ||
|
|
2179cf57b6 | ||
|
|
cca0f2d5db | ||
|
|
d3c3ffb88d | ||
|
|
10c7196f9f | ||
|
|
254def6152 | ||
|
|
09bae2565c | ||
|
|
5572918750 | ||
|
|
4e91db09a6 | ||
|
|
bbaee20097 | ||
|
|
7554e2b03e | ||
|
|
3baac1138f | ||
|
|
8815cef66c | ||
|
|
afa836c6df | ||
|
|
bf513508e4 | ||
|
|
a0423f301d | ||
|
|
302fa8f4dd | ||
|
|
4a75a22a6c | ||
|
|
c7d5cf1cc2 | ||
|
|
e031ca3ef4 | ||
|
|
8e0e926a68 | ||
|
|
d4ed634c4e | ||
|
|
18582f1a2d | ||
|
|
3688b128e4 | ||
|
|
4718b75f89 | ||
|
|
aa7f8f79a5 | ||
|
|
c794a6aaf8 | ||
|
|
0786a45995 | ||
|
|
66fde65550 | ||
|
|
caf56aca94 | ||
|
|
06581692e6 | ||
|
|
ca2fbd9fdd | ||
|
|
81ce6c9950 | ||
|
|
31b67f2217 | ||
|
|
0ee76d39b2 | ||
|
|
6a3f4816d5 | ||
|
|
1365754d62 | ||
|
|
ac4c98bead | ||
|
|
74306719ca | ||
|
|
cc028dd37f | ||
|
|
43184fff6d | ||
|
|
d41f94f254 | ||
|
|
fb741a4e54 | ||
|
|
4319dfd0fe | ||
|
|
a06a0e893c | ||
|
|
8517092a88 | ||
|
|
52e79dfaa7 | ||
|
|
7fb19fd032 | ||
|
|
6344657940 | ||
|
|
3f01a99b66 | ||
|
|
69d7350e89 | ||
|
|
9ee502f437 | ||
|
|
b8b876535b | ||
|
|
816edefe25 | ||
|
|
c523ff608e | ||
|
|
663f14c439 | ||
|
|
20c6ef6702 | ||
|
|
91241f045f | ||
|
|
82e5c54cdc | ||
|
|
4b17102e5a | ||
|
|
bc58324a56 | ||
|
|
b43bcf244a | ||
|
|
befe1a0bdc | ||
|
|
1aae5433f3 | ||
|
|
afeb12686b | ||
|
|
75f4b94b26 | ||
|
|
4a867904ad | ||
|
|
249b161243 | ||
|
|
a709a3268a | ||
|
|
50b8a06f63 | ||
|
|
29e6338b41 | ||
|
|
82e9fd7f2e | ||
|
|
8eb41eec53 | ||
|
|
07f96019b1 | ||
|
|
1a0d4cb8cd | ||
|
|
5ae34047ee | ||
|
|
260181ec7c | ||
|
|
21bc826eda | ||
|
|
e1d9125a5c | ||
|
|
8d1b836f59 | ||
|
|
bd50399483 | ||
|
|
d641d25392 | ||
|
|
a8c9f41fce | ||
|
|
2ff9d84584 | ||
|
|
11cd61cd44 | ||
|
|
cf43988fae | ||
|
|
4ef27e3027 | ||
|
|
5ffabb387b | ||
|
|
d78a45b7e5 | ||
|
|
bd5a9c382d | ||
|
|
e238e829ad | ||
|
|
689c20d367 | ||
|
|
43588df2b4 | ||
|
|
d2aaddea33 | ||
|
|
de233f8ae5 | ||
|
|
3179e74dc7 | ||
|
|
6f5e9f5d49 | ||
|
|
8a43fdf200 | ||
|
|
69051e5bbb | ||
|
|
c648e7e0ec | ||
|
|
c91cf376ad | ||
|
|
f4e07597d0 | ||
|
|
64b64d3340 | ||
|
|
57ea87871e | ||
|
|
6154d80bb9 | ||
|
|
ad85745f85 | ||
|
|
41fc77322c | ||
|
|
1b83c26417 | ||
|
|
10ea5f0b93 | ||
|
|
032684de68 | ||
|
|
3efec5ce58 | ||
|
|
4ed32964d9 | ||
|
|
05c1d0cc6e | ||
|
|
3c54924971 | ||
|
|
e57dfc0e3f | ||
|
|
eff8bb16a0 | ||
|
|
87e681b71e | ||
|
|
ac73ef4ea7 | ||
|
|
20945e7443 | ||
|
|
b7b1278111 | ||
|
|
6f9378404f | ||
|
|
79787aca6e |
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
|
||||
os: [ "ubuntu-24.04", "ubuntu-22.04-arm", "windows-latest" ]
|
||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -29,14 +29,14 @@ jobs:
|
||||
|
||||
# Setup
|
||||
- name: Set up GraalVM
|
||||
uses: graalvm/setup-graalvm@5298d94fb55a4f185c602eeac5de1b553882abe2 # v1.6.4
|
||||
uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: "graalvm"
|
||||
distribution: "mandrel"
|
||||
cache: "maven"
|
||||
|
||||
- name: Set up NodeJS cache
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: 'commafeed-client/package-lock.json'
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
# Build & Test
|
||||
- name: Build with Maven
|
||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipITs=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||
|
||||
# Build pages
|
||||
- name: Create pages directory structure
|
||||
@@ -73,21 +73,21 @@ jobs:
|
||||
|
||||
# Upload artifacts
|
||||
- name: Upload cross-platform app
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: matrix.os == 'ubuntu-24.04' # we only need to upload the cross-platform artifact once per database
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-jvm
|
||||
path: commafeed-server/target/commafeed-*.zip
|
||||
|
||||
- name: Upload native executable
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: commafeed-server/target/commafeed-*-runner*
|
||||
|
||||
- name: Upload pages
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.database == 'h2' # we only need to upload the pages once
|
||||
with:
|
||||
path: target/pages
|
||||
|
||||
@@ -110,17 +110,17 @@ jobs:
|
||||
|
||||
# Setup
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Install required packages
|
||||
run: sudo apt-get install -y rename unzip
|
||||
|
||||
# Prepare artifacts
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: commafeed-${{ matrix.database }}-*
|
||||
path: ./artifacts
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
|
||||
# Docker
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
|
||||
## build but don't push for PRs and renovate
|
||||
- name: Docker build - native
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
|
||||
- name: Docker build - jvm
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
## build and push tag
|
||||
- name: Docker build and push tag - native
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push tag - jvm
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
## build and push master
|
||||
- name: Docker build and push master - native
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
tags: athou/commafeed:master-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push master - jvm
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: commafeed-*
|
||||
path: ./artifacts
|
||||
@@ -280,5 +280,5 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
id: deployment
|
||||
|
||||
2
.mvn/wrapper/maven-wrapper.properties
vendored
2
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,3 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip
|
||||
|
||||
131
CHANGELOG.md
131
CHANGELOG.md
@@ -1,23 +1,44 @@
|
||||
# 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]
|
||||
|
||||
- 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
|
||||
(GHSA-hp8h-jqfm-v7x5)
|
||||
emails. This setting is only required when the password recovery feature is enabled, which is not the default.
|
||||
|
||||
## [7.3.0]
|
||||
|
||||
- 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
|
||||
- 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
|
||||
local address blocked" section of the README if you subscribe to feeds that are only available on your local network.
|
||||
|
||||
## [7.2.1]
|
||||
|
||||
- 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]
|
||||
|
||||
@@ -27,26 +48,36 @@
|
||||
- Small performance improvements to the feed refresh engine
|
||||
- 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
|
||||
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges
|
||||
- Prevent DDOS attacks by filtering OPML files during imports
|
||||
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges (GHSA-hgrr-mjfp-gmr6)
|
||||
- Prevent DDOS attacks by filtering OPML files during imports (GHSA-83jp-rww3-57rr)
|
||||
|
||||
## [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)
|
||||
- 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]
|
||||
|
||||
- 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.
|
||||
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are discovered (#1610)
|
||||
- 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.
|
||||
- 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)
|
||||
- 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.
|
||||
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites redirecting to local ones.
|
||||
- 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.
|
||||
- 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]
|
||||
|
||||
- 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]
|
||||
|
||||
@@ -54,16 +85,21 @@
|
||||
|
||||
## [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)
|
||||
- The profile menu is now closed when scrolling the page (#2019)
|
||||
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
||||
|
||||
## [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
|
||||
- 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)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## [5.12.1]
|
||||
@@ -93,15 +129,18 @@
|
||||
|
||||
## [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
|
||||
- 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]
|
||||
|
||||
- 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)
|
||||
|
||||
## [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)
|
||||
- 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)
|
||||
- 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]
|
||||
|
||||
@@ -129,14 +169,22 @@
|
||||
|
||||
## [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)
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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]
|
||||
|
||||
- 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
|
||||
- Fix an issue with some labels not correctly internationalized
|
||||
|
||||
@@ -153,8 +201,10 @@
|
||||
## [5.3.5]
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age
|
||||
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]
|
||||
|
||||
@@ -181,19 +231,22 @@
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
## [5.1.1]
|
||||
|
||||
- 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]
|
||||
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
## [5.0.2]
|
||||
@@ -208,9 +261,9 @@
|
||||
## [5.0.0]
|
||||
|
||||
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 gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
|
||||
0.3s) and very low memory footprint (< 50M).
|
||||
the [announcement](https://github.com/Athou/commafeed/discussions/1517). The gist of it is that CommaFeed can now be
|
||||
compiled to a native binary, resulting in blazing fast startup times (around 0.3s) and very low memory footprint (<
|
||||
50M).
|
||||
|
||||
- CommaFeed now has a different package for each supported database.
|
||||
- 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 two options in the settings to toggle those buttons
|
||||
- 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 (
|
||||
older than `keepStatusDays`) (#1303)
|
||||
- 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)
|
||||
|
||||
## [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)
|
||||
- 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)
|
||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
||||
to 365 days
|
||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. The setting is
|
||||
disabled by default for existing installations, except for the docker image where it is enabled and set to 365 days
|
||||
- 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)
|
||||
- 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
|
||||
- updated UI library Mantine to 7.0, improving performance
|
||||
- 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
|
||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is recommended
|
||||
(see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||
- 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
|
||||
- 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]
|
||||
|
||||
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
||||
consumption and better overall performances.
|
||||
See the README on how to build CommaFeed from now on.
|
||||
consumption and better overall performances. See the README on how to build CommaFeed from now on.
|
||||
- CommaFeed should no longer fetch the same feed multiple times in a row
|
||||
- Users can use their username or email to log in
|
||||
|
||||
14
README.md
14
README.md
@@ -167,6 +167,20 @@ CommaFeed blocks access to local resources by default to prevent [SSRF](https://
|
||||
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `commafeed.http-client.block-local-addresses` variable to `false`.
|
||||
Do this only if you trust all users of your CommaFeed instance not to access private resources.
|
||||
|
||||
### Listen on a single network interface
|
||||
|
||||
By default, CommaFeed listens on all interfaces. You can restrict it by setting `quarkus.http.host`.
|
||||
|
||||
Note that if you set it to a local name like `127.0.0.1` host validation is enabled automatically. This prevents
|
||||
access if you're using a reverse proxy like Nginx. To fix, add your actual hostname to `allowed-hosts`:
|
||||
|
||||
```
|
||||
quarkus.http.host=127.0.0.1
|
||||
quarkus.http.proxy.proxy-address-forwarding=true
|
||||
quarkus.http.proxy.allow-forwarded=true
|
||||
quarkus.http.host-validation.allowed-hosts=commafeed.example.com
|
||||
```
|
||||
|
||||
## Translation
|
||||
|
||||
Files for internationalization are
|
||||
|
||||
1
commafeed-client/.npmrc
Normal file
1
commafeed-client/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
loglevel=warn
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
|
||||
1981
commafeed-client/package-lock.json
generated
1981
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,35 +17,35 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@fontsource/open-sans": "5.3.0",
|
||||
"@lingui/core": "6.6.0",
|
||||
"@lingui/react": "6.6.0",
|
||||
"@mantine/core": "9.5.1",
|
||||
"@mantine/dates": "9.5.1",
|
||||
"@mantine/form": "9.5.1",
|
||||
"@mantine/hooks": "9.5.1",
|
||||
"@mantine/modals": "9.5.1",
|
||||
"@mantine/notifications": "9.5.1",
|
||||
"@mantine/spotlight": "9.5.1",
|
||||
"@lingui/core": "6.11.0",
|
||||
"@lingui/react": "6.11.0",
|
||||
"@mantine/core": "9.7.4",
|
||||
"@mantine/dates": "9.7.4",
|
||||
"@mantine/form": "9.7.4",
|
||||
"@mantine/hooks": "9.7.4",
|
||||
"@mantine/modals": "9.7.4",
|
||||
"@mantine/notifications": "9.7.4",
|
||||
"@mantine/spotlight": "9.7.4",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@react-querybuilder/mantine": "8.22.5",
|
||||
"@reduxjs/toolkit": "2.12.0",
|
||||
"@rolldown/plugin-babel": "0.2.3",
|
||||
"axios": "1.19.0",
|
||||
"dayjs": "1.11.21",
|
||||
"@react-querybuilder/mantine": "8.26.0",
|
||||
"@reduxjs/toolkit": "2.13.0",
|
||||
"@rolldown/plugin-babel": "0.3.0",
|
||||
"axios": "1.21.0",
|
||||
"dayjs": "1.11.23",
|
||||
"escape-string-regexp": "5.0.0",
|
||||
"interweave": "13.1.1",
|
||||
"monaco-editor": "0.56.0",
|
||||
"monaco-editor": "0.57.0",
|
||||
"mousetrap": "1.6.5",
|
||||
"react": "19.2.8",
|
||||
"react": "19.3.1",
|
||||
"react-async-hook": "4.0.0",
|
||||
"react-contexify": "6.0.0",
|
||||
"react-dom": "19.2.8",
|
||||
"react-draggable": "4.7.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-draggable": "4.7.2",
|
||||
"react-icons": "5.7.0",
|
||||
"react-infinite-scroller": "1.2.6",
|
||||
"react-querybuilder": "8.22.5",
|
||||
"react-redux": "9.3.0",
|
||||
"react-router-dom": "7.18.2",
|
||||
"react-querybuilder": "8.26.0",
|
||||
"react-redux": "9.3.1",
|
||||
"react-router-dom": "7.18.5",
|
||||
"react-swipeable": "7.0.2",
|
||||
"style-to-object": "2.0.2",
|
||||
"throttle-debounce": "5.0.2",
|
||||
@@ -54,32 +54,30 @@
|
||||
"websocket-heartbeat-js": "1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@biomejs/biome": "2.5.8",
|
||||
"@lingui/babel-plugin-lingui-macro": "6.6.0",
|
||||
"@lingui/cli": "6.6.0",
|
||||
"@lingui/vite-plugin": "6.6.0",
|
||||
"@biomejs/biome": "2.6.4",
|
||||
"@lingui/cli": "6.11.0",
|
||||
"@lingui/vite-plugin": "6.11.0",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.4",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.8",
|
||||
"@types/mousetrap": "1.6.15",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/react-infinite-scroller": "1.2.5",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycon": "0.6.7",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"jsdom": "30.0.1",
|
||||
"@vitejs/plugin-react": "6.1.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"jsdom": "30.1.3",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.1",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-checker": "0.14.5",
|
||||
"vitest": "4.1.10",
|
||||
"yaml": "2.9.0"
|
||||
"vitest": "5.0.5",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
"overrides": {
|
||||
"react-infinite-scroller": {
|
||||
"react": "19.2.8"
|
||||
"react": "19.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>7.3.1</version>
|
||||
<version>7.4.1</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<name>CommaFeed Client</name>
|
||||
|
||||
<properties>
|
||||
<!-- renovate: datasource=node-version depName=node -->
|
||||
<node.version>v24.19.0</node.version>
|
||||
<node.version>v26.13.0</node.version>
|
||||
<!-- renovate: datasource=npm depName=npm -->
|
||||
<npm.version>12.0.2</npm.version>
|
||||
<npm.version>12.2.1</npm.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
@@ -110,7 +110,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.6.3</version>
|
||||
<version>3.6.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>kill-biome</id>
|
||||
|
||||
36
commafeed-client/src/app/client.test.ts
Normal file
36
commafeed-client/src/app/client.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n, type MessageDescriptor } from "@lingui/core"
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import axios, { type AxiosError } from "axios"
|
||||
import type {
|
||||
AddCategoryRequest,
|
||||
@@ -6,6 +8,8 @@ import type {
|
||||
Category,
|
||||
CategoryModificationRequest,
|
||||
CollapseRequest,
|
||||
CommaFeedApplicationError,
|
||||
CommaFeedExceptionType,
|
||||
Entries,
|
||||
FeedInfo,
|
||||
FeedInfoRequest,
|
||||
@@ -31,6 +35,10 @@ import type {
|
||||
UserModel,
|
||||
} 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 })
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
@@ -128,14 +136,25 @@ export const errorToStrings = (err: unknown) => {
|
||||
let strings: string[] = []
|
||||
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
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]
|
||||
if (isCommaFeedApplicationError(err)) {
|
||||
strings.push(i18n._(applicationErrorMessages[err.response.data.type]))
|
||||
} 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
|
||||
}
|
||||
|
||||
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 }> {
|
||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||
}
|
||||
|
||||
@@ -116,6 +116,30 @@ describe("entries", () => {
|
||||
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||
})
|
||||
|
||||
it("flags an entry marked as unread by the user until entries are reloaded", async () => {
|
||||
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "3", read: true, markable: true } as Entry],
|
||||
hasMore: false,
|
||||
name: "my-feed",
|
||||
errorCount: 0,
|
||||
feedLink: "https://mysite.com/feed",
|
||||
timestamp: 123,
|
||||
ignoredReadStatus: false,
|
||||
},
|
||||
} as AxiosResponse<Entries>)
|
||||
|
||||
const store = configureStore({ reducer: reducers })
|
||||
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||
|
||||
store.dispatch(markEntry({ entry: store.getState().entries.entries[0], read: false }))
|
||||
expect(store.getState().entries.entries[0].keepUnread).toBe(true)
|
||||
|
||||
// reloading the entry list resets the flag
|
||||
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: false }))
|
||||
expect(store.getState().entries.entries[0].keepUnread).toBeUndefined()
|
||||
})
|
||||
|
||||
it("marks all entries as read", () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
|
||||
@@ -10,7 +10,11 @@ export interface EntrySource {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||
export type ExpendableEntry = Entry & {
|
||||
expanded?: boolean
|
||||
/** set when the user explicitly marks the entry as unread, prevents it from being marked as read again by scrolling in expanded mode until the entry list is reloaded */
|
||||
keepUnread?: boolean
|
||||
}
|
||||
|
||||
interface EntriesState {
|
||||
/** selected source */
|
||||
@@ -71,11 +75,13 @@ export const entriesSlice = createSlice({
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
||||
e.read = action.meta.arg.read
|
||||
if (!action.meta.arg.read) e.keepUnread = true
|
||||
}
|
||||
})
|
||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
||||
e.read = action.meta.arg.read
|
||||
if (!action.meta.arg.read) e.keepUnread = true
|
||||
}
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||
|
||||
@@ -40,7 +40,7 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
||||
const state = thunkApi.getState()
|
||||
const { source } = state.entries
|
||||
const offset =
|
||||
state.user.settings?.readingMode === "all" || (source.type === "category" && source.id === "starred")
|
||||
state.user.settings?.readingMode === "all" || state.entries.search || (source.type === "category" && source.id === "starred")
|
||||
? state.entries.entries.length
|
||||
: state.entries.entries.filter(e => !e.read).length
|
||||
const endpoint = getEndpoint(state.entries.source.type)
|
||||
@@ -137,6 +137,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
||||
const state = thunkApi.getState()
|
||||
const source = state.entries.source
|
||||
const entriesTimestamp = state.entries.timestamp ?? Date.now()
|
||||
const search = state.entries.search
|
||||
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
|
||||
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||
|
||||
@@ -151,6 +152,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
||||
read: true,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
keywords: search,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -166,7 +168,6 @@ export const starEntry = createAppAsyncThunk(
|
||||
(arg: { entry: Entry; starred: boolean }) => {
|
||||
client.entry.star({
|
||||
id: arg.entry.id,
|
||||
feedId: +arg.entry.feedId,
|
||||
starred: arg.starred,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -297,7 +297,6 @@ export interface LocalSettings {
|
||||
|
||||
export interface StarRequest {
|
||||
id: string
|
||||
feedId: number
|
||||
starred: boolean
|
||||
}
|
||||
|
||||
@@ -338,3 +337,10 @@ export interface AuthenticationError {
|
||||
message: string
|
||||
allowRegistrations: boolean
|
||||
}
|
||||
|
||||
export type CommaFeedExceptionType = "WRONG_USERNAME_OR_PASSWORD"
|
||||
|
||||
export interface CommaFeedApplicationError {
|
||||
type: CommaFeedExceptionType
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useActionButton } from "@/hooks/useActionButton"
|
||||
|
||||
export type Mode = "auto" | "mobile" | "desktop"
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: ReactNode
|
||||
className?: string
|
||||
label?: string | MessageDescriptor
|
||||
onClick?: MouseEventHandler
|
||||
variant?: ActionIconVariant & ButtonVariant
|
||||
hideLabelOnDesktop?: boolean
|
||||
showLabelOnMobile?: boolean
|
||||
mode?: Mode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,9 @@ export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props
|
||||
|
||||
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
||||
const variant = props.variant ?? "subtle"
|
||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
||||
|
||||
const mode: Mode = props.mode ?? "auto"
|
||||
const iconOnly = mode === "mobile" || (mode === "auto" && mobile)
|
||||
|
||||
return (
|
||||
<Box ref={ref} className="cf-action-button">
|
||||
|
||||
@@ -13,6 +13,7 @@ export function MarkAllAsReadConfirmationDialog() {
|
||||
const source = useAppSelector(state => state.entries.source)
|
||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
||||
const search = useAppSelector(state => state.entries.search)
|
||||
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
@@ -26,6 +27,7 @@ export function MarkAllAsReadConfirmationDialog() {
|
||||
read: true,
|
||||
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
||||
insertedBefore: entriesTimestamp,
|
||||
keywords: search,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -111,12 +111,13 @@ export function FeedEntries() {
|
||||
const el = document.getElementById(Constants.dom.entryId(e))
|
||||
return el && !Constants.layout.isTopVisible(el)
|
||||
})
|
||||
if (currentEntry) {
|
||||
if (currentEntry && currentEntry.id !== selectedEntryId) {
|
||||
dispatch(
|
||||
selectEntry({
|
||||
entry: currentEntry,
|
||||
expand: false,
|
||||
markAsRead: !!scrollMarks,
|
||||
// don't mark as read entries that the user explicitly marked as unread
|
||||
markAsRead: !!scrollMarks && !currentEntry.keepUnread,
|
||||
scrollToEntry: false,
|
||||
})
|
||||
)
|
||||
@@ -124,7 +125,7 @@ export function FeedEntries() {
|
||||
})
|
||||
window.addEventListener("scroll", listener)
|
||||
return () => window.removeEventListener("scroll", listener)
|
||||
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
|
||||
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry, selectedEntryId])
|
||||
|
||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||
useMousetrap(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { useEffect } from "react"
|
||||
import { type ReactNode, useEffect } from "react"
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
@@ -31,7 +31,7 @@ function HeaderDivider() {
|
||||
return <Divider orientation="vertical" />
|
||||
}
|
||||
|
||||
function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||
function HeaderToolbar(props: { children: ReactNode }) {
|
||||
const { spacing } = useActionButton()
|
||||
const mobile = useMobile("480px")
|
||||
return mobile ? (
|
||||
@@ -47,7 +47,7 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||
{props.children}
|
||||
</Box>
|
||||
) : (
|
||||
<Group gap={spacing} className="cf-toolbar">
|
||||
<Group gap={spacing} wrap="nowrap" className="cf-toolbar">
|
||||
{props.children}
|
||||
</Group>
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
msgstr "مرحباً! يبدو أن هذه هي المرة الأولى التي تقوم فيها بتشغيل CommaFeed. يرجى إنشاء حساب مسؤول للبدء."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "أصفر"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Groc"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Žlutá"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Melyn"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Gelb"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Yellow"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Amarillo"
|
||||
|
||||
@@ -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."
|
||||
msgstr "خوش آمدید! به نظر میرسد این اولین بار است که CommaFeed را اجرا میکنید. لطفاً برای شروع یک حساب مدیر ایجاد کنید."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "زرد"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Keltainen"
|
||||
|
||||
@@ -460,11 +460,11 @@ msgstr "Extensions"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API"
|
||||
msgstr ""
|
||||
msgstr "API Google Reader"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Google Reader API URL"
|
||||
msgstr ""
|
||||
msgstr "URL de l'API Google Reader"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Jaune"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Amarelo"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Sárga"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Kuning"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Giallo"
|
||||
|
||||
@@ -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."
|
||||
msgstr "ようこそ!CommaFeedを初めて実行するようです。開始するには管理者アカウントを作成してください。"
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "イエロー"
|
||||
|
||||
@@ -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."
|
||||
msgstr "환영합니다! CommaFeed를 처음 실행하는 것 같습니다. 시작하려면 관리자 계정을 만드십시오."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "옐로우"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Kuning"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Geel"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Żółty"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Amarelo"
|
||||
|
||||
@@ -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."
|
||||
msgstr "Добро пожаловать! Похоже, вы запускаете CommaFeed в первый раз. Пожалуйста, создайте учетную запись администратора, чтобы начать работу."
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "Желтый"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Žltá"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Gul"
|
||||
|
||||
@@ -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."
|
||||
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
|
||||
msgid "Yellow"
|
||||
msgstr "Sarı"
|
||||
|
||||
@@ -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."
|
||||
msgstr "欢迎!当前页仅当您第一次使用CommaFeed时出现,请创建一个管理员帐号以开始使用。"
|
||||
|
||||
#: src/app/client.ts
|
||||
msgid "Wrong username or password"
|
||||
msgstr "用户名或密码错误"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "黄"
|
||||
|
||||
@@ -42,7 +42,7 @@ export function WelcomePage() {
|
||||
icon={<TbClock size={iconSize} />}
|
||||
variant="outline"
|
||||
onClick={async () => await login.execute({ name: "demo", password: "demo" })}
|
||||
showLabelOnMobile
|
||||
mode="desktop"
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
@@ -100,7 +100,7 @@ function Buttons() {
|
||||
icon={<TbKey size={iconSize} />}
|
||||
variant="outline"
|
||||
onClick={async () => await dispatch(redirectToLogin())}
|
||||
showLabelOnMobile
|
||||
mode="desktop"
|
||||
/>
|
||||
{serverInfos?.allowRegistrations && (
|
||||
<ActionButton
|
||||
@@ -108,7 +108,7 @@ function Buttons() {
|
||||
icon={<TbUserPlus size={iconSize} />}
|
||||
variant="filled"
|
||||
onClick={async () => await dispatch(redirectToRegistration())}
|
||||
showLabelOnMobile
|
||||
mode="desktop"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -116,7 +116,7 @@ function Buttons() {
|
||||
label={dark ? msg`Switch to light theme` : msg`Switch to dark theme`}
|
||||
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
|
||||
onClick={() => toggleColorScheme()}
|
||||
hideLabelOnDesktop
|
||||
mode="mobile"
|
||||
/>
|
||||
|
||||
{isBrowserExtensionPopup && (
|
||||
@@ -124,7 +124,7 @@ function Buttons() {
|
||||
label={msg`Extension options`}
|
||||
icon={<TbSettings size={iconSize} />}
|
||||
onClick={() => openSettingsPage()}
|
||||
hideLabelOnDesktop
|
||||
mode="mobile"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -37,6 +37,9 @@ export function CategoryDetailsPage() {
|
||||
category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
|
||||
}
|
||||
|
||||
// a category cannot be moved under itself or one of its own descendants
|
||||
const excludedCategoryIds = category ? flattenCategoryTree(category).map(c => c.id) : [id]
|
||||
|
||||
const form = useForm<CategoryModificationRequest>()
|
||||
const { setValues } = form
|
||||
|
||||
@@ -125,7 +128,7 @@ export function CategoryDetailsPage() {
|
||||
label={<Trans>Parent Category</Trans>}
|
||||
{...form.getInputProps("parentId")}
|
||||
clearable
|
||||
withoutCategoryIds={[id]}
|
||||
withoutCategoryIds={excludedCategoryIds}
|
||||
/>
|
||||
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro"
|
||||
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
|
||||
import { useViewportSize } from "@mantine/hooks"
|
||||
import { useEffect } from "react"
|
||||
import { TbEdit } from "react-icons/tb"
|
||||
import { TbSettings } from "react-icons/tb"
|
||||
import { useLocation, useParams } from "react-router-dom"
|
||||
import { Constants } from "@/app/constants"
|
||||
import type { EntrySourceType } from "@/app/entries/slice"
|
||||
@@ -138,7 +138,7 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
||||
</Title>
|
||||
)}
|
||||
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
|
||||
<TbEdit size={18} />
|
||||
<TbSettings size={18} />
|
||||
</ActionIcon>
|
||||
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
|
||||
</Group>
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function Layout(props: Readonly<LayoutProps>) {
|
||||
)}
|
||||
</OnMobile>
|
||||
<OnDesktop>
|
||||
<Group p="md">
|
||||
<Group p="md" wrap="nowrap">
|
||||
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
|
||||
<Box>
|
||||
<LogoAndTitle />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "@testing-library/jest-dom"
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { vi } from "vitest"
|
||||
import { Constants } from "@/app/constants"
|
||||
|
||||
|
||||
@@ -7,11 +7,8 @@ import checker from "vite-plugin-checker"
|
||||
export default defineConfig(() => ({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({
|
||||
presets: [reactCompilerPreset()],
|
||||
plugins: ["@lingui/babel-plugin-lingui-macro"],
|
||||
}),
|
||||
lingui(),
|
||||
lingui({ macroTransform: true }),
|
||||
babel({ presets: [reactCompilerPreset()] }),
|
||||
checker({
|
||||
// temporary disabled until TypeScript 7 exposes a stable api
|
||||
// typescript: true,
|
||||
@@ -44,6 +41,7 @@ export default defineConfig(() => ({
|
||||
inconsistentCjsInterop: true,
|
||||
},
|
||||
test: {
|
||||
isolate: false,
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
@@ -51,6 +49,9 @@ export default defineConfig(() => ({
|
||||
build: {
|
||||
chunkSizeWarningLimit: 4000,
|
||||
rolldownOptions: {
|
||||
checks: {
|
||||
pluginTimings: false,
|
||||
},
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>7.3.1</version>
|
||||
<version>7.4.1</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-server</artifactId>
|
||||
<name>CommaFeed Server</name>
|
||||
<packaging>quarkus</packaging>
|
||||
|
||||
<properties>
|
||||
<quarkus.version>3.38.2</quarkus.version>
|
||||
<querydsl.version>7.5</querydsl.version>
|
||||
<quarkus.version>3.40.1.1</quarkus.version>
|
||||
<querydsl.version>7.7</querydsl.version>
|
||||
<rome.version>2.1.0</rome.version>
|
||||
<!-- renovate: depName=com.google.googlejavaformat:google-java-format -->
|
||||
<google-java-format.version>1.36.1</google-java-format.version>
|
||||
<google-java-format.version>2.0.0</google-java-format.version>
|
||||
|
||||
<build.database>h2</build.database>
|
||||
</properties>
|
||||
@@ -35,7 +35,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.protobuf</groupId>
|
||||
<artifactId>protobuf-java</artifactId>
|
||||
<version>4.35.1</version>
|
||||
<version>4.36.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
@@ -64,7 +64,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>properties-maven-plugin</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<version>1.3.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -116,7 +116,7 @@
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.6.3</version>
|
||||
<version>3.6.4</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
@@ -164,7 +164,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<version>3.6.1</version>
|
||||
<configuration>
|
||||
<argLine>@{argLine}</argLine>
|
||||
<systemPropertyVariables>
|
||||
@@ -176,7 +176,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -221,7 +221,7 @@
|
||||
<plugin>
|
||||
<groupId>io.github.git-commit-id</groupId>
|
||||
<artifactId>git-commit-id-maven-plugin</artifactId>
|
||||
<version>10.0.0</version>
|
||||
<version>10.0.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -245,7 +245,7 @@
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>13.11.0</version>
|
||||
<version>14.4.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
@@ -274,7 +274,7 @@
|
||||
<plugin>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
<version>3.10.0</version>
|
||||
<version>3.10.4</version>
|
||||
<?m2e ignore?>
|
||||
<executions>
|
||||
<execution>
|
||||
@@ -302,14 +302,14 @@
|
||||
<dependency>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<version>7.3.1</version>
|
||||
<version>7.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- compile-time processors -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.46</version>
|
||||
<version>1.18.48</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.kohsuke.metainf-services</groupId>
|
||||
@@ -322,6 +322,11 @@
|
||||
<artifactId>quarkus-extension-processor</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.graalvm.sdk</groupId>
|
||||
<artifactId>nativeimage</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- quarkus dependencies -->
|
||||
<dependency>
|
||||
@@ -368,7 +373,7 @@
|
||||
<dependency>
|
||||
<groupId>io.dropwizard.metrics</groupId>
|
||||
<artifactId>metrics-json</artifactId>
|
||||
<version>4.2.39</version>
|
||||
<version>4.2.40</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign.querydsl</groupId>
|
||||
@@ -399,7 +404,7 @@
|
||||
<dependency>
|
||||
<groupId>dev.cel</groupId>
|
||||
<artifactId>cel</artifactId>
|
||||
<version>0.13.1</version>
|
||||
<version>0.15.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.passay</groupId>
|
||||
@@ -429,12 +434,12 @@
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.23.1</version>
|
||||
<version>1.23.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ibm.icu</groupId>
|
||||
<artifactId>icu4j</artifactId>
|
||||
<version>78.3</version>
|
||||
<version>79.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.cssparser</groupId>
|
||||
@@ -449,17 +454,12 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
<version>5.6.4</version>
|
||||
<version>5.6.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.hakky54</groupId>
|
||||
<artifactId>ayza-for-apache5</artifactId>
|
||||
<version>10.0.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.brotli</groupId>
|
||||
<artifactId>dec</artifactId>
|
||||
<version>0.1.2</version>
|
||||
<version>10.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.seancfoley</groupId>
|
||||
@@ -476,13 +476,13 @@
|
||||
<dependency>
|
||||
<groupId>io.quarkiverse.playwright</groupId>
|
||||
<artifactId>quarkus-playwright</artifactId>
|
||||
<version>2.3.8</version>
|
||||
<version>2.3.10</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mock-server</groupId>
|
||||
<artifactId>mockserver-junit-jupiter</artifactId>
|
||||
<version>7.5.0</version>
|
||||
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
|
||||
<version>9.2.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -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
|
||||
|
||||
RUN mkdir -p /commafeed/data
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
FROM debian:13.6@sha256:34cd9e9fd437c0a095ec39cb2e73422c9f30821b0d0848ed74fd0d43bae4d958
|
||||
FROM debian:13.7@sha256:2dc419c6d7fe3dad0a2f9fd469c34cc90b0f9ba642d8fde837fe3d7d34a0e7f1
|
||||
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.commafeed;
|
||||
|
||||
import com.commafeed.frontend.exception.CommaFeedApplicationException;
|
||||
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||
import com.commafeed.security.CookieService;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
@@ -26,6 +28,18 @@ public class ExceptionMappers {
|
||||
private final CookieService cookieService;
|
||||
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)
|
||||
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
|
||||
return RestResponse.status(
|
||||
@@ -55,4 +69,7 @@ public class ExceptionMappers {
|
||||
|
||||
@RegisterForReflection
|
||||
public record ValidationFailed(String message) {}
|
||||
|
||||
@RegisterForReflection
|
||||
public record CommaFeedApplicationError(CommaFeedExceptionType type, String message) {}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.codahale.metrics.Histogram;
|
||||
import com.codahale.metrics.Meter;
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
import com.codahale.metrics.Timer;
|
||||
import com.oracle.svm.core.annotate.Substitute;
|
||||
import com.oracle.svm.core.annotate.TargetClass;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
@@ -346,4 +348,22 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
com.rometools.opml.feed.synd.impl.ConverterForOPML10.class,
|
||||
com.rometools.opml.feed.synd.impl.ConverterForOPML20.class,
|
||||
})
|
||||
public class NativeImageClasses {}
|
||||
public class NativeImageClasses {
|
||||
|
||||
// disable zstd and brotli runtime detection because it causes native image failure
|
||||
@TargetClass(className = "org.apache.hc.client5.http.impl.ZstdRuntime")
|
||||
static final class ZstdRuntime {
|
||||
@Substitute
|
||||
public static boolean available() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@TargetClass(className = "org.apache.hc.client5.http.impl.Brotli4jRuntime")
|
||||
static final class Brotli4jRuntime {
|
||||
@Substitute
|
||||
public static boolean available() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,27 +19,27 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils;
|
||||
|
||||
import org.apache.hc.client5.http.DnsResolver;
|
||||
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
||||
import org.apache.hc.client5.http.async.methods.InflatingAsyncDataConsumer;
|
||||
import org.apache.hc.client5.http.async.methods.InflatingGzipDataConsumer;
|
||||
import org.apache.hc.client5.http.config.ConnectionConfig;
|
||||
import org.apache.hc.client5.http.config.TlsConfig;
|
||||
import org.apache.hc.client5.http.entity.DeflateInputStream;
|
||||
import org.apache.hc.client5.http.entity.InputStreamFactory;
|
||||
import org.apache.hc.client5.http.entity.compress.ContentCoding;
|
||||
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
|
||||
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
|
||||
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
|
||||
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.protocol.RedirectStrategy;
|
||||
import org.apache.hc.core5.http.Header;
|
||||
import org.apache.hc.core5.http.HttpException;
|
||||
import org.apache.hc.core5.http.HttpRequest;
|
||||
import org.apache.hc.core5.http.HttpResponse;
|
||||
import org.apache.hc.core5.http.message.BasicHeader;
|
||||
import org.apache.hc.core5.http.nio.AsyncDataConsumer;
|
||||
import org.apache.hc.core5.http.protocol.HttpContext;
|
||||
import org.apache.hc.core5.http2.HttpVersionPolicy;
|
||||
import org.apache.hc.core5.util.TimeValue;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.brotli.dec.BrotliInputStream;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
@@ -48,7 +48,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.SequencedMap;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
@Singleton
|
||||
@RequiredArgsConstructor
|
||||
@@ -60,56 +60,61 @@ public class HttpClientFactory {
|
||||
private final CommaFeedConfiguration config;
|
||||
private final CommaFeedVersion version;
|
||||
|
||||
public CloseableHttpClient newClient(int poolSize) {
|
||||
PoolingHttpClientConnectionManager connectionManager =
|
||||
public CloseableHttpAsyncClient newClient(int poolSize) {
|
||||
PoolingAsyncClientConnectionManager connectionManager =
|
||||
newConnectionManager(config, poolSize);
|
||||
String userAgent =
|
||||
config.httpClient()
|
||||
.userAgent()
|
||||
.orElseGet(
|
||||
() ->
|
||||
String.format(
|
||||
"CommaFeed/%s (https://github.com/Athou/commafeed)",
|
||||
version.getVersion()));
|
||||
.orElseGet(() ->
|
||||
String.format(
|
||||
"CommaFeed/%s (https://github.com/Athou/commafeed)",
|
||||
version.getVersion()));
|
||||
return newClient(config, connectionManager, userAgent);
|
||||
}
|
||||
|
||||
private CloseableHttpClient newClient(
|
||||
private CloseableHttpAsyncClient newClient(
|
||||
CommaFeedConfiguration config,
|
||||
HttpClientConnectionManager connectionManager,
|
||||
PoolingAsyncClientConnectionManager connectionManager,
|
||||
String userAgent) {
|
||||
List<Header> headers = new ArrayList<>();
|
||||
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
|
||||
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
|
||||
headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache"));
|
||||
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
|
||||
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
|
||||
|
||||
SequencedMap<String, InputStreamFactory> contentDecoderMap = new LinkedHashMap<>();
|
||||
contentDecoderMap.put(ContentCoding.GZIP.token(), GZIPInputStream::new);
|
||||
contentDecoderMap.put(ContentCoding.DEFLATE.token(), DeflateInputStream::new);
|
||||
contentDecoderMap.put(ContentCoding.BROTLI.token(), BrotliInputStream::new);
|
||||
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap =
|
||||
new LinkedHashMap<>();
|
||||
contentDecoderMap.put(
|
||||
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
|
||||
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
|
||||
|
||||
RedirectStrategy redirectStrategy =
|
||||
config.httpClient().blockLocalAddresses()
|
||||
? new BlockLocalAddressesRedirectStrategy(DNS_RESOLVER)
|
||||
: new DefaultRedirectStrategy();
|
||||
|
||||
return HttpClientBuilder.create()
|
||||
.disableConnectionState()
|
||||
.useSystemProperties()
|
||||
.disableAutomaticRetries()
|
||||
.disableCookieManagement()
|
||||
.setUserAgent(userAgent)
|
||||
.setDefaultHeaders(headers)
|
||||
.setConnectionManager(connectionManager)
|
||||
.evictExpiredConnections()
|
||||
.evictIdleConnections(
|
||||
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
||||
.setContentDecoderRegistry(new LinkedHashMap<>(contentDecoderMap))
|
||||
.setRedirectStrategy(redirectStrategy)
|
||||
.build();
|
||||
CloseableHttpAsyncClient client =
|
||||
HttpAsyncClients.custom()
|
||||
.disableConnectionState()
|
||||
.useSystemProperties()
|
||||
.disableAutomaticRetries()
|
||||
.disableCookieManagement()
|
||||
.setUserAgent(userAgent)
|
||||
.setDefaultHeaders(headers)
|
||||
.setConnectionManager(connectionManager)
|
||||
.evictExpiredConnections()
|
||||
.evictIdleConnections(
|
||||
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
||||
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
|
||||
.setRedirectStrategy(redirectStrategy)
|
||||
.build();
|
||||
client.start();
|
||||
return client;
|
||||
}
|
||||
|
||||
private PoolingHttpClientConnectionManager newConnectionManager(
|
||||
private PoolingAsyncClientConnectionManager newConnectionManager(
|
||||
CommaFeedConfiguration config, int poolSize) {
|
||||
SSLFactory sslFactory =
|
||||
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
|
||||
@@ -118,8 +123,8 @@ public class HttpClientFactory {
|
||||
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
|
||||
: DNS_RESOLVER;
|
||||
|
||||
return PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory))
|
||||
return PoolingAsyncClientConnectionManagerBuilder.create()
|
||||
.setTlsStrategy(Apache5SslUtils.toTlsStrategy(sslFactory))
|
||||
.setDefaultConnectionConfig(
|
||||
ConnectionConfig.custom()
|
||||
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
|
||||
@@ -129,6 +134,7 @@ public class HttpClientFactory {
|
||||
.build())
|
||||
.setDefaultTlsConfig(
|
||||
TlsConfig.custom()
|
||||
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
|
||||
.setHandshakeTimeout(
|
||||
Timeout.of(config.httpClient().sslHandshakeTimeout()))
|
||||
.build())
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.commafeed.CommaFeedConfiguration.HttpClientCache;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
@@ -17,39 +16,53 @@ import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Lombok;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.Delegate;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||
import org.apache.hc.client5.http.protocol.HttpClientContext;
|
||||
import org.apache.hc.client5.http.protocol.RedirectLocations;
|
||||
import org.apache.hc.client5.http.utils.DateUtils;
|
||||
import org.apache.hc.core5.http.ClassicHttpRequest;
|
||||
import org.apache.hc.core5.http.HttpEntity;
|
||||
import org.apache.hc.core5.concurrent.FutureCallback;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.EntityDetails;
|
||||
import org.apache.hc.core5.http.HttpException;
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.apache.hc.core5.http.Message;
|
||||
import org.apache.hc.core5.http.NameValuePair;
|
||||
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
|
||||
import org.apache.hc.core5.http.nio.AsyncRequestProducer;
|
||||
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
|
||||
import org.apache.hc.core5.http.nio.entity.AbstractBinAsyncEntityConsumer;
|
||||
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
|
||||
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.InstantSource;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/** Smart HTTP getter: handles gzip, ssl, last modified and etag headers */
|
||||
/** Smart HTTP getter: handles gzip, ssl, last modified, and etag headers */
|
||||
@Singleton
|
||||
@Slf4j
|
||||
public class HttpGetter {
|
||||
private final CommaFeedConfiguration config;
|
||||
private final InstantSource instantSource;
|
||||
private final CloseableHttpClient client;
|
||||
private final CloseableHttpAsyncClient client;
|
||||
private final Cache<HttpRequest, HttpResponse> cache;
|
||||
|
||||
public HttpGetter(
|
||||
@@ -150,70 +163,96 @@ public class HttpGetter {
|
||||
HttpClientContext context = HttpClientContext.create();
|
||||
context.setRequestConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
// causes issues with some feeds
|
||||
// see https://github.com/Athou/commafeed/issues/1572
|
||||
// and https://issues.apache.org/jira/browse/HTTPCLIENT-2344
|
||||
.setProtocolUpgradeEnabled(false)
|
||||
.build());
|
||||
|
||||
return client.execute(
|
||||
request.toClassicHttpRequest(),
|
||||
context,
|
||||
resp -> {
|
||||
byte[] content =
|
||||
resp.getEntity() == null
|
||||
? null
|
||||
: toByteArray(
|
||||
resp.getEntity(),
|
||||
config.httpClient().maxResponseSize().asLongValue());
|
||||
int code = resp.getCode();
|
||||
String lastModifiedHeader =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
String eTagHeader =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
long maxResponseSize = config.httpClient().maxResponseSize().asLongValue();
|
||||
AsyncRequestProducer req = SimpleRequestProducer.create(request.toSimpleHttpRequest());
|
||||
AsyncResponseConsumer<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> resp =
|
||||
new BasicResponseConsumer<>(
|
||||
new LimitedContentLengthEntityConsumer(
|
||||
new LimitedBytesEntityConsumer(maxResponseSize), maxResponseSize));
|
||||
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void completed(
|
||||
Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
|
||||
|
||||
CacheControl cacheControl =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(HttpGetter::toCacheControl)
|
||||
.orElse(null);
|
||||
@Override
|
||||
public void failed(Exception ex) {}
|
||||
|
||||
Instant retryAfter =
|
||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(this::toInstant)
|
||||
.orElse(null);
|
||||
@Override
|
||||
public void cancelled() {}
|
||||
};
|
||||
Future<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> future =
|
||||
client.execute(req, resp, context, callback);
|
||||
|
||||
String contentType =
|
||||
Optional.ofNullable(resp.getEntity())
|
||||
.map(HttpEntity::getContentType)
|
||||
.orElse(null);
|
||||
String urlAfterRedirect =
|
||||
Optional.ofNullable(context.getRedirectLocations())
|
||||
.map(RedirectLocations::getAll)
|
||||
.map(l -> Iterables.getLast(l, null))
|
||||
.map(URI::toString)
|
||||
.orElse(request.getUrl());
|
||||
try {
|
||||
Message<org.apache.hc.core5.http.HttpResponse, byte[]> message =
|
||||
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS);
|
||||
|
||||
return new HttpResponse(
|
||||
code,
|
||||
lastModifiedHeader,
|
||||
eTagHeader,
|
||||
cacheControl,
|
||||
retryAfter,
|
||||
content,
|
||||
contentType,
|
||||
urlAfterRedirect);
|
||||
});
|
||||
byte[] content = message.getBody();
|
||||
int code = message.getHead().getCode();
|
||||
String lastModifiedHeader =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.LAST_MODIFIED))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
String eTagHeader =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.ETAG))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
|
||||
CacheControl cacheControl =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CACHE_CONTROL))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(HttpGetter::toCacheControl)
|
||||
.orElse(null);
|
||||
|
||||
Instant retryAfter =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.RETRY_AFTER))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(this::toInstant)
|
||||
.orElse(null);
|
||||
|
||||
String contentType =
|
||||
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CONTENT_TYPE))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
String urlAfterRedirect =
|
||||
Optional.ofNullable(context.getRedirectLocations())
|
||||
.map(RedirectLocations::getAll)
|
||||
.map(l -> Iterables.getLast(l, null))
|
||||
.map(URI::toString)
|
||||
.orElse(request.getUrl());
|
||||
|
||||
return new HttpResponse(
|
||||
code,
|
||||
lastModifiedHeader,
|
||||
eTagHeader,
|
||||
cacheControl,
|
||||
retryAfter,
|
||||
content,
|
||||
contentType,
|
||||
urlAfterRedirect);
|
||||
} catch (InterruptedException e) {
|
||||
future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("interrupted while fetching " + request.getUrl(), e);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException("timed out while fetching " + request.getUrl(), e);
|
||||
} catch (ExecutionException e) {
|
||||
future.cancel(true);
|
||||
throw Lombok.sneakyThrow(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private static CacheControl toCacheControl(String headerValue) {
|
||||
@@ -237,28 +276,6 @@ public class HttpGetter {
|
||||
return DateUtils.parseStandardDate(headerValue);
|
||||
}
|
||||
|
||||
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
|
||||
if (entity.getContentLength() > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(entity.getContentLength(), maxBytes));
|
||||
}
|
||||
|
||||
try (InputStream input = entity.getContent()) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] bytes = ByteStreams.limit(input, maxBytes + 1).readAllBytes();
|
||||
if (bytes.length > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(maxBytes));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
|
||||
HttpClientCache cacheConfig = config.httpClient().cache();
|
||||
if (!cacheConfig.enabled()) {
|
||||
@@ -266,14 +283,67 @@ public class HttpGetter {
|
||||
}
|
||||
|
||||
return CacheBuilder.newBuilder()
|
||||
.weigher(
|
||||
(HttpRequest key, HttpResponse value) ->
|
||||
value.content() != null ? value.content().length : 0)
|
||||
.weigher((HttpRequest key, HttpResponse value) ->
|
||||
value.content() != null ? value.content().length : 0)
|
||||
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
|
||||
.expireAfterWrite(cacheConfig.expiration())
|
||||
.build();
|
||||
}
|
||||
|
||||
private record LimitedContentLengthEntityConsumer(
|
||||
@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
|
||||
implements AsyncEntityConsumer<byte[]> {
|
||||
|
||||
@Override
|
||||
public void streamStart(EntityDetails entityDetails, FutureCallback<byte[]> resultCallback)
|
||||
throws HttpException, IOException {
|
||||
long contentLength = entityDetails.getContentLength();
|
||||
if (contentLength > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(contentLength, maxBytes));
|
||||
}
|
||||
delegate.streamStart(entityDetails, resultCallback);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor
|
||||
private static class LimitedBytesEntityConsumer extends AbstractBinAsyncEntityConsumer<byte[]> {
|
||||
|
||||
private final long maxBytes;
|
||||
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
|
||||
@Override
|
||||
protected void streamStart(ContentType contentType) {}
|
||||
|
||||
@Override
|
||||
protected int capacityIncrement() {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
|
||||
if (out.size() + src.remaining() > maxBytes) {
|
||||
throw new IOException(
|
||||
"Response size exceeds the maximum allowed size (%s bytes)"
|
||||
.formatted(maxBytes));
|
||||
}
|
||||
byte[] chunk = new byte[src.remaining()];
|
||||
src.get(chunk);
|
||||
out.write(chunk, 0, chunk.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected byte[] generateContent() {
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseResources() {
|
||||
out.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public static class SchemeNotAllowedException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -336,15 +406,15 @@ public class HttpGetter {
|
||||
return new HttpRequestBuilder().url(url);
|
||||
}
|
||||
|
||||
public ClassicHttpRequest toClassicHttpRequest() {
|
||||
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build();
|
||||
public SimpleHttpRequest toSimpleHttpRequest() {
|
||||
SimpleRequestBuilder builder = SimpleRequestBuilder.get(url);
|
||||
if (lastModified != null) {
|
||||
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
|
||||
builder.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
|
||||
}
|
||||
if (eTag != null) {
|
||||
req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
|
||||
builder.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
|
||||
}
|
||||
return req;
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.FeedCategory;
|
||||
import com.commafeed.backend.model.QFeedCategory;
|
||||
import com.commafeed.backend.model.QUser;
|
||||
@@ -17,8 +18,8 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
|
||||
|
||||
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
|
||||
|
||||
public FeedCategoryDAO(EntityManager entityManager) {
|
||||
super(entityManager, FeedCategory.class);
|
||||
public FeedCategoryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, FeedCategory.class, config);
|
||||
}
|
||||
|
||||
public List<FeedCategory> findAll(User user) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.Feed;
|
||||
import com.commafeed.backend.model.QFeed;
|
||||
import com.commafeed.backend.model.QFeedSubscription;
|
||||
@@ -20,8 +21,8 @@ public class FeedDAO extends GenericDAO<Feed> {
|
||||
private static final QFeed FEED = QFeed.feed;
|
||||
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
|
||||
|
||||
public FeedDAO(EntityManager entityManager) {
|
||||
super(entityManager, Feed.class);
|
||||
public FeedDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, Feed.class, config);
|
||||
}
|
||||
|
||||
public List<Feed> findByIds(List<Long> id) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.FeedEntryContent;
|
||||
import com.commafeed.backend.model.QFeedEntry;
|
||||
import com.commafeed.backend.model.QFeedEntryContent;
|
||||
@@ -15,8 +16,8 @@ public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
|
||||
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
|
||||
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
|
||||
|
||||
public FeedEntryContentDAO(EntityManager entityManager) {
|
||||
super(entityManager, FeedEntryContent.class);
|
||||
public FeedEntryContentDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, FeedEntryContent.class, config);
|
||||
}
|
||||
|
||||
public List<FeedEntryContent> findExisting(String contentHash, String titleHash) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.Feed;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.QFeedEntry;
|
||||
@@ -23,8 +24,8 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
||||
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
|
||||
private static final int IN_CLAUSE_BATCH_SIZE = 1000;
|
||||
|
||||
public FeedEntryDAO(EntityManager entityManager) {
|
||||
super(entityManager, FeedEntry.class);
|
||||
public FeedEntryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, FeedEntry.class, config);
|
||||
}
|
||||
|
||||
public FeedEntry findExisting(String guidHash, Feed feed) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.apache.commons.collections4.CollectionUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -50,7 +51,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
||||
EntityManager entityManager,
|
||||
FeedEntryTagDAO feedEntryTagDAO,
|
||||
CommaFeedConfiguration config) {
|
||||
super(entityManager, FeedEntryStatus.class);
|
||||
super(entityManager, FeedEntryStatus.class, config);
|
||||
this.feedEntryTagDAO = feedEntryTagDAO;
|
||||
this.config = config;
|
||||
}
|
||||
@@ -129,8 +130,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
||||
query.limit(limit);
|
||||
}
|
||||
|
||||
setTimeout(query, config.database().queryTimeout());
|
||||
|
||||
List<FeedEntryStatus> statuses = query.fetch();
|
||||
statuses.forEach(s -> s.setMarkable(true));
|
||||
if (includeContent) {
|
||||
@@ -207,8 +206,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
||||
query.limit(limit);
|
||||
}
|
||||
|
||||
setTimeout(query, config.database().queryTimeout());
|
||||
|
||||
List<FeedEntryStatus> statuses = new ArrayList<>();
|
||||
List<Tuple> tuples = query.fetch();
|
||||
for (Tuple tuple : tuples) {
|
||||
@@ -238,19 +235,41 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
public UnreadCount getUnreadCount(FeedSubscription sub) {
|
||||
public Map<Long, UnreadCount> getUnreadCounts(List<FeedSubscription> subs) {
|
||||
if (subs.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<Long, List<FeedSubscription>> subsByFeedId =
|
||||
subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId()));
|
||||
|
||||
JPAQuery<Tuple> query =
|
||||
query().select(ENTRY.count(), ENTRY.published.max())
|
||||
query().select(ENTRY.feed.id, ENTRY.count(), ENTRY.published.max())
|
||||
.from(ENTRY)
|
||||
.leftJoin(ENTRY.statuses, STATUS)
|
||||
.on(STATUS.subscription.eq(sub))
|
||||
.where(ENTRY.feed.eq(sub.getFeed()))
|
||||
.where(buildUnreadPredicate());
|
||||
.on(STATUS.subscription.in(subs))
|
||||
.where(ENTRY.feed.id.in(subsByFeedId.keySet()))
|
||||
.where(buildUnreadPredicate())
|
||||
.groupBy(ENTRY.feed.id);
|
||||
|
||||
Tuple tuple = query.fetchOne();
|
||||
Long count = tuple.get(ENTRY.count());
|
||||
Instant published = tuple.get(ENTRY.published.max());
|
||||
return new UnreadCount(sub.getId(), count == null ? 0 : count, published);
|
||||
Map<Long, UnreadCount> result = new HashMap<>();
|
||||
for (Tuple tuple : query.fetch()) {
|
||||
Long feedId = tuple.get(ENTRY.feed.id);
|
||||
Long count = tuple.get(ENTRY.count());
|
||||
Instant published = tuple.get(ENTRY.published.max());
|
||||
for (FeedSubscription sub : subsByFeedId.get(feedId)) {
|
||||
result.put(
|
||||
sub.getId(),
|
||||
new UnreadCount(sub.getId(), count == null ? 0 : count, published));
|
||||
}
|
||||
}
|
||||
|
||||
// feeds with no unread entries are absent from the query result, default them to zero
|
||||
for (FeedSubscription sub : subs) {
|
||||
result.putIfAbsent(sub.getId(), new UnreadCount(sub.getId(), 0, null));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private BooleanBuilder buildUnreadPredicate() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedEntryTag;
|
||||
import com.commafeed.backend.model.QFeedEntryTag;
|
||||
@@ -17,8 +18,8 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
|
||||
|
||||
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
|
||||
|
||||
public FeedEntryTagDAO(EntityManager entityManager) {
|
||||
super(entityManager, FeedEntryTag.class);
|
||||
public FeedEntryTagDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, FeedEntryTag.class, config);
|
||||
}
|
||||
|
||||
public List<String> findByUser(User user) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.AbstractModel;
|
||||
import com.commafeed.backend.model.Feed;
|
||||
import com.commafeed.backend.model.FeedCategory;
|
||||
@@ -31,8 +32,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
|
||||
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public FeedSubscriptionDAO(EntityManager entityManager) {
|
||||
super(entityManager, FeedSubscription.class);
|
||||
public FeedSubscriptionDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, FeedSubscription.class, config);
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@@ -124,10 +125,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
|
||||
Set<Long> categoryIds =
|
||||
categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
|
||||
return findAll(user).stream()
|
||||
.filter(
|
||||
s ->
|
||||
s.getCategory() != null
|
||||
&& categoryIds.contains(s.getCategory().getId()))
|
||||
.filter(s ->
|
||||
s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.AbstractModel;
|
||||
import com.querydsl.core.types.EntityPath;
|
||||
import com.querydsl.jpa.impl.JPADeleteClause;
|
||||
@@ -21,9 +22,15 @@ public abstract class GenericDAO<T extends AbstractModel> {
|
||||
|
||||
private final EntityManager entityManager;
|
||||
private final Class<T> entityClass;
|
||||
private final CommaFeedConfiguration config;
|
||||
|
||||
protected JPAQueryFactory query() {
|
||||
return new JPAQueryFactory(entityManager);
|
||||
return new JPAQueryFactory(entityManager) {
|
||||
@Override
|
||||
public JPAQuery<?> query() {
|
||||
return withTimeout(super.query(), config.database().queryTimeout());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) {
|
||||
@@ -57,9 +64,10 @@ public abstract class GenericDAO<T extends AbstractModel> {
|
||||
return objects.size();
|
||||
}
|
||||
|
||||
protected void setTimeout(JPAQuery<?> query, Duration timeout) {
|
||||
private JPAQuery<?> withTimeout(JPAQuery<?> query, Duration timeout) {
|
||||
if (!timeout.isZero()) {
|
||||
query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis()));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.QUser;
|
||||
import com.commafeed.backend.model.User;
|
||||
|
||||
@@ -11,8 +12,8 @@ public class UserDAO extends GenericDAO<User> {
|
||||
|
||||
private static final QUser USER = QUser.user;
|
||||
|
||||
public UserDAO(EntityManager entityManager) {
|
||||
super(entityManager, User.class);
|
||||
public UserDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, User.class, config);
|
||||
}
|
||||
|
||||
public User findByName(String name) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.QUserRole;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserRole;
|
||||
@@ -17,8 +18,8 @@ public class UserRoleDAO extends GenericDAO<UserRole> {
|
||||
|
||||
private static final QUserRole ROLE = QUserRole.userRole;
|
||||
|
||||
public UserRoleDAO(EntityManager entityManager) {
|
||||
super(entityManager, UserRole.class);
|
||||
public UserRoleDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, UserRole.class, config);
|
||||
}
|
||||
|
||||
public List<UserRole> findAll() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.model.QUserSettings;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserSettings;
|
||||
@@ -12,8 +13,8 @@ public class UserSettingsDAO extends GenericDAO<UserSettings> {
|
||||
|
||||
private static final QUserSettings SETTINGS = QUserSettings.userSettings;
|
||||
|
||||
public UserSettingsDAO(EntityManager entityManager) {
|
||||
super(entityManager, UserSettings.class);
|
||||
public UserSettingsDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||
super(entityManager, UserSettings.class, config);
|
||||
}
|
||||
|
||||
public UserSettings findByUser(User user) {
|
||||
|
||||
@@ -104,56 +104,52 @@ public class FeedRefreshEngine {
|
||||
|
||||
private void startFeedProcessingLoop() {
|
||||
// take a feed from the queue, process it, rince, repeat
|
||||
feedProcessingLoopExecutor.submit(
|
||||
() -> {
|
||||
while (!feedProcessingLoopExecutor.isShutdown()) {
|
||||
try {
|
||||
// take() is blocking until a feed is available from the queue
|
||||
Feed feed = queue.take();
|
||||
feedProcessingLoopExecutor.submit(() -> {
|
||||
while (!feedProcessingLoopExecutor.isShutdown()) {
|
||||
try {
|
||||
// take() is blocking until a feed is available from the queue
|
||||
Feed feed = queue.take();
|
||||
|
||||
// send the feed to be processed
|
||||
log.debug(
|
||||
"got feed {} from the queue, send it for processing",
|
||||
feed.getId());
|
||||
processFeedAsync(feed);
|
||||
// send the feed to be processed
|
||||
log.debug("got feed {} from the queue, send it for processing", feed.getId());
|
||||
processFeedAsync(feed);
|
||||
|
||||
// we removed a feed from the queue, try to refill it as it may now be
|
||||
// empty
|
||||
if (queue.isEmpty()) {
|
||||
log.debug("took the last feed from the queue, try to refill");
|
||||
refillQueueAsync();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
log.debug("interrupted while waiting for a feed in the queue");
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
}
|
||||
// we removed a feed from the queue, try to refill it as it may now be
|
||||
// empty
|
||||
if (queue.isEmpty()) {
|
||||
log.debug("took the last feed from the queue, try to refill");
|
||||
refillQueueAsync();
|
||||
}
|
||||
});
|
||||
} catch (InterruptedException e) {
|
||||
log.debug("interrupted while waiting for a feed in the queue");
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void startRefillLoop() {
|
||||
// refill the queue at regular intervals if it's empty
|
||||
refillLoopExecutor.submit(
|
||||
() -> {
|
||||
while (!refillLoopExecutor.isShutdown()) {
|
||||
try {
|
||||
if (queue.isEmpty()) {
|
||||
log.debug("refilling queue");
|
||||
refillQueueAsync();
|
||||
}
|
||||
|
||||
log.debug("sleeping for 15s");
|
||||
TimeUnit.SECONDS.sleep(15);
|
||||
} catch (InterruptedException e) {
|
||||
log.debug("interrupted while sleeping");
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
}
|
||||
refillLoopExecutor.submit(() -> {
|
||||
while (!refillLoopExecutor.isShutdown()) {
|
||||
try {
|
||||
if (queue.isEmpty()) {
|
||||
log.debug("refilling queue");
|
||||
refillQueueAsync();
|
||||
}
|
||||
});
|
||||
|
||||
log.debug("sleeping for 15s");
|
||||
TimeUnit.SECONDS.sleep(15);
|
||||
} catch (InterruptedException e) {
|
||||
log.debug("interrupted while sleeping");
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void refreshImmediately(Feed feed) {
|
||||
@@ -184,65 +180,53 @@ public class FeedRefreshEngine {
|
||||
}
|
||||
},
|
||||
refillExecutor)
|
||||
.whenComplete(
|
||||
(data, ex) -> {
|
||||
if (ex != null) {
|
||||
log.error("error while refilling the queue", ex);
|
||||
}
|
||||
});
|
||||
.whenComplete((data, ex) -> {
|
||||
if (ex != null) {
|
||||
log.error("error while refilling the queue", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void processFeedAsync(Feed feed) {
|
||||
CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor)
|
||||
.thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor)
|
||||
.thenCompose(
|
||||
r -> {
|
||||
List<CompletableFuture<Void>> futures =
|
||||
r.insertedUnreadEntriesBySubscription().entrySet().stream()
|
||||
.map(
|
||||
e -> {
|
||||
FeedSubscription sub = e.getKey();
|
||||
List<FeedEntry> entries = e.getValue();
|
||||
.thenCompose(r -> {
|
||||
List<CompletableFuture<Void>> futures =
|
||||
r.insertedUnreadEntriesBySubscription().entrySet().stream()
|
||||
.map(e -> {
|
||||
FeedSubscription sub = e.getKey();
|
||||
List<FeedEntry> entries = e.getValue();
|
||||
|
||||
notifier.notifyOverWebsocket(sub, entries);
|
||||
return CompletableFuture.runAsync(
|
||||
() ->
|
||||
notifier
|
||||
.sendPushNotifications(
|
||||
sub,
|
||||
entries),
|
||||
notifierExecutor);
|
||||
})
|
||||
.toList();
|
||||
return CompletableFuture.allOf(
|
||||
futures.toArray(CompletableFuture[]::new));
|
||||
})
|
||||
.exceptionally(
|
||||
ex -> {
|
||||
log.error("error while processing feed {}", feed.getUrl(), ex);
|
||||
return null;
|
||||
});
|
||||
notifier.notifyOverWebsocket(sub, entries);
|
||||
return CompletableFuture.runAsync(
|
||||
() -> notifier.sendPushNotifications(sub, entries),
|
||||
notifierExecutor);
|
||||
})
|
||||
.toList();
|
||||
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
log.error("error while processing feed {}", feed.getUrl(), ex);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private List<Feed> getNextUpdatableFeeds(int max) {
|
||||
return unitOfWork.call(
|
||||
() -> {
|
||||
Instant lastLoginThreshold =
|
||||
config.feedRefresh().userInactivityPeriod().isZero()
|
||||
? null
|
||||
: Instant.now()
|
||||
.minus(config.feedRefresh().userInactivityPeriod());
|
||||
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
|
||||
if (!feeds.isEmpty()) {
|
||||
// update disabledUntil to prevent feeds from being returned again by
|
||||
// feedDAO.findNextUpdatable()
|
||||
Instant nextUpdateDate =
|
||||
Instant.now().plus(config.feedRefresh().interval());
|
||||
feedDAO.setDisabledUntil(
|
||||
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
|
||||
}
|
||||
return feeds;
|
||||
});
|
||||
return unitOfWork.call(() -> {
|
||||
Instant lastLoginThreshold =
|
||||
config.feedRefresh().userInactivityPeriod().isZero()
|
||||
? null
|
||||
: Instant.now().minus(config.feedRefresh().userInactivityPeriod());
|
||||
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
|
||||
if (!feeds.isEmpty()) {
|
||||
// update disabledUntil to prevent feeds from being returned again by
|
||||
// feedDAO.findNextUpdatable()
|
||||
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
|
||||
feedDAO.setDisabledUntil(
|
||||
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
|
||||
}
|
||||
return feeds;
|
||||
});
|
||||
}
|
||||
|
||||
private int getBatchSize() {
|
||||
@@ -292,19 +276,18 @@ public class FeedRefreshEngine {
|
||||
ThreadPoolExecutor pool =
|
||||
new ThreadPoolExecutor(
|
||||
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
|
||||
pool.setRejectedExecutionHandler(
|
||||
(r, e) -> {
|
||||
if (e.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
pool.setRejectedExecutionHandler((r, e) -> {
|
||||
if (e.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
e.getQueue().put(r);
|
||||
} catch (InterruptedException ex) {
|
||||
log.debug("interrupted while waiting for a slot in the queue.", ex);
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
});
|
||||
try {
|
||||
e.getQueue().put(r);
|
||||
} catch (InterruptedException ex) {
|
||||
log.debug("interrupted while waiting for a slot in the queue.", ex);
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,24 +91,22 @@ public class FeedRefreshUpdater {
|
||||
if (locked1 && locked2) {
|
||||
processed = true;
|
||||
insertedEntry =
|
||||
unitOfWork.call(
|
||||
() -> {
|
||||
if (feedEntryService.find(feed, entry) != null) {
|
||||
// entry already exists, nothing to do
|
||||
return null;
|
||||
}
|
||||
unitOfWork.call(() -> {
|
||||
if (feedEntryService.find(feed, entry) != null) {
|
||||
// entry already exists, nothing to do
|
||||
return null;
|
||||
}
|
||||
|
||||
FeedEntry feedEntry = feedEntryService.create(feed, entry);
|
||||
entryInserted.mark();
|
||||
for (FeedSubscription sub : subscriptions) {
|
||||
boolean unread =
|
||||
feedEntryService.applyFilter(sub, feedEntry);
|
||||
if (unread) {
|
||||
subscriptionsForWhichEntryIsUnread.add(sub);
|
||||
}
|
||||
}
|
||||
return feedEntry;
|
||||
});
|
||||
FeedEntry feedEntry = feedEntryService.create(feed, entry);
|
||||
entryInserted.mark();
|
||||
for (FeedSubscription sub : subscriptions) {
|
||||
boolean unread = feedEntryService.applyFilter(sub, feedEntry);
|
||||
if (unread) {
|
||||
subscriptionsForWhichEntryIsUnread.add(sub);
|
||||
}
|
||||
}
|
||||
return feedEntry;
|
||||
});
|
||||
} else {
|
||||
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
|
||||
}
|
||||
@@ -147,14 +145,13 @@ public class FeedRefreshUpdater {
|
||||
AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions);
|
||||
processed &= addEntryResult.processed;
|
||||
inserted += addEntryResult.insertedEntry != null ? 1 : 0;
|
||||
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(
|
||||
sub -> {
|
||||
if (addEntryResult.insertedEntry != null) {
|
||||
insertedUnreadEntriesBySubscription
|
||||
.computeIfAbsent(sub, k -> new ArrayList<>())
|
||||
.add(addEntryResult.insertedEntry);
|
||||
}
|
||||
});
|
||||
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> {
|
||||
if (addEntryResult.insertedEntry != null) {
|
||||
insertedUnreadEntriesBySubscription
|
||||
.computeIfAbsent(sub, k -> new ArrayList<>())
|
||||
.add(addEntryResult.insertedEntry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (inserted == 0) {
|
||||
|
||||
@@ -65,20 +65,17 @@ public class OPMLExporter {
|
||||
|
||||
for (FeedCategory child :
|
||||
categories.stream()
|
||||
.filter(
|
||||
c ->
|
||||
c.getParent() != null
|
||||
&& c.getParent().getId().equals(cat.getId()))
|
||||
.filter(c ->
|
||||
c.getParent() != null && c.getParent().getId().equals(cat.getId()))
|
||||
.toList()) {
|
||||
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
|
||||
}
|
||||
|
||||
for (FeedSubscription sub :
|
||||
subscriptions.stream()
|
||||
.filter(
|
||||
s ->
|
||||
s.getCategory() != null
|
||||
&& s.getCategory().getId().equals(cat.getId()))
|
||||
.filter(s ->
|
||||
s.getCategory() != null
|
||||
&& s.getCategory().getId().equals(cat.getId()))
|
||||
.toList()) {
|
||||
outline.getChildren().add(buildSubscriptionOutline(sub));
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ public class FeedEntryContentCleaningService {
|
||||
|
||||
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
|
||||
whitelist.addProtocols("blockquote", "cite", "http", "https");
|
||||
whitelist.addProtocols("iframe", "src", "http", "https");
|
||||
whitelist.addProtocols("img", "src", "http", "https");
|
||||
whitelist.addProtocols("q", "cite", "http", "https");
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ public class FeedEntryFilteringService {
|
||||
.addVar("categories", SimpleType.STRING)
|
||||
.addVar("categoriesLower", SimpleType.STRING)
|
||||
.build();
|
||||
private static final CelRuntime CEL_RUNTIME =
|
||||
CelRuntimeFactory.standardCelRuntimeBuilder().build();
|
||||
private static final CelRuntime CEL_RUNTIME = CelRuntimeFactory.plannerRuntimeBuilder().build();
|
||||
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
private final CommaFeedConfiguration config;
|
||||
|
||||
@@ -98,15 +98,14 @@ public class FeedEntryService {
|
||||
}
|
||||
}
|
||||
|
||||
public void starEntry(User user, Long entryId, Long subscriptionId, boolean starred) {
|
||||
|
||||
FeedSubscription sub = feedSubscriptionDAO.findById(user, subscriptionId);
|
||||
if (sub == null) {
|
||||
public void starEntry(User user, Long entryId, boolean starred) {
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
if (sub == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,26 +148,23 @@ public class FeedEntryService {
|
||||
List<FeedEntryStatus> statusesToMark =
|
||||
statuses.stream()
|
||||
.filter(FeedEntryStatus::isMarkable)
|
||||
.filter(
|
||||
s -> {
|
||||
Instant entryDate = s.getEntry().getPublished();
|
||||
return olderThan == null
|
||||
|| entryDate == null
|
||||
|| entryDate.isBefore(olderThan);
|
||||
})
|
||||
.filter(
|
||||
s -> {
|
||||
Instant insertedDate = s.getEntry().getInserted();
|
||||
return insertedBefore == null
|
||||
|| insertedDate == null
|
||||
|| insertedDate.isBefore(insertedBefore);
|
||||
})
|
||||
.filter(s -> {
|
||||
Instant entryDate = s.getEntry().getPublished();
|
||||
return olderThan == null
|
||||
|| entryDate == null
|
||||
|| entryDate.isBefore(olderThan);
|
||||
})
|
||||
.filter(s -> {
|
||||
Instant insertedDate = s.getEntry().getInserted();
|
||||
return insertedBefore == null
|
||||
|| insertedDate == null
|
||||
|| insertedDate.isBefore(insertedBefore);
|
||||
})
|
||||
.toList();
|
||||
|
||||
statusesToMark.forEach(
|
||||
s -> {
|
||||
s.setRead(true);
|
||||
feedEntryStatusDAO.merge(s);
|
||||
});
|
||||
statusesToMark.forEach(s -> {
|
||||
s.setRead(true);
|
||||
feedEntryStatusDAO.merge(s);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.commafeed.backend.service;
|
||||
|
||||
import com.commafeed.backend.dao.FeedEntryDAO;
|
||||
import com.commafeed.backend.dao.FeedEntryTagDAO;
|
||||
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedEntryTag;
|
||||
import com.commafeed.backend.model.FeedSubscription;
|
||||
import com.commafeed.backend.model.User;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
@@ -20,6 +22,7 @@ public class FeedEntryTagService {
|
||||
|
||||
private final FeedEntryDAO feedEntryDAO;
|
||||
private final FeedEntryTagDAO feedEntryTagDAO;
|
||||
private final FeedSubscriptionDAO feedSubscriptionDAO;
|
||||
|
||||
public void updateTags(User user, Long entryId, List<String> tagNames) {
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
@@ -27,6 +30,11 @@ public class FeedEntryTagService {
|
||||
return;
|
||||
}
|
||||
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
if (sub == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
|
||||
Set<String> existingTagNames =
|
||||
existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
|
||||
|
||||
@@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Singleton
|
||||
@@ -47,14 +46,13 @@ public class FeedSubscriptionService {
|
||||
// we need to use this hook because the feed needs to have been persisted before being
|
||||
// processed
|
||||
// by the feed engine
|
||||
feedSubscriptionDAO.onPostCommitInsert(
|
||||
sub -> {
|
||||
Feed feed = sub.getFeed();
|
||||
if (feed.getDisabledUntil() == null
|
||||
|| feed.getDisabledUntil().isBefore(Instant.now())) {
|
||||
feedRefreshEngine.refreshImmediately(feed);
|
||||
}
|
||||
});
|
||||
feedSubscriptionDAO.onPostCommitInsert(sub -> {
|
||||
Feed feed = sub.getFeed();
|
||||
if (feed.getDisabledUntil() == null
|
||||
|| feed.getDisabledUntil().isBefore(Instant.now())) {
|
||||
feedRefreshEngine.refreshImmediately(feed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public long subscribe(
|
||||
@@ -116,10 +114,7 @@ public class FeedSubscriptionService {
|
||||
}
|
||||
|
||||
public Map<Long, UnreadCount> getUnreadCount(User user) {
|
||||
return feedSubscriptionDAO.findAll(user).stream()
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount));
|
||||
return feedEntryStatusDAO.getUnreadCounts(feedSubscriptionDAO.findAll(user));
|
||||
}
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
|
||||
@@ -16,26 +16,33 @@ import jakarta.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
|
||||
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
|
||||
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.HttpResponse;
|
||||
import org.apache.hc.core5.http.Message;
|
||||
import org.apache.hc.core5.http.NameValuePair;
|
||||
import org.apache.hc.core5.http.io.entity.StringEntity;
|
||||
import org.apache.hc.core5.http.message.BasicNameValuePair;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.apache.hc.core5.http.nio.entity.DiscardingEntityConsumer;
|
||||
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
|
||||
import org.apache.hc.core5.net.WWWFormCodec;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
@Singleton
|
||||
@Slf4j
|
||||
public class PushNotificationService {
|
||||
|
||||
private final CloseableHttpClient httpClient;
|
||||
private final CloseableHttpAsyncClient httpClient;
|
||||
private final Meter meter;
|
||||
private final CommaFeedConfiguration config;
|
||||
|
||||
@@ -97,13 +104,9 @@ public class PushNotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
HttpPost request = new HttpPost(serverUrl + "/" + topic);
|
||||
request.setConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
.build());
|
||||
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/" + topic);
|
||||
request.addHeader("Title", feedTitle);
|
||||
request.setEntity(new StringEntity(entryTitle, StandardCharsets.UTF_8));
|
||||
request.setBody(entryTitle, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8));
|
||||
|
||||
if (StringUtils.isNotBlank(entryUrl)) {
|
||||
request.addHeader("Click", entryUrl);
|
||||
@@ -113,15 +116,7 @@ public class PushNotificationService {
|
||||
request.addHeader("Authorization", "Bearer " + settings.getUserSecret());
|
||||
}
|
||||
|
||||
httpClient.execute(
|
||||
request,
|
||||
response -> {
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
"ntfy notification failed with status " + response.getCode());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
send("ntfy", request.build());
|
||||
}
|
||||
|
||||
private void sendGotify(
|
||||
@@ -152,23 +147,11 @@ public class PushNotificationService {
|
||||
.put("click", new JsonObject().put("url", entryUrl))));
|
||||
}
|
||||
|
||||
HttpPost request = new HttpPost(serverUrl + "/message");
|
||||
request.setConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
.build());
|
||||
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
|
||||
request.addHeader("X-Gotify-Key", token);
|
||||
request.setEntity(new StringEntity(json.toString(), ContentType.APPLICATION_JSON));
|
||||
request.setBody(json.toString(), ContentType.APPLICATION_JSON);
|
||||
|
||||
httpClient.execute(
|
||||
request,
|
||||
response -> {
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
"gotify notification failed with status " + response.getCode());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
send("gotify", request.build());
|
||||
}
|
||||
|
||||
private void sendPushover(
|
||||
@@ -194,22 +177,40 @@ public class PushNotificationService {
|
||||
params.add(new BasicNameValuePair("url", entryUrl));
|
||||
}
|
||||
|
||||
HttpPost request = new HttpPost("https://api.pushover.net/1/messages.json");
|
||||
request.setConfig(
|
||||
RequestConfig.custom()
|
||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
||||
.build());
|
||||
request.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
|
||||
SimpleRequestBuilder request =
|
||||
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
|
||||
request.setBody(
|
||||
WWWFormCodec.format(params, StandardCharsets.UTF_8),
|
||||
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
|
||||
|
||||
httpClient.execute(
|
||||
request,
|
||||
response -> {
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
"pushover notification failed with status " + response.getCode());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
send("pushover", request.build());
|
||||
}
|
||||
|
||||
private void send(String name, SimpleHttpRequest request) throws IOException {
|
||||
Future<Message<HttpResponse, Object>> future =
|
||||
httpClient.execute(
|
||||
SimpleRequestProducer.create(request),
|
||||
new BasicResponseConsumer<>(new DiscardingEntityConsumer<>()),
|
||||
null);
|
||||
try {
|
||||
HttpResponse response =
|
||||
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS)
|
||||
.getHead();
|
||||
if (response.getCode() >= 400) {
|
||||
throw new PushNotificationException(
|
||||
name + " notification failed with status " + response.getCode());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while sending " + name + " notification", e);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException("timed out while sending " + name + " notification", e);
|
||||
} catch (ExecutionException e) {
|
||||
future.cancel(true);
|
||||
throw new IOException(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
public static class PushNotificationException extends RuntimeException {
|
||||
|
||||
@@ -79,13 +79,10 @@ public class DatabaseCleaningService {
|
||||
} while (entriesDeleted > 0);
|
||||
}
|
||||
deleted =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
feedDAO.delete(
|
||||
feedDAO.findByIds(
|
||||
feeds.stream()
|
||||
.map(AbstractModel::getId)
|
||||
.toList())));
|
||||
unitOfWork.call(() ->
|
||||
feedDAO.delete(
|
||||
feedDAO.findByIds(
|
||||
feeds.stream().map(AbstractModel::getId).toList())));
|
||||
total += deleted;
|
||||
log.debug("removed {} feeds without subscriptions", total);
|
||||
} while (deleted != 0);
|
||||
@@ -119,10 +116,9 @@ public class DatabaseCleaningService {
|
||||
}
|
||||
|
||||
List<FeedCapacity> feeds =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
feedEntryDAO.findFeedsExceedingCapacity(
|
||||
maxFeedCapacity, batchSize, keepStarredEntries));
|
||||
unitOfWork.call(() ->
|
||||
feedEntryDAO.findFeedsExceedingCapacity(
|
||||
maxFeedCapacity, batchSize, keepStarredEntries));
|
||||
if (feeds.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
@@ -139,12 +135,11 @@ public class DatabaseCleaningService {
|
||||
|
||||
final long rem = remaining;
|
||||
deleted =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
feedEntryDAO.deleteOldEntries(
|
||||
feed.id(),
|
||||
Math.min(batchSize, rem),
|
||||
keepStarredEntries));
|
||||
unitOfWork.call(() ->
|
||||
feedEntryDAO.deleteOldEntries(
|
||||
feed.id(),
|
||||
Math.min(batchSize, rem),
|
||||
keepStarredEntries));
|
||||
entriesDeletedMeter.mark(deleted);
|
||||
total += deleted;
|
||||
remaining -= deleted;
|
||||
@@ -166,10 +161,9 @@ public class DatabaseCleaningService {
|
||||
}
|
||||
|
||||
deleted =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
feedEntryDAO.deleteEntriesOlderThan(
|
||||
olderThan, batchSize, keepStarredEntries));
|
||||
unitOfWork.call(() ->
|
||||
feedEntryDAO.deleteEntriesOlderThan(
|
||||
olderThan, batchSize, keepStarredEntries));
|
||||
entriesDeletedMeter.mark(deleted);
|
||||
total += deleted;
|
||||
log.debug("removed {} old entries", total);
|
||||
|
||||
@@ -31,16 +31,15 @@ public class DemoAccountCleanupTask extends ScheduledTask {
|
||||
}
|
||||
|
||||
log.info("recreating demo user account");
|
||||
unitOfWork.run(
|
||||
() -> {
|
||||
User demoUser = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
|
||||
if (demoUser == null) {
|
||||
return;
|
||||
}
|
||||
unitOfWork.run(() -> {
|
||||
User demoUser = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
|
||||
if (demoUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
userService.unregister(demoUser);
|
||||
userService.createDemoUser();
|
||||
});
|
||||
userService.unregister(demoUser);
|
||||
userService.createDemoUser();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.commafeed.frontend.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Objects;
|
||||
|
||||
public class CommaFeedApplicationException extends RuntimeException {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
private final CommaFeedExceptionType type;
|
||||
|
||||
public CommaFeedApplicationException(CommaFeedExceptionType type) {
|
||||
super(Objects.requireNonNull(type).message());
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public CommaFeedExceptionType type() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.commafeed.frontend.exception;
|
||||
|
||||
import org.jboss.resteasy.reactive.RestResponse.Status;
|
||||
|
||||
public enum CommaFeedExceptionType {
|
||||
WRONG_USERNAME_OR_PASSWORD(Status.UNAUTHORIZED, "wrong username or password");
|
||||
|
||||
private final Status status;
|
||||
private final String message;
|
||||
|
||||
CommaFeedExceptionType(Status status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Status status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,6 @@ public class StarRequest implements Serializable {
|
||||
@Size(max = 128)
|
||||
private String id;
|
||||
|
||||
@Schema(description = "feed id", required = true)
|
||||
private Long feedId;
|
||||
|
||||
@Schema(description = "starred or not", required = true)
|
||||
private boolean starred;
|
||||
}
|
||||
|
||||
@@ -435,6 +435,19 @@ public class CategoryREST {
|
||||
&& !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) {
|
||||
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId()));
|
||||
}
|
||||
if (parent != null) {
|
||||
Long parentId = parent.getId();
|
||||
List<FeedCategory> descendants =
|
||||
feedCategoryDAO.findAllChildrenCategories(user, category);
|
||||
boolean parentIsDescendant =
|
||||
descendants.stream().anyMatch(c -> c.getId().equals(parentId));
|
||||
if (parentIsDescendant) {
|
||||
return Response.status(Status.BAD_REQUEST)
|
||||
.entity(
|
||||
"<message>cannot set a category's own descendant as its parent</message>")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
category.setParent(parent);
|
||||
|
||||
if (req.getPosition() != null) {
|
||||
|
||||
@@ -89,11 +89,9 @@ public class EntryREST {
|
||||
@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
|
||||
Preconditions.checkNotNull(req);
|
||||
Preconditions.checkNotNull(req.getId());
|
||||
Preconditions.checkNotNull(req.getFeedId());
|
||||
|
||||
User user = authenticationContext.getCurrentUser();
|
||||
feedEntryService.starEntry(
|
||||
user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred());
|
||||
feedEntryService.starEntry(user, Long.valueOf(req.getId()), req.isStarred());
|
||||
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
||||
@@ -140,14 +140,9 @@ public class FeverREST {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
|
||||
form.getValues()
|
||||
.forEach(
|
||||
(k, v) ->
|
||||
params.put(
|
||||
k,
|
||||
v.stream()
|
||||
.map(FormValue::getValue)
|
||||
.findFirst()
|
||||
.orElse(null)));
|
||||
.forEach((k, v) ->
|
||||
params.put(
|
||||
k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
|
||||
return handle(userId, params);
|
||||
}
|
||||
|
||||
@@ -246,46 +241,42 @@ public class FeverREST {
|
||||
s -> s.getCategory() == null ? 0 : s.getCategory().getId()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(
|
||||
e -> {
|
||||
FeverFeedGroup fg = new FeverFeedGroup();
|
||||
fg.setGroupId(e.getKey());
|
||||
fg.setFeedIds(
|
||||
e.getValue().stream().map(FeedSubscription::getId).toList());
|
||||
return fg;
|
||||
})
|
||||
.map(e -> {
|
||||
FeverFeedGroup fg = new FeverFeedGroup();
|
||||
fg.setGroupId(e.getKey());
|
||||
fg.setFeedIds(e.getValue().stream().map(FeedSubscription::getId).toList());
|
||||
return fg;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<FeverGroup> buildGroups(List<FeedCategory> categories) {
|
||||
return categories.stream()
|
||||
.map(
|
||||
c -> {
|
||||
FeverGroup g = new FeverGroup();
|
||||
g.setId(c.getId());
|
||||
g.setTitle(c.getName());
|
||||
return g;
|
||||
})
|
||||
.map(c -> {
|
||||
FeverGroup g = new FeverGroup();
|
||||
g.setId(c.getId());
|
||||
g.setTitle(c.getName());
|
||||
return g;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<FeverFeed> buildFeeds(List<FeedSubscription> subscriptions) {
|
||||
return subscriptions.stream()
|
||||
.map(
|
||||
s -> {
|
||||
FeverFeed f = new FeverFeed();
|
||||
f.setId(s.getId());
|
||||
f.setFaviconId(s.getId());
|
||||
f.setTitle(s.getTitle());
|
||||
f.setUrl(s.getFeed().getUrl());
|
||||
f.setSiteUrl(s.getFeed().getLink());
|
||||
f.setSpark(false);
|
||||
f.setLastUpdatedOnTime(
|
||||
s.getFeed().getLastUpdated() == null
|
||||
? 0
|
||||
: s.getFeed().getLastUpdated().getEpochSecond());
|
||||
return f;
|
||||
})
|
||||
.map(s -> {
|
||||
FeverFeed f = new FeverFeed();
|
||||
f.setId(s.getId());
|
||||
f.setFaviconId(s.getId());
|
||||
f.setTitle(s.getTitle());
|
||||
f.setUrl(s.getFeed().getUrl());
|
||||
f.setSiteUrl(s.getFeed().getLink());
|
||||
f.setSpark(false);
|
||||
f.setLastUpdatedOnTime(
|
||||
s.getFeed().getLastUpdated() == null
|
||||
? 0
|
||||
: s.getFeed().getLastUpdated().getEpochSecond());
|
||||
return f;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -367,19 +358,18 @@ public class FeverREST {
|
||||
|
||||
private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
|
||||
return subscriptions.stream()
|
||||
.map(
|
||||
s -> {
|
||||
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
|
||||
.map(s -> {
|
||||
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
|
||||
|
||||
FeverFavicon f = new FeverFavicon();
|
||||
f.setId(s.getFeed().getId());
|
||||
f.setData(
|
||||
String.format(
|
||||
"data:%s;base64,%s",
|
||||
favicon.mediaType(),
|
||||
Base64.getEncoder().encodeToString(favicon.icon())));
|
||||
return f;
|
||||
})
|
||||
FeverFavicon f = new FeverFavicon();
|
||||
f.setId(s.getFeed().getId());
|
||||
f.setData(
|
||||
String.format(
|
||||
"data:%s;base64,%s",
|
||||
favicon.mediaType(),
|
||||
Base64.getEncoder().encodeToString(favicon.icon())));
|
||||
return f;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -388,9 +378,7 @@ public class FeverREST {
|
||||
if ("read".equals(action) || "unread".equals(action)) {
|
||||
feedEntryService.markEntry(user, id, "read".equals(action));
|
||||
} else if ("saved".equals(action) || "unsaved".equals(action)) {
|
||||
FeedEntry entry = feedEntryDAO.findById(id);
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
feedEntryService.starEntry(user, id, sub.getId(), "saved".equals(action));
|
||||
feedEntryService.starEntry(user, id, "saved".equals(action));
|
||||
}
|
||||
} else if ("feed".equals(source)) {
|
||||
FeedSubscription subscription = feedSubscriptionDAO.findById(user, id);
|
||||
|
||||
@@ -216,11 +216,10 @@ public class GoogleReaderREST {
|
||||
List<GoogleReaderModel.Tag> tags = new ArrayList<>();
|
||||
feedCategoryDAO
|
||||
.findAll(user)
|
||||
.forEach(
|
||||
c ->
|
||||
tags.add(
|
||||
new GoogleReaderModel.Tag(
|
||||
GoogleReaderStreamId.label(c.getName()))));
|
||||
.forEach(c ->
|
||||
tags.add(
|
||||
new GoogleReaderModel.Tag(
|
||||
GoogleReaderStreamId.label(c.getName()))));
|
||||
tags.add(new GoogleReaderModel.Tag(GoogleReaderStreamId.STARRED));
|
||||
|
||||
GoogleReaderModel.TagList resp = new GoogleReaderModel.TagList();
|
||||
@@ -259,21 +258,19 @@ public class GoogleReaderREST {
|
||||
Map<Long, FeedCategory> categoriesById =
|
||||
feedCategoryDAO.findAll(user).stream()
|
||||
.collect(Collectors.toMap(FeedCategory::getId, c -> c));
|
||||
unreadByCategory.forEach(
|
||||
(categoryId, count) -> {
|
||||
if (categoryId == 0) {
|
||||
return;
|
||||
}
|
||||
FeedCategory category = categoriesById.get(categoryId);
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
GoogleReaderModel.UnreadCountEntry entry =
|
||||
new GoogleReaderModel.UnreadCountEntry();
|
||||
entry.setId(GoogleReaderStreamId.label(category.getName()));
|
||||
entry.setCount(count);
|
||||
entries.add(entry);
|
||||
});
|
||||
unreadByCategory.forEach((categoryId, count) -> {
|
||||
if (categoryId == 0) {
|
||||
return;
|
||||
}
|
||||
FeedCategory category = categoriesById.get(categoryId);
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
GoogleReaderModel.UnreadCountEntry entry = new GoogleReaderModel.UnreadCountEntry();
|
||||
entry.setId(GoogleReaderStreamId.label(category.getName()));
|
||||
entry.setCount(count);
|
||||
entries.add(entry);
|
||||
});
|
||||
|
||||
GoogleReaderModel.UnreadCountEntry readingList = new GoogleReaderModel.UnreadCountEntry();
|
||||
readingList.setId(GoogleReaderStreamId.READING_LIST);
|
||||
@@ -334,10 +331,8 @@ public class GoogleReaderREST {
|
||||
GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs();
|
||||
resp.setItemRefs(
|
||||
paged.statuses().stream()
|
||||
.map(
|
||||
s ->
|
||||
new GoogleReaderModel.ItemRef(
|
||||
String.valueOf(s.getEntry().getId())))
|
||||
.map(s ->
|
||||
new GoogleReaderModel.ItemRef(String.valueOf(s.getEntry().getId())))
|
||||
.toList());
|
||||
resp.setContinuation(paged.continuation());
|
||||
return resp;
|
||||
@@ -402,16 +397,11 @@ public class GoogleReaderREST {
|
||||
if (markUnread) {
|
||||
feedEntryService.markEntry(user, entryId, false);
|
||||
}
|
||||
if (star || unstar) {
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
if (sub != null) {
|
||||
if (star) {
|
||||
feedEntryService.starEntry(user, entryId, sub.getId(), true);
|
||||
}
|
||||
if (unstar) {
|
||||
feedEntryService.starEntry(user, entryId, sub.getId(), false);
|
||||
}
|
||||
}
|
||||
if (star) {
|
||||
feedEntryService.starEntry(user, entryId, true);
|
||||
}
|
||||
if (unstar) {
|
||||
feedEntryService.starEntry(user, entryId, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,20 +38,18 @@ public class DatabaseApiKeyIdentityProvider
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
TokenAuthenticationRequest request, AuthenticationRequestContext context) {
|
||||
return context.runBlocking(
|
||||
() -> {
|
||||
Optional<User> user =
|
||||
unitOfWork.call(() -> userService.login(request.getToken().getToken()));
|
||||
if (user.isEmpty()) {
|
||||
throw new AuthenticationFailedException(
|
||||
"could not find a user with this api key");
|
||||
}
|
||||
return context.runBlocking(() -> {
|
||||
Optional<User> user =
|
||||
unitOfWork.call(() -> userService.login(request.getToken().getToken()));
|
||||
if (user.isEmpty()) {
|
||||
throw new AuthenticationFailedException("could not find a user with this api key");
|
||||
}
|
||||
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import com.commafeed.backend.dao.UnitOfWork;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserRole.Role;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.frontend.exception.CommaFeedApplicationException;
|
||||
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||
|
||||
import io.quarkus.security.AuthenticationFailedException;
|
||||
import io.quarkus.security.identity.AuthenticationRequestContext;
|
||||
import io.quarkus.security.identity.IdentityProvider;
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
@@ -38,24 +39,22 @@ public class DatabaseUsernamePasswordIdentityProvider
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
UsernamePasswordAuthenticationRequest request, AuthenticationRequestContext context) {
|
||||
return context.runBlocking(
|
||||
() -> {
|
||||
Optional<User> user =
|
||||
unitOfWork.call(
|
||||
() ->
|
||||
userService.login(
|
||||
request.getUsername(),
|
||||
new String(
|
||||
request.getPassword().getPassword())));
|
||||
if (user.isEmpty()) {
|
||||
throw new AuthenticationFailedException("wrong username or password");
|
||||
}
|
||||
return context.runBlocking(() -> {
|
||||
Optional<User> user =
|
||||
unitOfWork.call(() ->
|
||||
userService.login(
|
||||
request.getUsername(),
|
||||
new String(request.getPassword().getPassword())));
|
||||
if (user.isEmpty()) {
|
||||
throw new CommaFeedApplicationException(
|
||||
CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);
|
||||
}
|
||||
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId())))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,24 +40,23 @@ public class TrustedIdentityProvider implements IdentityProvider<TrustedAuthenti
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
TrustedAuthenticationRequest request, AuthenticationRequestContext context) {
|
||||
return context.runBlocking(
|
||||
() -> {
|
||||
Long userId = Long.valueOf(request.getPrincipal());
|
||||
User user = unitOfWork.call(() -> userDAO.findById(userId));
|
||||
if (user == null) {
|
||||
throw new AuthenticationFailedException("user not found");
|
||||
}
|
||||
return context.runBlocking(() -> {
|
||||
Long userId = Long.valueOf(request.getPrincipal());
|
||||
User user = unitOfWork.call(() -> userDAO.findById(userId));
|
||||
if (user == null) {
|
||||
throw new AuthenticationFailedException("user not found");
|
||||
}
|
||||
|
||||
// execute post login activities manually because we didn't call login() since
|
||||
// we received
|
||||
// a trusted authentication request
|
||||
unitOfWork.run(() -> postLoginActivities.executeFor(user));
|
||||
// execute post login activities manually because we didn't call login() since
|
||||
// we received
|
||||
// a trusted authentication request
|
||||
unitOfWork.run(() -> postLoginActivities.executeFor(user));
|
||||
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(userId)))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user));
|
||||
return QuarkusSecurityIdentity.builder()
|
||||
.setPrincipal(new QuarkusPrincipal(String.valueOf(userId)))
|
||||
.addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet()))
|
||||
.build();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,10 @@ class NativeImageClassesTest {
|
||||
WireFeedGenerator.class)) {
|
||||
Set<Class<?>> moduleClasses =
|
||||
new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass()));
|
||||
moduleClasses.removeIf(
|
||||
c ->
|
||||
c.isInterface()
|
||||
|| Modifier.isAbstract(c.getModifiers())
|
||||
|| !Modifier.isPublic(c.getModifiers()));
|
||||
moduleClasses.removeIf(c ->
|
||||
c.isInterface()
|
||||
|| Modifier.isAbstract(c.getModifiers())
|
||||
|| !Modifier.isPublic(c.getModifiers()));
|
||||
moduleClasses.removeAll(classesInAnnotation);
|
||||
missingClasses.addAll(moduleClasses);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.google.common.net.HttpHeaders;
|
||||
|
||||
import io.quarkus.runtime.configuration.MemorySize;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.hc.client5.http.ConnectTimeoutException;
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
@@ -29,12 +31,12 @@ import org.mockserver.model.Delay;
|
||||
import org.mockserver.model.HttpRequest;
|
||||
import org.mockserver.model.HttpResponse;
|
||||
import org.mockserver.model.MediaType;
|
||||
import org.mockserver.model.Protocol;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -45,6 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
@Slf4j
|
||||
class HttpGetterTest {
|
||||
|
||||
private static final Instant NOW = Instant.now();
|
||||
@@ -237,7 +240,7 @@ class HttpGetterTest {
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(HttpResponse.response().withDelay(Delay.milliseconds(1000)));
|
||||
|
||||
Assertions.assertThrows(SocketTimeoutException.class, () -> getter.get(this.feedUrl));
|
||||
Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -314,18 +317,17 @@ class HttpGetterTest {
|
||||
|
||||
this.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
calls.incrementAndGet();
|
||||
.respond(req -> {
|
||||
calls.incrementAndGet();
|
||||
|
||||
if (req.containsHeader(HttpHeaders.COOKIE)) {
|
||||
throw new Exception("cookie should not be sent by the client");
|
||||
}
|
||||
if (req.containsHeader(HttpHeaders.COOKIE)) {
|
||||
throw new Exception("cookie should not be sent by the client");
|
||||
}
|
||||
|
||||
return HttpResponse.response()
|
||||
.withBody("ok")
|
||||
.withHeader(HttpHeaders.SET_COOKIE, "foo=bar");
|
||||
});
|
||||
return HttpResponse.response()
|
||||
.withBody("ok")
|
||||
.withHeader(HttpHeaders.SET_COOKIE, "foo=bar");
|
||||
});
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl));
|
||||
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl + "?foo=bar"));
|
||||
@@ -338,11 +340,10 @@ class HttpGetterTest {
|
||||
|
||||
this.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
calls.incrementAndGet();
|
||||
return HttpResponse.response().withBody("ok");
|
||||
});
|
||||
.respond(req -> {
|
||||
calls.incrementAndGet();
|
||||
return HttpResponse.response().withBody("ok");
|
||||
});
|
||||
|
||||
HttpResult result = getter.get(this.feedUrl);
|
||||
Assertions.assertEquals(result, getter.get(this.feedUrl));
|
||||
@@ -397,17 +398,15 @@ class HttpGetterTest {
|
||||
|
||||
this.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
calls.incrementAndGet();
|
||||
.respond(req -> {
|
||||
calls.incrementAndGet();
|
||||
|
||||
if (req.containsHeader(HttpHeaders.UPGRADE)) {
|
||||
throw new Exception(
|
||||
"upgrade header should not be sent by the client");
|
||||
}
|
||||
if (req.containsHeader(HttpHeaders.UPGRADE)) {
|
||||
throw new Exception("upgrade header should not be sent by the client");
|
||||
}
|
||||
|
||||
return HttpResponse.response().withBody("ok");
|
||||
});
|
||||
return HttpResponse.response().withBody("ok");
|
||||
});
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl));
|
||||
Assertions.assertEquals(1, calls.get());
|
||||
@@ -416,7 +415,7 @@ class HttpGetterTest {
|
||||
@Nested
|
||||
class Compression {
|
||||
|
||||
private static final String ACCEPT_ENCODING = "gzip, deflate, br";
|
||||
private static final String ACCEPT_ENCODING = "deflate, gzip";
|
||||
|
||||
@Test
|
||||
void gzip() throws Exception {
|
||||
@@ -435,27 +434,30 @@ class HttpGetterTest {
|
||||
HttpGetterTest.this
|
||||
.mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
req -> {
|
||||
String acceptEncodingHeader =
|
||||
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
|
||||
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
|
||||
throw new Exception(
|
||||
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
|
||||
.formatted(
|
||||
ACCEPT_ENCODING, acceptEncodingHeader));
|
||||
}
|
||||
.respond(req -> {
|
||||
try {
|
||||
String acceptEncodingHeader =
|
||||
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
|
||||
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
|
||||
throw new Exception(
|
||||
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
|
||||
.formatted(ACCEPT_ENCODING, acceptEncodingHeader));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (OutputStream compressionOutputStream =
|
||||
compressionFunction.apply(output)) {
|
||||
compressionOutputStream.write(body.getBytes());
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (OutputStream compressionOutputStream =
|
||||
compressionFunction.apply(output)) {
|
||||
compressionOutputStream.write(body.getBytes());
|
||||
}
|
||||
|
||||
return HttpResponse.response()
|
||||
.withBody(output.toByteArray())
|
||||
.withHeader(HttpHeaders.CONTENT_ENCODING, encoding);
|
||||
});
|
||||
return HttpResponse.response()
|
||||
.withBody(output.toByteArray())
|
||||
.withHeader(HttpHeaders.CONTENT_ENCODING, encoding);
|
||||
} catch (Exception e) {
|
||||
log.error("Error in compression callback", e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
HttpResult result = getter.get(HttpGetterTest.this.feedUrl);
|
||||
Assertions.assertEquals(body, new String(result.content()));
|
||||
@@ -590,4 +592,30 @@ class HttpGetterTest {
|
||||
UnknownHostException.class, () -> getter.get("http://[::a00:1]")); // 10.0.0.1
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Protocols {
|
||||
@Test
|
||||
void usesHttp2OverTls() throws Exception {
|
||||
mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_2))
|
||||
.respond(HttpResponse.response().withBody("h2"));
|
||||
mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
|
||||
.respond(HttpResponse.response().withBody("h1"));
|
||||
|
||||
HttpResult result = getter.get("https://localhost:" + mockServerClient.getPort());
|
||||
Assertions.assertEquals("h2", new String(result.content()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToHttp1OverCleartext() throws Exception {
|
||||
mockServerClient
|
||||
.when(HttpRequest.request().withMethod("GET").withProtocol(Protocol.HTTP_1_1))
|
||||
.respond(HttpResponse.response().withBody("h1"));
|
||||
|
||||
HttpResult result = getter.get("http://localhost:" + mockServerClient.getPort());
|
||||
Assertions.assertEquals("h1", new String(result.content()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] apiResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult apiHttpResult =
|
||||
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
|
||||
@@ -102,7 +103,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] apiResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult apiHttpResult =
|
||||
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
|
||||
@@ -136,7 +138,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] playlistResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"channelId":"testChannelId"}}]}"""
|
||||
{"items":[{"snippet":{"channelId":"testChannelId"}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult playlistHttpResult =
|
||||
new HttpResult(
|
||||
@@ -151,7 +154,8 @@ class YoutubeFaviconFetcherTest {
|
||||
|
||||
byte[] channelResponse =
|
||||
"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}"""
|
||||
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
|
||||
"""
|
||||
.getBytes();
|
||||
HttpResult channelHttpResult =
|
||||
new HttpResult(
|
||||
|
||||
@@ -226,14 +226,16 @@ class XMLCleanerTest {
|
||||
void testRemoveMultilineDoctype() {
|
||||
String source =
|
||||
"""
|
||||
<!DOCTYPE
|
||||
html
|
||||
>
|
||||
<html><head></head><body></body></html>""";
|
||||
<!DOCTYPE
|
||||
html
|
||||
>
|
||||
<html><head></head><body></body></html>\
|
||||
""";
|
||||
Assertions.assertEquals(
|
||||
"""
|
||||
|
||||
<html><head></head><body></body></html>""",
|
||||
<html><head></head><body></body></html>\
|
||||
""",
|
||||
xmlCleaner.removeDoctypeDeclarations(source));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,23 +12,23 @@ class FeedEntryContentCleaningServiceTest {
|
||||
void testClean() {
|
||||
String content =
|
||||
"""
|
||||
<p>
|
||||
Some text
|
||||
<img width="965" height="320" src="https://localhost/an-image.png" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="alt-desc" decoding="async" sizes="(max-width: 965px) 100vw, 965px" style="width: 100%; opacity: 0">
|
||||
<iframe src="url" style="width: 100%; opacity: 0"></iframe>
|
||||
<forbidden-element>aaa</forbidden-element>
|
||||
""";
|
||||
<p>
|
||||
Some text
|
||||
<img width="965" height="320" src="https://localhost/an-image.png" class="attachment-post-thumbnail size-post-thumbnail wp-post-image" alt="alt-desc" decoding="async" sizes="(max-width: 965px) 100vw, 965px" style="width: 100%; opacity: 0">
|
||||
<iframe src="https://localhost/an-iframe" style="width: 100%; opacity: 0"></iframe>
|
||||
<forbidden-element>aaa</forbidden-element>
|
||||
""";
|
||||
String result = feedEntryContentCleaningService.clean(content, "baseUri", false);
|
||||
|
||||
Assertions.assertLinesMatch(
|
||||
"""
|
||||
<p>
|
||||
Some text
|
||||
<img width="965" height="320" src="https://localhost/an-image.png" alt="alt-desc" style="width:100%;">
|
||||
<iframe src="url" style="width:100%;"></iframe>
|
||||
aaa
|
||||
</p>
|
||||
"""
|
||||
<p>
|
||||
Some text
|
||||
<img width="965" height="320" src="https://localhost/an-image.png" alt="alt-desc" style="width:100%;">
|
||||
<iframe src="https://localhost/an-iframe" style="width:100%;"></iframe>
|
||||
aaa
|
||||
</p>
|
||||
"""
|
||||
.lines(),
|
||||
result.lines());
|
||||
}
|
||||
|
||||
@@ -98,19 +98,19 @@ class PushNotificationServiceTest {
|
||||
.withBody(
|
||||
JsonBody.json(
|
||||
"""
|
||||
{
|
||||
"title": "Test Feed",
|
||||
"message": "Test Entry",
|
||||
"priority": 5,
|
||||
"extras": {
|
||||
"client::notification": {
|
||||
"click": {
|
||||
"url": "http://example.com/entry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""")))
|
||||
{
|
||||
"title": "Test Feed",
|
||||
"message": "Test Entry",
|
||||
"priority": 5,
|
||||
"extras": {
|
||||
"client::notification": {
|
||||
"click": {
|
||||
"url": "http://example.com/entry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""")))
|
||||
.respond(HttpResponse.response().withStatusCode(200));
|
||||
|
||||
Assertions.assertDoesNotThrow(
|
||||
|
||||
@@ -14,14 +14,15 @@ class InPageReferenceFeedURLProviderTest {
|
||||
String url = "http://example.com";
|
||||
String html =
|
||||
"""
|
||||
<html>
|
||||
<head>
|
||||
<link type="application/atom+xml" href="/feed.atom">
|
||||
<link type="application/rss+xml" href="/feed.rss">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>""";
|
||||
<html>
|
||||
<head>
|
||||
<link type="application/atom+xml" href="/feed.atom">
|
||||
<link type="application/rss+xml" href="/feed.rss">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>\
|
||||
""";
|
||||
|
||||
Assertions.assertIterableEquals(
|
||||
List.of("http://example.com/feed.atom", "http://example.com/feed.rss"),
|
||||
@@ -33,9 +34,10 @@ class InPageReferenceFeedURLProviderTest {
|
||||
String url = "http://example.com";
|
||||
String html =
|
||||
"""
|
||||
<?xml version="1.0"?>
|
||||
<feed></feed>
|
||||
</xml>""";
|
||||
<?xml version="1.0"?>
|
||||
<feed></feed>
|
||||
</xml>\
|
||||
""";
|
||||
|
||||
Assertions.assertTrue(provider.get(url, html).isEmpty());
|
||||
}
|
||||
@@ -45,13 +47,14 @@ class InPageReferenceFeedURLProviderTest {
|
||||
String url = "http://example.com";
|
||||
String html =
|
||||
"""
|
||||
<html>
|
||||
<head>
|
||||
<link type="text/css" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>""";
|
||||
<html>
|
||||
<head>
|
||||
<link type="text/css" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>\
|
||||
""";
|
||||
|
||||
Assertions.assertTrue(provider.get(url, html).isEmpty());
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class AuthentificationIT {
|
||||
page.navigate(getLoginPageUrl());
|
||||
PlaywrightTestUtils.login(page, TestConstants.ADMIN_USERNAME, "wrong_password");
|
||||
PlaywrightAssertions.assertThat(page.getByRole(AriaRole.ALERT))
|
||||
.containsText("wrong username or password");
|
||||
.containsText("Wrong username or password");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user