mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54da4e6839 | ||
|
|
3a6b4c588c | ||
|
|
48071b9fd1 | ||
|
|
f519aa039f | ||
|
|
dc3e5476a1 | ||
|
|
903035ecfc | ||
|
|
13ad57da10 | ||
|
|
44bc24c22a | ||
|
|
97f90405fc | ||
|
|
0fc2a0b022 | ||
|
|
89eb641704 | ||
|
|
c53da9f631 | ||
|
|
998868e63a | ||
|
|
93f22d2351 | ||
|
|
c3782bd7d2 | ||
|
|
f330349397 | ||
|
|
99c973c8c2 | ||
|
|
469420b5bf | ||
|
|
bde556d41f | ||
|
|
bf6c2d7beb | ||
|
|
fa62ca21e0 | ||
|
|
7dcf76da84 | ||
|
|
3dc80fa762 | ||
|
|
dbce12492b | ||
|
|
85f5eaffec | ||
|
|
106276351e | ||
|
|
961fb6a464 | ||
|
|
ac3d9ef57f | ||
|
|
3478ee4815 | ||
|
|
3dc02d7ba1 | ||
|
|
c886f8b83c | ||
|
|
4a2154d0b3 | ||
|
|
ba530d5019 | ||
|
|
85b6209c52 | ||
|
|
7ff86a5e31 | ||
|
|
8edd6a1e2d | ||
|
|
6e65ed49e9 | ||
|
|
711b01abfa | ||
|
|
c7014ca2a1 | ||
|
|
a3984cd959 | ||
|
|
8d85b1bcba | ||
|
|
c451eee406 | ||
|
|
8f42135996 | ||
|
|
2c26aeed17 | ||
|
|
e2c4aa998b | ||
|
|
c9e3b7f349 | ||
|
|
ebb4e52ba7 | ||
|
|
1ddfdfb12e | ||
|
|
81f16aea62 | ||
|
|
429ec193c8 | ||
|
|
732b714448 | ||
|
|
82e0405ad9 | ||
|
|
9ef002fcd1 | ||
|
|
ec938e416c | ||
|
|
37cf711cbc | ||
|
|
de441e4ff7 | ||
|
|
46251526b6 | ||
|
|
67eeea0b06 | ||
|
|
b49ccc4cd9 | ||
|
|
8586a8b57b | ||
|
|
d9f63786a8 | ||
|
|
8f0c8b68b9 | ||
|
|
15e574c5c4 | ||
|
|
fe532242b4 | ||
|
|
fb48ff0858 | ||
|
|
8d850639d7 | ||
|
|
ee73195915 | ||
|
|
72d9dad61b | ||
|
|
fde8dab8cd | ||
|
|
dae5efa787 | ||
|
|
3c067140fd | ||
|
|
4ccbe81e87 | ||
|
|
3d5d93bb72 | ||
|
|
4138b6eb9b | ||
|
|
9c39c95a9b | ||
|
|
32b2bf99a4 | ||
|
|
cf459876af | ||
|
|
6698bd74b5 | ||
|
|
c81d06e5f3 | ||
|
|
b12a78dc84 | ||
|
|
b076587e44 | ||
|
|
bb12f16bea | ||
|
|
e80caadd12 | ||
|
|
846d93f2b2 | ||
|
|
0ed6f6ef9c | ||
|
|
15992dcb80 | ||
|
|
1a5c399b54 | ||
|
|
5e92f9ffb8 | ||
|
|
71164d1b69 | ||
|
|
6947670fe6 | ||
|
|
30810e37b9 | ||
|
|
b17b2767b0 | ||
|
|
d37cf5bbcf | ||
|
|
045baba705 | ||
|
|
3623dc8e1d | ||
|
|
2610c37067 | ||
|
|
286b69a646 | ||
|
|
9673f27090 | ||
|
|
0722599f6d | ||
|
|
1df40d8370 | ||
|
|
457e4ec69e | ||
|
|
647310a45f | ||
|
|
e85c92f216 | ||
|
|
d93b0dbfd4 | ||
|
|
b4e61ef547 | ||
|
|
71dffbba46 | ||
|
|
2c0b0c4e3b | ||
|
|
d868e58e1e | ||
|
|
90eb2095bf | ||
|
|
62d3ed16e6 | ||
|
|
74f7c48818 | ||
|
|
23fe9c29ed | ||
|
|
8f7be8278a | ||
|
|
49118b6ea0 | ||
|
|
d97bd04ae2 | ||
|
|
8d11309b64 | ||
|
|
68c24e4cb8 | ||
|
|
4e43e0235f | ||
|
|
62b79a9625 | ||
|
|
cb0706808c | ||
|
|
ffd5704b1e | ||
|
|
3987077e5a | ||
|
|
2e01a76784 | ||
|
|
8254093f5f | ||
|
|
0b06526756 | ||
|
|
06731ae76d | ||
|
|
9a59453792 | ||
|
|
c195a52c89 | ||
|
|
3d7924f953 | ||
|
|
f29efd7fae | ||
|
|
157bff3c83 | ||
|
|
5c17bbc36d | ||
|
|
c85e72e70c | ||
|
|
01150f67e1 | ||
|
|
75aca7aa6f | ||
|
|
affde7e43c | ||
|
|
b9b1b53235 | ||
|
|
708ebb8abc | ||
|
|
83e763df0a | ||
|
|
0ff812c1ea | ||
|
|
3e9dd6d8e2 | ||
|
|
23af73e847 | ||
|
|
e79e4719fd | ||
|
|
23fef98432 | ||
|
|
22478252e7 | ||
|
|
76b1f3cd35 | ||
|
|
420d73ec6a | ||
|
|
e0211cfa0c | ||
|
|
25a92c651c | ||
|
|
0781205c69 | ||
|
|
5102dd5e30 | ||
|
|
6ccfc3fd67 | ||
|
|
2791ed91ab | ||
|
|
f40c198233 | ||
|
|
003dc63121 | ||
|
|
f8ef1e2a99 | ||
|
|
14c7078940 | ||
|
|
074836d3e8 | ||
|
|
0cdbc144b3 | ||
|
|
dc63ec24c0 | ||
|
|
6d4c6c36a5 | ||
|
|
464af5f4d9 | ||
|
|
aa94a46a3d | ||
|
|
8542197dc3 | ||
|
|
64d77eaef4 | ||
|
|
675ef8794c | ||
|
|
4bcdbeb516 | ||
|
|
a9f37739fb | ||
|
|
5ab0fc19da | ||
|
|
7b232425f3 | ||
|
|
c0e7668140 | ||
|
|
ae3f059257 | ||
|
|
d44c7c1e95 | ||
|
|
6cd9d134cf | ||
|
|
6f21ba8afc | ||
|
|
b2fe13c117 | ||
|
|
03ece7a262 | ||
|
|
697fde2d0e | ||
|
|
7f0f85b356 | ||
|
|
a7d41debfe | ||
|
|
57bf758108 | ||
|
|
b37d933047 | ||
|
|
80ffef4555 | ||
|
|
af5a0002aa | ||
|
|
cd24e412e3 | ||
|
|
a073d843ab | ||
|
|
8ccb59ed18 | ||
|
|
e6dc7d2d0d |
270
.github/workflows/ci.yml
vendored
270
.github/workflows/ci.yml
vendored
@@ -1,180 +1,224 @@
|
||||
name: ci
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on: [ push ]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
JAVA_VERSION: 21
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||
|
||||
steps:
|
||||
# Checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Setup
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up GraalVM
|
||||
uses: graalvm/setup-graalvm@v1
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: "graalvm"
|
||||
cache: "maven"
|
||||
|
||||
# Build & Test
|
||||
- name: Build with Maven
|
||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }}
|
||||
|
||||
# Upload artifacts
|
||||
- name: Upload cross-platform app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-jvm
|
||||
path: commafeed-server/target/commafeed-*.zip
|
||||
|
||||
- name: Upload native executable
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: commafeed-server/target/commafeed-*-runner
|
||||
|
||||
# Docker
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ github.ref_type == 'tag' || github.ref_name == 'master' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
## tags
|
||||
- name: Docker build and push tag - native
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
athou/commafeed:latest-${{ matrix.database }}
|
||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push tag - jvm
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: |
|
||||
athou/commafeed:latest-${{ matrix.database }}-jvm
|
||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
|
||||
|
||||
## master
|
||||
- name: Docker build and push master - native
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: athou/commafeed:master-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push master - jvm
|
||||
uses: docker/build-push-action@v6
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: athou/commafeed:master-${{ matrix.database }}-jvm
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
build:
|
||||
if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
|
||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
# Checkout
|
||||
- name: Configure git to checkout as-is
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Setup
|
||||
- name: Set up GraalVM
|
||||
uses: graalvm/setup-graalvm@v1
|
||||
uses: graalvm/setup-graalvm@b0cb26a8da53cb3e97cdc0c827d8e3071240e730 # v1
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: "graalvm"
|
||||
cache: "maven"
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: sudo apt-get install -y libgbm1
|
||||
if: matrix.os != 'windows-latest'
|
||||
|
||||
# Build & Test
|
||||
- name: Build with Maven
|
||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.database != 'h2' }}
|
||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||
|
||||
# Upload artifacts
|
||||
- name: Upload cross-platform app
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
|
||||
if: matrix.os == 'ubuntu-latest' # 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@v4
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: commafeed-server/target/commafeed-*-runner.exe
|
||||
path: commafeed-server/target/commafeed-*-runner*
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||
|
||||
steps:
|
||||
# Checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Setup
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3
|
||||
|
||||
- name: Install required packages
|
||||
run: sudo apt-get install -y rename unzip
|
||||
|
||||
# Prepare artifacts
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
with:
|
||||
pattern: commafeed-${{ matrix.database }}-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set the exec flag on the native executables
|
||||
run: chmod +x artifacts/*-runner
|
||||
|
||||
- name: Rename native executables to match buildx TARGETARCH
|
||||
run: |
|
||||
rename 's/x86_64/amd64/g' artifacts/*
|
||||
rename 's/aarch_64/arm64/g' artifacts/*
|
||||
|
||||
- name: Unzip jvm package
|
||||
run: |
|
||||
unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package
|
||||
rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/*
|
||||
|
||||
# Docker
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
|
||||
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
## build but don't push for PRs and renovate
|
||||
- name: Docker build - native
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||
push: false
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
|
||||
- name: Docker build - jvm
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||
push: false
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
|
||||
## build and push tag
|
||||
- name: Docker build and push tag - native
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: |
|
||||
athou/commafeed:latest-${{ matrix.database }}
|
||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push tag - jvm
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: |
|
||||
athou/commafeed:latest-${{ matrix.database }}-jvm
|
||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
|
||||
|
||||
## build and push master
|
||||
- name: Docker build and push master - native
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: athou/commafeed:master-${{ matrix.database }}
|
||||
|
||||
- name: Docker build and push master - jvm
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: athou/commafeed:master-${{ matrix.database }}-jvm
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-linux
|
||||
- build-windows
|
||||
- build
|
||||
- docker
|
||||
permissions:
|
||||
contents: write
|
||||
if: github.ref_type == 'tag'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
with:
|
||||
pattern: commafeed-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set the exec flag on the native executables
|
||||
run: chmod +x artifacts/*-runner
|
||||
|
||||
- name: Extract Changelog Entry
|
||||
uses: mindsers/changelog-reader-action@v2
|
||||
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2
|
||||
id: changelog_reader
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1
|
||||
with:
|
||||
name: CommaFeed ${{ github.ref_name }}
|
||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||
artifacts: ./artifacts/*
|
||||
|
||||
- name: Update Docker Hub Description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
- 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
|
||||
|
||||
## [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
|
||||
- Audio enclosures (e.g. podcasts) now fill available entry width
|
||||
- Fix an issue with some labels not correctly internationalized
|
||||
|
||||
## [5.4.0]
|
||||
|
||||
- An arm64 native executable is now available for download on the releases page
|
||||
- The native executable Docker image now supports arm64
|
||||
- Fixed an issue with feeds that declared an invalid DOCTYPE (#1260)
|
||||
|
||||
## [5.3.6]
|
||||
|
||||
- Ignore invalid Cache-Control header values (#1619)
|
||||
|
||||
## [5.3.5]
|
||||
|
||||
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
||||
|
||||
@@ -94,7 +94,7 @@ There are multiple ways to configure CommaFeed:
|
||||
|
||||
- a `config/application.properties` [properties](https://en.wikipedia.org/wiki/.properties) file relative to the working
|
||||
directory (keys in kebab-case)
|
||||
- Command line arguments prefixed with `-D` (keys in kebab-case)
|
||||
- Command line arguments each prefixed with `-D` (keys in kebab-case)
|
||||
- Environment variables (keys in UPPER_CASE)
|
||||
- a `.env` file in the working directory (keys in UPPER_CASE)
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>CommaFeed</title>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="custom_css.css" />
|
||||
<script type="text/javascript" src="custom_js.js"></script>
|
||||
|
||||
<title>CommaFeed</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
3895
commafeed-client/package-lock.json
generated
3895
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,66 +15,68 @@
|
||||
"i18n:extract": "lingui extract --clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.5",
|
||||
"@fontsource/open-sans": "^5.1.0",
|
||||
"@lingui/core": "^5.0.0",
|
||||
"@lingui/react": "^5.0.0",
|
||||
"@mantine/core": "^7.14.3",
|
||||
"@mantine/form": "^7.14.3",
|
||||
"@mantine/hooks": "^7.14.3",
|
||||
"@mantine/modals": "^7.14.3",
|
||||
"@mantine/notifications": "^7.14.3",
|
||||
"@mantine/spotlight": "^7.14.3",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@reduxjs/toolkit": "^2.4.0",
|
||||
"axios": "^1.7.8",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fontsource/open-sans": "^5.1.1",
|
||||
"@mantine/core": "^7.16.3",
|
||||
"@mantine/form": "^7.16.3",
|
||||
"@mantine/hooks": "^7.16.3",
|
||||
"@mantine/modals": "^7.16.3",
|
||||
"@mantine/notifications": "^7.16.3",
|
||||
"@mantine/spotlight": "^7.16.3",
|
||||
"@lingui/core": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"interweave": "^13.1.0",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"interweave": "^13.1.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^18.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-async-hook": "^4.0.0",
|
||||
"react-contexify": "^6.0.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"redoc": "^2.2.0",
|
||||
"redoc": "^2.4.0",
|
||||
"style-to-object": "^1.0.8",
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tinycon": "^0.6.8",
|
||||
"tss-react": "^4.9.13",
|
||||
"tss-react": "^4.9.15",
|
||||
"websocket-heartbeat-js": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.0.0",
|
||||
"@lingui/cli": "^5.0.0",
|
||||
"@lingui/vite-plugin": "^5.0.0",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
||||
"@lingui/cli": "^5.2.0",
|
||||
"@lingui/vite-plugin": "^5.2.0",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-infinite-scroller": "^1.2.5",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@types/tinycon": "^0.6.7",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"jsdom": "^26.0.0",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-checker": "^0.8.0",
|
||||
"vite-tsconfig-paths": "^5.1.3",
|
||||
"vitest": "^2.1.6",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.5",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"react-infinite-scroller": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>5.3.5</version>
|
||||
<version>5.6.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<name>CommaFeed Client</name>
|
||||
|
||||
<properties>
|
||||
<!-- renovate: datasource=node-version depName=node -->
|
||||
<node.version>v22.11.0</node.version>
|
||||
<node.version>v22.14.0</node.version>
|
||||
<!-- renovate: datasource=npm depName=npm -->
|
||||
<npm.version>10.9.1</npm.version>
|
||||
<npm.version>11.1.0</npm.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -32,7 +32,6 @@ import { RegistrationPage } from "pages/auth/RegistrationPage"
|
||||
import React, { useEffect } from "react"
|
||||
import { isSafari } from "react-device-detect"
|
||||
import ReactGA from "react-ga4"
|
||||
import { Helmet } from "react-helmet"
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
||||
import Tinycon from "tinycon"
|
||||
|
||||
@@ -143,7 +142,7 @@ function GoogleAnalyticsHandler() {
|
||||
}
|
||||
|
||||
function UnreadCountTitleHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
||||
return <Helmet title={enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"} />
|
||||
return <title>{enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"}</title>
|
||||
}
|
||||
|
||||
function UnreadCountFaviconHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
||||
@@ -170,15 +169,6 @@ function BrowserExtensionBadgeUnreadCountHandler() {
|
||||
return null
|
||||
}
|
||||
|
||||
function CustomCode() {
|
||||
return (
|
||||
<Helmet>
|
||||
<link rel="stylesheet" type="text/css" href="custom_css.css" />
|
||||
<script type="text/javascript" src="custom_js.js" />
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useI18n()
|
||||
const root = useAppSelector(state => state.tree.rootCategory)
|
||||
@@ -202,7 +192,6 @@ export function App() {
|
||||
<GoogleAnalyticsHandler />
|
||||
<RedirectHandler />
|
||||
<AppRoutes />
|
||||
<CustomCode />
|
||||
{/* disable pull-to-refresh as it messes with vertical scrolling
|
||||
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
|
||||
https://github.com/Athou/commafeed/issues/1168
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { IconType } from "react-icons"
|
||||
import { FaAt } from "react-icons/fa"
|
||||
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiX } from "react-icons/si"
|
||||
import type { Category, Entry, SharingSettings } from "./types"
|
||||
|
||||
const categories: Record<string, Category> = {
|
||||
const categories: Record<string, Omit<Category, "name">> = {
|
||||
all: {
|
||||
id: "all",
|
||||
name: t`All`,
|
||||
expanded: false,
|
||||
children: [],
|
||||
feeds: [],
|
||||
@@ -15,7 +13,6 @@ const categories: Record<string, Category> = {
|
||||
},
|
||||
starred: {
|
||||
id: "starred",
|
||||
name: t`Starred`,
|
||||
expanded: false,
|
||||
children: [],
|
||||
feeds: [],
|
||||
|
||||
@@ -11,6 +11,7 @@ import { flushSync } from "react-dom"
|
||||
|
||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||
|
||||
export const loadEntries = createAppAsyncThunk(
|
||||
"entries/load",
|
||||
async (
|
||||
@@ -28,6 +29,7 @@ export const loadEntries = createAppAsyncThunk(
|
||||
return result.data
|
||||
}
|
||||
)
|
||||
|
||||
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { source } = state.entries
|
||||
@@ -37,6 +39,7 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||
return result.data
|
||||
})
|
||||
|
||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||
order: state.user.settings?.readingOrder,
|
||||
@@ -46,15 +49,18 @@ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource,
|
||||
tag: source.type === "tag" ? source.id : undefined,
|
||||
keywords: state.entries.search,
|
||||
})
|
||||
|
||||
export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
|
||||
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(setSearch(arg))
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
|
||||
export const markEntry = createAppAsyncThunk(
|
||||
"entries/entry/mark",
|
||||
(arg: { entry: Entry; read: boolean }) => {
|
||||
@@ -67,6 +73,7 @@ export const markEntry = createAppAsyncThunk(
|
||||
condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
|
||||
}
|
||||
)
|
||||
|
||||
export const markMultipleEntries = createAppAsyncThunk(
|
||||
"entries/entry/markMultiple",
|
||||
async (
|
||||
@@ -84,6 +91,7 @@ export const markMultipleEntries = createAppAsyncThunk(
|
||||
thunkApi.dispatch(reloadTree())
|
||||
}
|
||||
)
|
||||
|
||||
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
@@ -98,6 +106,7 @@ export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
export const markAllEntries = createAppAsyncThunk(
|
||||
"entries/entry/markAll",
|
||||
async (
|
||||
@@ -113,6 +122,7 @@ export const markAllEntries = createAppAsyncThunk(
|
||||
thunkApi.dispatch(reloadTree())
|
||||
}
|
||||
)
|
||||
|
||||
export const starEntry = createAppAsyncThunk(
|
||||
"entries/entry/star",
|
||||
(arg: { entry: Entry; starred: boolean }) => {
|
||||
@@ -126,6 +136,7 @@ export const starEntry = createAppAsyncThunk(
|
||||
condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
|
||||
}
|
||||
)
|
||||
|
||||
export const selectEntry = createAppAsyncThunk(
|
||||
"entries/entry/select",
|
||||
(
|
||||
@@ -191,6 +202,7 @@ export const selectEntry = createAppAsyncThunk(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
||||
const offset = (header?.bottom ?? 0) + margin
|
||||
@@ -228,6 +240,7 @@ export const selectPreviousEntry = createAppAsyncThunk(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const selectNextEntry = createAppAsyncThunk(
|
||||
"entries/entry/selectNext",
|
||||
async (
|
||||
@@ -261,6 +274,7 @@ export const selectNextEntry = createAppAsyncThunk(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
||||
await client.entry.tag(arg)
|
||||
thunkApi.dispatch(reloadTags())
|
||||
|
||||
@@ -3,43 +3,55 @@ import { Constants } from "app/constants"
|
||||
import { redirectTo } from "app/redirect/slice"
|
||||
|
||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
||||
|
||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
||||
export const redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/passwordRecovery"))
|
||||
)
|
||||
|
||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
||||
|
||||
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
||||
const { source } = thunkApi.getState().entries
|
||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||
})
|
||||
|
||||
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||
)
|
||||
|
||||
export const redirectToRootCategory = createAppAsyncThunk(
|
||||
"redirect/category/root",
|
||||
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||
)
|
||||
|
||||
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||
)
|
||||
|
||||
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||
)
|
||||
|
||||
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||
)
|
||||
|
||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
||||
|
||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||
)
|
||||
|
||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
||||
|
||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
||||
|
||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||
)
|
||||
|
||||
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||
)
|
||||
|
||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
||||
|
||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { incrementUnreadCount } from "app/tree/slice"
|
||||
import type { CollapseRequest } from "app/types"
|
||||
import { flattenCategoryTree } from "app/utils"
|
||||
|
||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
||||
|
||||
export const collapseTreeCategory = createAppAsyncThunk(
|
||||
"tree/category/collapse",
|
||||
async (req: CollapseRequest) => await client.category.collapse(req)
|
||||
)
|
||||
|
||||
export const newFeedEntriesDiscovered = createAppAsyncThunk(
|
||||
"tree/new-feed-entries-discovered",
|
||||
async ({ feedId, amount }: { feedId: number; amount: number }, thunkApi) => {
|
||||
const root = thunkApi.getState().tree.rootCategory
|
||||
if (!root) return
|
||||
|
||||
const feed = flattenCategoryTree(root)
|
||||
.flatMap(c => c.feeds)
|
||||
.some(f => f.id === feedId)
|
||||
if (!feed) {
|
||||
// feed not found in the tree, reload the tree completely
|
||||
thunkApi.dispatch(reloadTree())
|
||||
} else {
|
||||
thunkApi.dispatch(
|
||||
incrementUnreadCount({
|
||||
feedId,
|
||||
amount,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,45 +4,55 @@ import { reloadEntries } from "app/entries/thunks"
|
||||
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types"
|
||||
|
||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
||||
|
||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
||||
|
||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
||||
|
||||
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingMode })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
})
|
||||
|
||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingOrder })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
})
|
||||
|
||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, language })
|
||||
})
|
||||
|
||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||
})
|
||||
|
||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, showRead })
|
||||
})
|
||||
|
||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollMarks })
|
||||
})
|
||||
|
||||
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollMode })
|
||||
})
|
||||
|
||||
export const changeEntriesToKeepOnTopWhenScrolling = createAppAsyncThunk(
|
||||
"settings/entriesToKeepOnTopWhenScrolling",
|
||||
(entriesToKeepOnTopWhenScrolling: number, thunkApi) => {
|
||||
@@ -51,6 +61,7 @@ export const changeEntriesToKeepOnTopWhenScrolling = createAppAsyncThunk(
|
||||
client.user.saveSettings({ ...settings, entriesToKeepOnTopWhenScrolling })
|
||||
}
|
||||
)
|
||||
|
||||
export const changeStarIconDisplayMode = createAppAsyncThunk(
|
||||
"settings/starIconDisplayMode",
|
||||
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||
@@ -59,6 +70,7 @@ export const changeStarIconDisplayMode = createAppAsyncThunk(
|
||||
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
||||
}
|
||||
)
|
||||
|
||||
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
||||
"settings/externalLinkIconDisplayMode",
|
||||
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||
@@ -67,6 +79,7 @@ export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
||||
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
||||
}
|
||||
)
|
||||
|
||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||
"settings/markAllAsReadConfirmation",
|
||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
||||
@@ -75,26 +88,31 @@ export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
||||
}
|
||||
)
|
||||
|
||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, customContextMenu })
|
||||
})
|
||||
|
||||
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, mobileFooter })
|
||||
})
|
||||
|
||||
export const changeUnreadCountTitle = createAppAsyncThunk("settings/unreadCountTitle", (unreadCountTitle: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, unreadCountTitle })
|
||||
})
|
||||
|
||||
export const changeUnreadCountFavicon = createAppAsyncThunk("settings/unreadCountFavicon", (unreadCountFavicon: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, unreadCountFavicon })
|
||||
})
|
||||
|
||||
export const changeSharingSetting = createAppAsyncThunk(
|
||||
"settings/sharingSetting",
|
||||
(
|
||||
|
||||
4
commafeed-client/src/components/DisablePullToRefresh.css
Normal file
4
commafeed-client/src/components/DisablePullToRefresh.css
Normal file
@@ -0,0 +1,4 @@
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
@@ -1,15 +1,4 @@
|
||||
import { Helmet } from "react-helmet"
|
||||
|
||||
export const DisablePullToRefresh = () => {
|
||||
return (
|
||||
<Helmet>
|
||||
<style type="text/css">
|
||||
{`
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</Helmet>
|
||||
)
|
||||
import("./DisablePullToRefresh.css")
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Enclosure(props: {
|
||||
)}
|
||||
{hasAudio && (
|
||||
// biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
|
||||
<audio controls>
|
||||
<audio controls style={{ width: "100%" }}>
|
||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||
</audio>
|
||||
)}
|
||||
|
||||
@@ -39,8 +39,8 @@ export function Subscribe() {
|
||||
},
|
||||
})
|
||||
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
||||
onSuccess: sub => {
|
||||
dispatch(reloadTree())
|
||||
onSuccess: async sub => {
|
||||
await dispatch(reloadTree())
|
||||
dispatch(redirectToFeed(sub.data))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setWebSocketConnected } from "app/server/slice"
|
||||
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
|
||||
import { incrementUnreadCount } from "app/tree/slice"
|
||||
import { newFeedEntriesDiscovered } from "app/tree/thunks"
|
||||
import { useEffect } from "react"
|
||||
import WebsocketHeartbeatJs from "websocket-heartbeat-js"
|
||||
|
||||
@@ -9,7 +9,7 @@ const handleMessage = (dispatch: AppDispatch, message: string) => {
|
||||
const type = parts[0]
|
||||
if (type === "new-feed-entries") {
|
||||
dispatch(
|
||||
incrementUnreadCount({
|
||||
newFeedEntriesDiscovered({
|
||||
feedId: +parts[1],
|
||||
amount: +parts[2],
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ msgstr "浏览器扩展"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Browser tab"
|
||||
msgstr ""
|
||||
msgstr "浏览器标签页"
|
||||
|
||||
#: src/components/admin/UserEdit.tsx
|
||||
#: src/components/content/add/AddCategory.tsx
|
||||
@@ -323,7 +323,7 @@ msgstr "输入您当前的密码以更改配置文件设置"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Entries to keep above the selected entry when scrolling"
|
||||
msgstr ""
|
||||
msgstr "滚动时固定在顶部的条目"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Entry headers"
|
||||
@@ -378,7 +378,7 @@ msgstr "过滤表达式"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Force fetching feeds is not yet available."
|
||||
msgstr ""
|
||||
msgstr "强制获取订阅源功能不可用。"
|
||||
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
msgid "Forgot password?"
|
||||
@@ -844,11 +844,11 @@ msgstr "显示星标图标"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show unread count in tab favicon"
|
||||
msgstr ""
|
||||
msgstr "在标签页图标上显示未读数量"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show unread count in tab title"
|
||||
msgstr ""
|
||||
msgstr "在标签页标题中显示未读数量"
|
||||
|
||||
#: src/pages/auth/RegistrationPage.tsx
|
||||
#: src/pages/auth/RegistrationPage.tsx
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
@@ -19,6 +21,7 @@ import { useParams } from "react-router-dom"
|
||||
|
||||
export function CategoryDetailsPage() {
|
||||
const { id = Constants.categories.all.id } = useParams()
|
||||
const { _ } = useLingui()
|
||||
|
||||
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -26,7 +29,7 @@ export function CategoryDetailsPage() {
|
||||
const query = useAsync(async () => await client.category.getRoot(), [])
|
||||
const category =
|
||||
id === Constants.categories.starred.id
|
||||
? Constants.categories.starred
|
||||
? { ...Constants.categories.starred, name: _(msg`Starred`) }
|
||||
: query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
|
||||
|
||||
const form = useForm<CategoryModificationRequest>()
|
||||
@@ -63,14 +66,14 @@ export function CategoryDetailsPage() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!category) return
|
||||
if (!category?.id) return
|
||||
setValues({
|
||||
id: +category.id,
|
||||
name: category.name,
|
||||
parentId: category.parentId,
|
||||
position: category.position,
|
||||
})
|
||||
}, [setValues, category])
|
||||
}, [setValues, category?.id, category?.name, category?.parentId, category?.position])
|
||||
|
||||
const editable = id !== Constants.categories.all.id && id !== Constants.categories.starred.id
|
||||
if (!category) return <Loader />
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import { useWebSocket } from "hooks/useWebSocket"
|
||||
import { LoadingPage } from "pages/LoadingPage"
|
||||
import { type ReactNode, Suspense, useEffect, useRef } from "react"
|
||||
import { type ReactNode, type RefObject, Suspense, useEffect, useRef } from "react"
|
||||
import Draggable from "react-draggable"
|
||||
import { TbMenu2, TbPlus, TbX } from "react-icons/tb"
|
||||
import { Outlet } from "react-router-dom"
|
||||
@@ -185,7 +185,7 @@ export default function Layout(props: LayoutProps) {
|
||||
</AppShell.Navbar>
|
||||
<OnDesktop>
|
||||
<Draggable
|
||||
nodeRef={draggableSeparator}
|
||||
nodeRef={draggableSeparator as RefObject<HTMLElement>}
|
||||
axis="x"
|
||||
defaultPosition={{
|
||||
x: sidebarWidth,
|
||||
|
||||
@@ -287,6 +287,29 @@ MemorySize [🛈](#memory-size-note-anchor)
|
||||
`5M`
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`commafeed.http-client.block-local-addresses`
|
||||
|
||||
Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal
|
||||
resources.
|
||||
|
||||
You may want to disable this if you subscribe to feeds that are only available on your local network and you trust all users of
|
||||
your CommaFeed instance.
|
||||
|
||||
|
||||
|
||||
Environment variable: `COMMAFEED_HTTP_CLIENT_BLOCK_LOCAL_ADDRESSES`</td>
|
||||
<td>
|
||||
|
||||
boolean
|
||||
</td>
|
||||
<td>
|
||||
|
||||
`true`
|
||||
</td>
|
||||
</tr>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" colspan="3">
|
||||
@@ -366,7 +389,7 @@ Feed refresh engine settings
|
||||
|
||||
`commafeed.feed-refresh.interval`
|
||||
|
||||
Amount of time CommaFeed will wait before refreshing the same feed.
|
||||
Default amount of time CommaFeed will wait before refreshing a feed.
|
||||
|
||||
|
||||
|
||||
@@ -383,10 +406,36 @@ Environment variable: `COMMAFEED_FEED_REFRESH_INTERVAL`</td>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`commafeed.feed-refresh.max-interval`
|
||||
|
||||
Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
|
||||
|
||||
<ul>
|
||||
<li>an error occurs while refreshing a feed and we're backing off exponentially</li>
|
||||
<li>we receive a Cache-Control header from the feed</li>
|
||||
<li>we receive a Retry-After header from the feed</li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
Environment variable: `COMMAFEED_FEED_REFRESH_MAX_INTERVAL`</td>
|
||||
<td>
|
||||
|
||||
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
|
||||
</td>
|
||||
<td>
|
||||
|
||||
`4H`
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`commafeed.feed-refresh.interval-empirical`
|
||||
|
||||
If true, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since the
|
||||
last entry was published. The interval will be somewhere between the default refresh interval and 24h.
|
||||
If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since
|
||||
the last entry was published. The interval will be sometimes between the default refresh interval
|
||||
(`commafeed.feed-refresh.interval`) and the maximum refresh interval (`commafeed.feed-refresh.max-interval`).
|
||||
|
||||
See <code>FeedRefreshIntervalCalculator</code> for details.
|
||||
|
||||
@@ -399,7 +448,7 @@ boolean
|
||||
</td>
|
||||
<td>
|
||||
|
||||
`false`
|
||||
`true`
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -502,6 +551,52 @@ Environment variable: `COMMAFEED_FEED_REFRESH_FORCE_REFRESH_COOLDOWN_DURATION`</
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" colspan="3">
|
||||
Feed refresh engine error handling settings
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`commafeed.feed-refresh.errors.retries-before-backoff`
|
||||
|
||||
Number of retries before backoff is applied.
|
||||
|
||||
|
||||
|
||||
Environment variable: `COMMAFEED_FEED_REFRESH_ERRORS_RETRIES_BEFORE_BACKOFF`</td>
|
||||
<td>
|
||||
|
||||
int
|
||||
</td>
|
||||
<td>
|
||||
|
||||
`3`
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`commafeed.feed-refresh.errors.backoff-interval`
|
||||
|
||||
Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last successful fetch.
|
||||
|
||||
|
||||
|
||||
Environment variable: `COMMAFEED_FEED_REFRESH_ERRORS_BACKOFF_INTERVAL`</td>
|
||||
<td>
|
||||
|
||||
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
|
||||
</td>
|
||||
<td>
|
||||
|
||||
`1H`
|
||||
</td>
|
||||
</tr>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" colspan="3">
|
||||
Database settings
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>5.3.5</version>
|
||||
<version>5.6.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-server</artifactId>
|
||||
<name>CommaFeed Server</name>
|
||||
|
||||
<properties>
|
||||
<quarkus.version>3.17.2</quarkus.version>
|
||||
<querydsl.version>6.9</querydsl.version>
|
||||
<quarkus.version>3.18.3</quarkus.version>
|
||||
<querydsl.version>6.10.1</querydsl.version>
|
||||
<rome.version>2.1.0</rome.version>
|
||||
<swagger.version>2.2.26</swagger.version>
|
||||
<swagger.version>2.2.28</swagger.version>
|
||||
|
||||
<build.database>h2</build.database>
|
||||
</properties>
|
||||
@@ -241,7 +241,7 @@
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.20.2</version>
|
||||
<version>10.21.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
@@ -270,7 +270,7 @@
|
||||
<plugin>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
<version>2.43.0</version>
|
||||
<version>2.44.2</version>
|
||||
<?m2e ignore?>
|
||||
<executions>
|
||||
<execution>
|
||||
@@ -297,7 +297,7 @@
|
||||
<dependency>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<version>5.3.5</version>
|
||||
<version>5.6.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- compile-time processors -->
|
||||
@@ -361,7 +361,7 @@
|
||||
<dependency>
|
||||
<groupId>io.dropwizard.metrics</groupId>
|
||||
<artifactId>metrics-json</artifactId>
|
||||
<version>4.2.29</version>
|
||||
<version>4.2.30</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
@@ -453,7 +453,7 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
<version>5.4.1</version>
|
||||
<version>5.4.2</version>
|
||||
</dependency>
|
||||
<!-- add brotli support for httpclient5 -->
|
||||
<dependency>
|
||||
@@ -464,7 +464,7 @@
|
||||
<dependency>
|
||||
<groupId>io.github.hakky54</groupId>
|
||||
<artifactId>sslcontext-kickstart-for-apache5</artifactId>
|
||||
<version>8.3.7</version>
|
||||
<version>9.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- test dependencies -->
|
||||
@@ -492,7 +492,7 @@
|
||||
<dependency>
|
||||
<groupId>com.microsoft.playwright</groupId>
|
||||
<artifactId>playwright</artifactId>
|
||||
<version>1.49.0</version>
|
||||
<version>1.50.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -4,7 +4,7 @@ EXPOSE 8082
|
||||
RUN mkdir -p /commafeed/data
|
||||
VOLUME /commafeed/data
|
||||
|
||||
COPY commafeed-server/target/quarkus-app/ /commafeed
|
||||
COPY artifacts/extracted-jvm-package/quarkus-app/ /commafeed
|
||||
WORKDIR /commafeed
|
||||
|
||||
CMD ["java", \
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
FROM debian:12.8
|
||||
FROM debian:12.9
|
||||
ARG TARGETARCH
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
RUN mkdir -p /commafeed/data
|
||||
VOLUME /commafeed/data
|
||||
|
||||
COPY commafeed-server/target/commafeed-*-runner /commafeed/application
|
||||
COPY artifacts/commafeed-*-${TARGETARCH}-runner /commafeed/application
|
||||
WORKDIR /commafeed
|
||||
|
||||
CMD ["./application"]
|
||||
|
||||
@@ -92,5 +92,4 @@ Tags are of the form `<version>-<database>[-jvm]` where:
|
||||
- `latest` (always points to the latest version)
|
||||
- `master` (always points to the latest git commit)
|
||||
- `<database>` is the database to use (`h2`, `postgresql`, `mysql` or `mariadb`)
|
||||
- `-jvm` is optional and indicates that CommaFeed is running on a JVM, and not compiled natively. This image supports
|
||||
the arm64 platform which is not yet supported by the native image.
|
||||
- `-jvm` is optional and indicates that CommaFeed is running on a JVM, and not compiled natively.
|
||||
|
||||
@@ -138,6 +138,16 @@ public interface CommaFeedConfiguration {
|
||||
@WithDefault("5M")
|
||||
MemorySize maxResponseSize();
|
||||
|
||||
/**
|
||||
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal
|
||||
* resources.
|
||||
*
|
||||
* You may want to disable this if you subscribe to feeds that are only available on your local network and you trust all users of
|
||||
* your CommaFeed instance.
|
||||
*/
|
||||
@WithDefault("true")
|
||||
boolean blockLocalAddresses();
|
||||
|
||||
/**
|
||||
* HTTP client cache configuration
|
||||
*/
|
||||
@@ -168,20 +178,39 @@ public interface CommaFeedConfiguration {
|
||||
|
||||
interface FeedRefresh {
|
||||
/**
|
||||
* Amount of time CommaFeed will wait before refreshing the same feed.
|
||||
* Default amount of time CommaFeed will wait before refreshing a feed.
|
||||
*/
|
||||
@WithDefault("5m")
|
||||
Duration interval();
|
||||
|
||||
/**
|
||||
* If true, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since the
|
||||
* last entry was published. The interval will be somewhere between the default refresh interval and 24h.
|
||||
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
|
||||
*
|
||||
* <ul>
|
||||
* <li>an error occurs while refreshing a feed and we're backing off exponentially</li>
|
||||
* <li>we receive a Cache-Control header from the feed</li>
|
||||
* <li>we receive a Retry-After header from the feed</li>
|
||||
* </ul>
|
||||
*/
|
||||
@WithDefault("4h")
|
||||
Duration maxInterval();
|
||||
|
||||
/**
|
||||
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since
|
||||
* the last entry was published. The interval will be sometimes between the default refresh interval
|
||||
* (`commafeed.feed-refresh.interval`) and the maximum refresh interval (`commafeed.feed-refresh.max-interval`).
|
||||
*
|
||||
* See {@link FeedRefreshIntervalCalculator} for details.
|
||||
*/
|
||||
@WithDefault("false")
|
||||
@WithDefault("true")
|
||||
boolean intervalEmpirical();
|
||||
|
||||
/**
|
||||
* Feed refresh engine error handling settings.
|
||||
*/
|
||||
@ConfigDocSection
|
||||
FeedRefreshErrorHandling errors();
|
||||
|
||||
/**
|
||||
* Amount of http threads used to fetch feeds.
|
||||
*/
|
||||
@@ -217,6 +246,21 @@ public interface CommaFeedConfiguration {
|
||||
Duration forceRefreshCooldownDuration();
|
||||
}
|
||||
|
||||
interface FeedRefreshErrorHandling {
|
||||
/**
|
||||
* Number of retries before backoff is applied.
|
||||
*/
|
||||
@Min(0)
|
||||
@WithDefault("3")
|
||||
int retriesBeforeBackoff();
|
||||
|
||||
/**
|
||||
* Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last successful fetch.
|
||||
*/
|
||||
@WithDefault("1h")
|
||||
Duration backoffInterval();
|
||||
}
|
||||
|
||||
interface Database {
|
||||
/**
|
||||
* Timeout applied to all database queries.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.commafeed;
|
||||
|
||||
import java.time.InstantSource;
|
||||
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
@@ -8,9 +10,16 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class CommaFeedProducers {
|
||||
|
||||
@Produces
|
||||
@Singleton
|
||||
public InstantSource instantSource() {
|
||||
return InstantSource.system();
|
||||
}
|
||||
|
||||
@Produces
|
||||
@Singleton
|
||||
public MetricRegistry metricRegistry() {
|
||||
return new MetricRegistry();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@ package com.commafeed.backend;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.IDN;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.InstantSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hc.client5.http.DnsResolver;
|
||||
@@ -25,6 +27,7 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuil
|
||||
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
|
||||
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.Header;
|
||||
import org.apache.hc.core5.http.HttpEntity;
|
||||
@@ -51,6 +54,7 @@ import jakarta.ws.rs.core.CacheControl;
|
||||
import lombok.Builder;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Value;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import nl.altindag.ssl.SSLFactory;
|
||||
@@ -64,11 +68,14 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils;
|
||||
public class HttpGetter {
|
||||
|
||||
private final CommaFeedConfiguration config;
|
||||
private final InstantSource instantSource;
|
||||
private final CloseableHttpClient client;
|
||||
private final Cache<HttpRequest, HttpResponse> cache;
|
||||
private final DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE;
|
||||
|
||||
public HttpGetter(CommaFeedConfiguration config, CommaFeedVersion version, MetricRegistry metrics) {
|
||||
public HttpGetter(CommaFeedConfiguration config, InstantSource instantSource, CommaFeedVersion version, MetricRegistry metrics) {
|
||||
this.config = config;
|
||||
this.instantSource = instantSource;
|
||||
|
||||
PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config);
|
||||
String userAgent = config.httpClient()
|
||||
@@ -88,11 +95,20 @@ public class HttpGetter {
|
||||
() -> cache == null ? 0 : cache.asMap().values().stream().mapToInt(e -> e.content != null ? e.content.length : 0).sum());
|
||||
}
|
||||
|
||||
public HttpResult get(String url) throws IOException, NotModifiedException {
|
||||
public HttpResult get(String url)
|
||||
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException {
|
||||
return get(HttpRequest.builder(url).build());
|
||||
}
|
||||
|
||||
public HttpResult get(HttpRequest request) throws IOException, NotModifiedException {
|
||||
public HttpResult get(HttpRequest request)
|
||||
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException {
|
||||
URI uri = URI.create(request.getUrl());
|
||||
ensureHttpScheme(uri.getScheme());
|
||||
|
||||
if (config.httpClient().blockLocalAddresses()) {
|
||||
ensurePublicAddress(uri.getHost());
|
||||
}
|
||||
|
||||
final HttpResponse response;
|
||||
if (cache == null) {
|
||||
response = invoke(request);
|
||||
@@ -109,9 +125,15 @@ public class HttpGetter {
|
||||
}
|
||||
|
||||
int code = response.getCode();
|
||||
if (code == HttpStatus.SC_TOO_MANY_REQUESTS || code == HttpStatus.SC_SERVICE_UNAVAILABLE && response.getRetryAfter() != null) {
|
||||
throw new TooManyRequestsException(response.getRetryAfter());
|
||||
}
|
||||
|
||||
if (code == HttpStatus.SC_NOT_MODIFIED) {
|
||||
throw new NotModifiedException("'304 - not modified' http code received");
|
||||
} else if (code >= 300) {
|
||||
}
|
||||
|
||||
if (code >= 300) {
|
||||
throw new HttpResponseException(code, "Server returned HTTP error code " + code);
|
||||
}
|
||||
|
||||
@@ -134,6 +156,28 @@ public class HttpGetter {
|
||||
response.getUrlAfterRedirect(), validFor);
|
||||
}
|
||||
|
||||
private void ensureHttpScheme(String scheme) throws SchemeNotAllowedException {
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
throw new SchemeNotAllowedException(scheme);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensurePublicAddress(String host) throws HostNotAllowedException, UnknownHostException {
|
||||
if (host == null) {
|
||||
throw new HostNotAllowedException(null);
|
||||
}
|
||||
|
||||
InetAddress[] addresses = dnsResolver.resolve(host);
|
||||
if (Stream.of(addresses).anyMatch(this::isPrivateAddress)) {
|
||||
throw new HostNotAllowedException(host);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPrivateAddress(InetAddress address) {
|
||||
return address.isSiteLocalAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isLoopbackAddress()
|
||||
|| address.isMulticastAddress();
|
||||
}
|
||||
|
||||
private HttpResponse invoke(HttpRequest request) throws IOException {
|
||||
log.debug("fetching {}", request.getUrl());
|
||||
|
||||
@@ -162,7 +206,13 @@ public class HttpGetter {
|
||||
CacheControl cacheControl = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(CacheControlDelegate.INSTANCE::fromString)
|
||||
.map(HttpGetter::toCacheControl)
|
||||
.orElse(null);
|
||||
|
||||
Instant retryAfter = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER))
|
||||
.map(NameValuePair::getValue)
|
||||
.map(StringUtils::trimToNull)
|
||||
.map(this::toInstant)
|
||||
.orElse(null);
|
||||
|
||||
String contentType = Optional.ofNullable(resp.getEntity()).map(HttpEntity::getContentType).orElse(null);
|
||||
@@ -172,10 +222,31 @@ public class HttpGetter {
|
||||
.map(URI::toString)
|
||||
.orElse(request.getUrl());
|
||||
|
||||
return new HttpResponse(code, lastModifiedHeader, eTagHeader, cacheControl, content, contentType, urlAfterRedirect);
|
||||
return new HttpResponse(code, lastModifiedHeader, eTagHeader, cacheControl, retryAfter, content, contentType, urlAfterRedirect);
|
||||
});
|
||||
}
|
||||
|
||||
private static CacheControl toCacheControl(String headerValue) {
|
||||
try {
|
||||
return CacheControlDelegate.INSTANCE.fromString(headerValue);
|
||||
} catch (Exception e) {
|
||||
log.debug("Invalid Cache-Control header: {}", headerValue);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Instant toInstant(String headerValue) {
|
||||
if (headerValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (StringUtils.isNumeric(headerValue)) {
|
||||
return instantSource.instant().plusSeconds(Long.parseLong(headerValue));
|
||||
}
|
||||
|
||||
return DateUtils.parseStandardDate(headerValue);
|
||||
}
|
||||
|
||||
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
|
||||
if (entity.getContentLength() > maxBytes) {
|
||||
throw new IOException(
|
||||
@@ -195,7 +266,7 @@ public class HttpGetter {
|
||||
}
|
||||
}
|
||||
|
||||
private static PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) {
|
||||
private PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) {
|
||||
SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
|
||||
|
||||
int poolSize = config.feedRefresh().httpThreads();
|
||||
@@ -209,7 +280,7 @@ public class HttpGetter {
|
||||
.setDefaultTlsConfig(TlsConfig.custom().setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout())).build())
|
||||
.setMaxConnPerRoute(poolSize)
|
||||
.setMaxConnTotal(poolSize)
|
||||
.setDnsResolver(new InternationalizedDomainNameToAsciiDnsResolver(SystemDefaultDnsResolver.INSTANCE))
|
||||
.setDnsResolver(dnsResolver)
|
||||
.build();
|
||||
|
||||
}
|
||||
@@ -246,15 +317,19 @@ public class HttpGetter {
|
||||
.build();
|
||||
}
|
||||
|
||||
private record InternationalizedDomainNameToAsciiDnsResolver(DnsResolver delegate) implements DnsResolver {
|
||||
@Override
|
||||
public InetAddress[] resolve(String host) throws UnknownHostException {
|
||||
return delegate.resolve(IDN.toASCII(host));
|
||||
}
|
||||
public static class SchemeNotAllowedException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Override
|
||||
public String resolveCanonicalHostname(String host) throws UnknownHostException {
|
||||
return delegate.resolveCanonicalHostname(IDN.toASCII(host));
|
||||
public SchemeNotAllowedException(String scheme) {
|
||||
super("Scheme not allowed: " + scheme);
|
||||
}
|
||||
}
|
||||
|
||||
public static class HostNotAllowedException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public HostNotAllowedException(String host) {
|
||||
super("Host not allowed: " + host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +358,14 @@ public class HttpGetter {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Getter
|
||||
public static class TooManyRequestsException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Instant retryAfter;
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static class HttpResponseException extends IOException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
@@ -325,6 +408,7 @@ public class HttpGetter {
|
||||
String lastModifiedHeader;
|
||||
String eTagHeader;
|
||||
CacheControl cacheControl;
|
||||
Instant retryAfter;
|
||||
byte[] content;
|
||||
String contentType;
|
||||
String urlAfterRedirect;
|
||||
|
||||
@@ -11,8 +11,11 @@ import org.apache.hc.core5.net.URIBuilder;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.HttpGetter;
|
||||
import com.commafeed.backend.HttpGetter.HostNotAllowedException;
|
||||
import com.commafeed.backend.HttpGetter.HttpResult;
|
||||
import com.commafeed.backend.HttpGetter.NotModifiedException;
|
||||
import com.commafeed.backend.HttpGetter.SchemeNotAllowedException;
|
||||
import com.commafeed.backend.HttpGetter.TooManyRequestsException;
|
||||
import com.commafeed.backend.model.Feed;
|
||||
import com.fasterxml.jackson.core.JsonPointer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -91,7 +94,8 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
|
||||
return new Favicon(bytes, contentType);
|
||||
}
|
||||
|
||||
private byte[] fetchForUser(String googleAuthKey, String userId) throws IOException, NotModifiedException {
|
||||
private byte[] fetchForUser(String googleAuthKey, String userId)
|
||||
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
|
||||
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
|
||||
.queryParam("part", "snippet")
|
||||
.queryParam("key", googleAuthKey)
|
||||
@@ -100,7 +104,8 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
|
||||
return getter.get(uri.toString()).getContent();
|
||||
}
|
||||
|
||||
private byte[] fetchForChannel(String googleAuthKey, String channelId) throws IOException, NotModifiedException {
|
||||
private byte[] fetchForChannel(String googleAuthKey, String channelId)
|
||||
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
|
||||
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
|
||||
.queryParam("part", "snippet")
|
||||
.queryParam("key", googleAuthKey)
|
||||
@@ -109,7 +114,8 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
|
||||
return getter.get(uri.toString()).getContent();
|
||||
}
|
||||
|
||||
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) throws IOException, NotModifiedException {
|
||||
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
|
||||
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
|
||||
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
|
||||
.queryParam("part", "snippet")
|
||||
.queryParam("key", googleAuthKey)
|
||||
|
||||
@@ -10,9 +10,12 @@ import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import com.commafeed.backend.Digests;
|
||||
import com.commafeed.backend.HttpGetter;
|
||||
import com.commafeed.backend.HttpGetter.HostNotAllowedException;
|
||||
import com.commafeed.backend.HttpGetter.HttpRequest;
|
||||
import com.commafeed.backend.HttpGetter.HttpResult;
|
||||
import com.commafeed.backend.HttpGetter.NotModifiedException;
|
||||
import com.commafeed.backend.HttpGetter.SchemeNotAllowedException;
|
||||
import com.commafeed.backend.HttpGetter.TooManyRequestsException;
|
||||
import com.commafeed.backend.feed.parser.FeedParser;
|
||||
import com.commafeed.backend.feed.parser.FeedParserResult;
|
||||
import com.commafeed.backend.urlprovider.FeedURLProvider;
|
||||
@@ -40,7 +43,8 @@ public class FeedFetcher {
|
||||
}
|
||||
|
||||
public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag,
|
||||
Instant lastPublishedDate, String lastContentHash) throws FeedException, IOException, NotModifiedException {
|
||||
Instant lastPublishedDate, String lastContentHash) throws FeedException, IOException, NotModifiedException,
|
||||
TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException {
|
||||
log.debug("Fetching feed {}", feedUrl);
|
||||
|
||||
HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
|
||||
|
||||
@@ -2,77 +2,83 @@ package com.commafeed.backend.feed;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.InstantSource;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling;
|
||||
import com.google.common.primitives.Longs;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
@Singleton
|
||||
public class FeedRefreshIntervalCalculator {
|
||||
|
||||
private final Duration refreshInterval;
|
||||
private final boolean empiricalInterval;
|
||||
private final Duration interval;
|
||||
private final Duration maxInterval;
|
||||
private final boolean empirical;
|
||||
private final FeedRefreshErrorHandling errorHandling;
|
||||
private final InstantSource instantSource;
|
||||
|
||||
public FeedRefreshIntervalCalculator(CommaFeedConfiguration config) {
|
||||
this.refreshInterval = config.feedRefresh().interval();
|
||||
this.empiricalInterval = config.feedRefresh().intervalEmpirical();
|
||||
public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) {
|
||||
this.interval = config.feedRefresh().interval();
|
||||
this.maxInterval = config.feedRefresh().maxInterval();
|
||||
this.empirical = config.feedRefresh().intervalEmpirical();
|
||||
this.errorHandling = config.feedRefresh().errors();
|
||||
this.instantSource = instantSource;
|
||||
}
|
||||
|
||||
public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval) {
|
||||
Instant defaultRefreshInterval = getDefaultRefreshInterval();
|
||||
return empiricalInterval ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval, defaultRefreshInterval)
|
||||
: defaultRefreshInterval;
|
||||
public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) {
|
||||
Instant instant = empirical ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval)
|
||||
: instantSource.instant().plus(interval);
|
||||
return constrainToBounds(ObjectUtils.max(instant, instantSource.instant().plus(validFor)));
|
||||
}
|
||||
|
||||
public Instant onFeedNotModified(Instant publishedDate, Long averageEntryInterval) {
|
||||
return onFetchSuccess(publishedDate, averageEntryInterval);
|
||||
return onFetchSuccess(publishedDate, averageEntryInterval, Duration.ZERO);
|
||||
}
|
||||
|
||||
public Instant onTooManyRequests(Instant retryAfter, int errorCount) {
|
||||
return constrainToBounds(ObjectUtils.max(retryAfter, onFetchError(errorCount)));
|
||||
}
|
||||
|
||||
public Instant onFetchError(int errorCount) {
|
||||
int retriesBeforeDisable = 3;
|
||||
if (errorCount < retriesBeforeDisable || !empiricalInterval) {
|
||||
return getDefaultRefreshInterval();
|
||||
if (errorCount < errorHandling.retriesBeforeBackoff()) {
|
||||
return constrainToBounds(instantSource.instant().plus(interval));
|
||||
}
|
||||
|
||||
int disabledHours = Math.min(24 * 7, errorCount - retriesBeforeDisable + 1);
|
||||
return Instant.now().plus(Duration.ofHours(disabledHours));
|
||||
Duration retryInterval = errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
|
||||
return constrainToBounds(instantSource.instant().plus(retryInterval));
|
||||
}
|
||||
|
||||
private Instant getDefaultRefreshInterval() {
|
||||
return Instant.now().plus(refreshInterval);
|
||||
}
|
||||
|
||||
private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval, Instant defaultRefreshInterval) {
|
||||
Instant now = Instant.now();
|
||||
private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) {
|
||||
Instant now = instantSource.instant();
|
||||
|
||||
if (publishedDate == null) {
|
||||
// feed with no entries, recheck in 24 hours
|
||||
return now.plus(Duration.ofHours(24));
|
||||
} else if (ChronoUnit.DAYS.between(publishedDate, now) >= 30) {
|
||||
// older than a month, recheck in 24 hours
|
||||
return now.plus(Duration.ofHours(24));
|
||||
} else if (ChronoUnit.DAYS.between(publishedDate, now) >= 14) {
|
||||
// older than two weeks, recheck in 12 hours
|
||||
return now.plus(Duration.ofHours(12));
|
||||
} else if (ChronoUnit.DAYS.between(publishedDate, now) >= 7) {
|
||||
// older than a week, recheck in 6 hours
|
||||
return now.plus(Duration.ofHours(6));
|
||||
return now.plus(maxInterval);
|
||||
}
|
||||
|
||||
long daysSinceLastPublication = ChronoUnit.DAYS.between(publishedDate, now);
|
||||
if (daysSinceLastPublication >= 30) {
|
||||
return now.plus(maxInterval);
|
||||
} else if (daysSinceLastPublication >= 14) {
|
||||
return now.plus(maxInterval.dividedBy(2));
|
||||
} else if (daysSinceLastPublication >= 7) {
|
||||
return now.plus(maxInterval.dividedBy(4));
|
||||
} else if (averageEntryInterval != null) {
|
||||
// use average time between entries to decide when to refresh next, divided by factor
|
||||
int factor = 2;
|
||||
|
||||
// not more than 6 hours
|
||||
long date = Math.min(now.plus(Duration.ofHours(6)).toEpochMilli(), now.toEpochMilli() + averageEntryInterval / factor);
|
||||
|
||||
// not less than default refresh interval
|
||||
date = Math.max(defaultRefreshInterval.toEpochMilli(), date);
|
||||
|
||||
return Instant.ofEpochMilli(date);
|
||||
long millis = Longs.constrainToRange(averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis());
|
||||
return now.plusMillis(millis);
|
||||
} else {
|
||||
// unknown case, recheck in 24 hours
|
||||
return now.plus(Duration.ofHours(24));
|
||||
// unknown case
|
||||
return now.plus(maxInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private Instant constrainToBounds(Instant instant) {
|
||||
return ObjectUtils.max(ObjectUtils.min(instant, instantSource.instant().plus(maxInterval)), instantSource.instant().plus(interval));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import com.codahale.metrics.Meter;
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.HttpGetter.NotModifiedException;
|
||||
import com.commafeed.backend.HttpGetter.TooManyRequestsException;
|
||||
import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult;
|
||||
import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
|
||||
import com.commafeed.backend.model.Feed;
|
||||
@@ -77,9 +77,8 @@ public class FeedRefreshWorker {
|
||||
|
||||
feed.setErrorCount(0);
|
||||
feed.setMessage(null);
|
||||
feed.setDisabledUntil(ObjectUtils.max(
|
||||
refreshIntervalCalculator.onFetchSuccess(result.feed().lastPublishedDate(), result.feed().averageEntryInterval()),
|
||||
Instant.now().plus(result.validFor())));
|
||||
feed.setDisabledUntil(refreshIntervalCalculator.onFetchSuccess(result.feed().lastPublishedDate(),
|
||||
result.feed().averageEntryInterval(), result.validFor()));
|
||||
|
||||
return new FeedRefreshWorkerResult(feed, entries);
|
||||
} catch (NotModifiedException e) {
|
||||
@@ -97,6 +96,14 @@ public class FeedRefreshWorker {
|
||||
feed.setEtagHeader(e.getNewEtagHeader());
|
||||
}
|
||||
|
||||
return new FeedRefreshWorkerResult(feed, Collections.emptyList());
|
||||
} catch (TooManyRequestsException e) {
|
||||
log.debug("Too many requests : {}", feed.getUrl());
|
||||
|
||||
feed.setErrorCount(feed.getErrorCount() + 1);
|
||||
feed.setMessage("Server indicated that we are sending too many requests");
|
||||
feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount()));
|
||||
|
||||
return new FeedRefreshWorkerResult(feed, Collections.emptyList());
|
||||
} catch (Exception e) {
|
||||
log.debug("unable to refresh feed {}", feed.getUrl(), e);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.commafeed.backend.feed.parser;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.ahocorasick.trie.Emit;
|
||||
import org.ahocorasick.trie.Trie;
|
||||
@@ -11,6 +12,8 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
class FeedCleaner {
|
||||
|
||||
private static final Pattern DOCTYPE_PATTERN = Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
public String trimInvalidXmlCharacters(String xml) {
|
||||
if (StringUtils.isBlank(xml)) {
|
||||
return null;
|
||||
@@ -60,4 +63,8 @@ class FeedCleaner {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public String removeDoctypeDeclarations(String xml) {
|
||||
return DOCTYPE_PATTERN.matcher(xml).replaceAll("");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ public class FeedParser {
|
||||
throw new FeedException("Input string is null for url " + feedUrl);
|
||||
}
|
||||
xmlString = feedCleaner.replaceHtmlEntitiesWithNumericEntities(xmlString);
|
||||
xmlString = feedCleaner.removeDoctypeDeclarations(xmlString);
|
||||
|
||||
InputSource source = new InputSource(new StringReader(xmlString));
|
||||
SyndFeed feed = new SyndFeedInput().build(source);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.commafeed.backend.urlprovider;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
@@ -14,12 +13,16 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class YoutubeFeedURLProvider implements FeedURLProvider {
|
||||
|
||||
private static final Pattern REGEXP = Pattern.compile("(.*\\byoutube\\.com)\\/channel\\/([^\\/]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final String PREFIX = "https://www.youtube.com/channel/";
|
||||
private static final String REPLACEMENT_PREFIX = "https://www.youtube.com/feeds/videos.xml?channel_id=";
|
||||
|
||||
@Override
|
||||
public String get(String url, String urlContent) {
|
||||
Matcher matcher = REGEXP.matcher(url);
|
||||
return matcher.find() ? matcher.group(1) + "/feeds/videos.xml?channel_id=" + matcher.group(2) : null;
|
||||
if (!StringUtils.startsWithIgnoreCase(url, PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return REPLACEMENT_PREFIX + url.substring(PREFIX.length());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ quarkus.native.add-all-charsets=true
|
||||
%test.commafeed.users.allow-registrations=true
|
||||
%test.commafeed.password-recovery-enabled=true
|
||||
%test.commafeed.http-client.cache.enabled=false
|
||||
%test.commafeed.http-client.block-local-addresses=false
|
||||
%test.commafeed.database.cleanup.entries-max-age=0
|
||||
%test.commafeed.feed-refresh.force-refresh-cooldown-duration=1m
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.io.OutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -39,6 +40,7 @@ import com.commafeed.CommaFeedVersion;
|
||||
import com.commafeed.backend.HttpGetter.HttpResponseException;
|
||||
import com.commafeed.backend.HttpGetter.HttpResult;
|
||||
import com.commafeed.backend.HttpGetter.NotModifiedException;
|
||||
import com.commafeed.backend.HttpGetter.TooManyRequestsException;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
|
||||
import io.quarkus.runtime.configuration.MemorySize;
|
||||
@@ -46,9 +48,12 @@ import io.quarkus.runtime.configuration.MemorySize;
|
||||
@ExtendWith(MockServerExtension.class)
|
||||
class HttpGetterTest {
|
||||
|
||||
private static final Instant NOW = Instant.now();
|
||||
|
||||
private MockServerClient mockServerClient;
|
||||
private String feedUrl;
|
||||
private byte[] feedContent;
|
||||
|
||||
private CommaFeedConfiguration config;
|
||||
|
||||
private HttpGetter getter;
|
||||
@@ -73,7 +78,7 @@ class HttpGetterTest {
|
||||
Mockito.when(config.httpClient().cache().expiration()).thenReturn(Duration.ofMinutes(1));
|
||||
Mockito.when(config.feedRefresh().httpThreads()).thenReturn(3);
|
||||
|
||||
this.getter = new HttpGetter(config, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
|
||||
this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@@ -94,7 +99,8 @@ class HttpGetterTest {
|
||||
.withContentType(MediaType.APPLICATION_ATOM_XML)
|
||||
.withHeader(HttpHeaders.LAST_MODIFIED, "123456")
|
||||
.withHeader(HttpHeaders.ETAG, "78910")
|
||||
.withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60"));
|
||||
.withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60, must-revalidate")
|
||||
.withHeader(HttpHeaders.RETRY_AFTER, "120"));
|
||||
|
||||
HttpResult result = getter.get(this.feedUrl);
|
||||
Assertions.assertArrayEquals(feedContent, result.getContent());
|
||||
@@ -105,6 +111,39 @@ class HttpGetterTest {
|
||||
Assertions.assertEquals(this.feedUrl, result.getUrlAfterRedirect());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoreInvalidCacheControlValue() throws Exception {
|
||||
this.mockServerClient.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(HttpResponse.response()
|
||||
.withBody(feedContent)
|
||||
.withContentType(MediaType.APPLICATION_ATOM_XML)
|
||||
.withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60; must-revalidate"));
|
||||
|
||||
HttpResult result = getter.get(this.feedUrl);
|
||||
Assertions.assertEquals(Duration.ZERO, result.getValidFor());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooManyRequestsExceptionSeconds() {
|
||||
this.mockServerClient.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(
|
||||
HttpResponse.response().withStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS).withHeader(HttpHeaders.RETRY_AFTER, "120"));
|
||||
|
||||
TooManyRequestsException e = Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl));
|
||||
Assertions.assertEquals(NOW.plusSeconds(120), e.getRetryAfter());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooManyRequestsExceptionDate() {
|
||||
this.mockServerClient.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(HttpResponse.response()
|
||||
.withStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS)
|
||||
.withHeader(HttpHeaders.RETRY_AFTER, "Wed, 21 Oct 2015 07:28:00 GMT"));
|
||||
|
||||
TooManyRequestsException e = Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl));
|
||||
Assertions.assertEquals(Instant.parse("2015-10-21T07:28:00Z"), e.getRetryAfter());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
ints = { HttpStatus.SC_MOVED_PERMANENTLY, HttpStatus.SC_MOVED_TEMPORARILY, HttpStatus.SC_TEMPORARY_REDIRECT,
|
||||
@@ -133,7 +172,7 @@ class HttpGetterTest {
|
||||
@Test
|
||||
void dataTimeout() {
|
||||
Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofMillis(500));
|
||||
this.getter = new HttpGetter(config, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
|
||||
this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
|
||||
|
||||
this.mockServerClient.when(HttpRequest.request().withMethod("GET"))
|
||||
.respond(HttpResponse.response().withDelay(Delay.milliseconds(1000)));
|
||||
@@ -144,7 +183,7 @@ class HttpGetterTest {
|
||||
@Test
|
||||
void connectTimeout() {
|
||||
Mockito.when(config.httpClient().connectTimeout()).thenReturn(Duration.ofMillis(500));
|
||||
this.getter = new HttpGetter(config, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
|
||||
this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
|
||||
// try to connect to a non-routable address
|
||||
// https://stackoverflow.com/a/904609
|
||||
Assertions.assertThrows(ConnectTimeoutException.class, () -> getter.get("http://10.255.255.1"));
|
||||
@@ -197,7 +236,7 @@ class HttpGetterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheSubsequentCalls() throws IOException, NotModifiedException {
|
||||
void cacheSubsequentCalls() throws Exception {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> {
|
||||
@@ -263,17 +302,16 @@ class HttpGetterTest {
|
||||
class Compression {
|
||||
|
||||
@Test
|
||||
void deflate() throws IOException, NotModifiedException {
|
||||
void deflate() throws Exception {
|
||||
supportsCompression("deflate", DeflaterOutputStream::new);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gzip() throws IOException, NotModifiedException {
|
||||
void gzip() throws Exception {
|
||||
supportsCompression("gzip", GZIPOutputStream::new);
|
||||
}
|
||||
|
||||
void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction)
|
||||
throws IOException, NotModifiedException {
|
||||
void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction) throws Exception {
|
||||
String body = "my body";
|
||||
|
||||
HttpGetterTest.this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> {
|
||||
@@ -301,4 +339,64 @@ class HttpGetterTest {
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SchemeNotAllowed {
|
||||
@Test
|
||||
void file() {
|
||||
Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("file://localhost"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ftp() {
|
||||
Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("ftp://localhost"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class HostNotAllowed {
|
||||
|
||||
@BeforeEach
|
||||
void init() {
|
||||
Mockito.when(config.httpClient().blockLocalAddresses()).thenReturn(true);
|
||||
getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localhost() {
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://localhost"));
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://127.0.0.1"));
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://2130706433"));
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0x7F.0x00.0x00.0X01"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zero() {
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0.0.0.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkLocal() {
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.12.34"));
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.169.254"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multicast() {
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://224.2.3.4"));
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://239.255.255.254"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateIpv4Ranges() {
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://10.0.0.1"));
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://172.16.0.1"));
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://192.168.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateIpv6Ranges() {
|
||||
Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://fd12:3456:789a:1::1"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.commafeed.backend.feed;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.InstantSource;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FeedRefreshIntervalCalculatorTest {
|
||||
|
||||
private static final Instant NOW = Instant.now();
|
||||
private static final Duration DEFAULT_INTERVAL = Duration.ofHours(1);
|
||||
private static final Duration MAX_INTERVAL = Duration.ofDays(1);
|
||||
|
||||
@Mock
|
||||
private InstantSource instantSource;
|
||||
|
||||
@Mock
|
||||
private CommaFeedConfiguration config;
|
||||
|
||||
@Mock
|
||||
private FeedRefreshErrorHandling errorHandling;
|
||||
|
||||
private FeedRefreshIntervalCalculator calculator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Mockito.when(instantSource.instant()).thenReturn(NOW);
|
||||
Mockito.when(config.feedRefresh()).thenReturn(Mockito.mock(CommaFeedConfiguration.FeedRefresh.class));
|
||||
Mockito.when(config.feedRefresh().interval()).thenReturn(DEFAULT_INTERVAL);
|
||||
Mockito.when(config.feedRefresh().maxInterval()).thenReturn(MAX_INTERVAL);
|
||||
Mockito.when(config.feedRefresh().errors()).thenReturn(errorHandling);
|
||||
|
||||
calculator = new FeedRefreshIntervalCalculator(config, instantSource);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class FetchSuccess {
|
||||
|
||||
@Nested
|
||||
class EmpiricalDisabled {
|
||||
@ParameterizedTest
|
||||
@ValueSource(longs = { 0, 1, 300, 86400000L })
|
||||
void withoutValidFor(long averageEntryInterval) {
|
||||
// averageEntryInterval is ignored when empirical is disabled
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), averageEntryInterval, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withValidForGreaterThanMaxInterval() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.plusDays(1));
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withValidForLowerThanMaxInterval() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.minusSeconds(1));
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL).minusSeconds(1), result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class EmpiricalEnabled {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Mockito.when(config.feedRefresh().intervalEmpirical()).thenReturn(true);
|
||||
calculator = new FeedRefreshIntervalCalculator(config, instantSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withNullPublishedDate() {
|
||||
Instant result = calculator.onFetchSuccess(null, 1L, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void with31DaysOldPublishedDate() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(31)), 1L, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void with15DaysOldPublishedDate() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(15)), 1L, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(2)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void with8DaysOldPublishedDate() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(8)), 1L, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class FiveDaysOld {
|
||||
@Test
|
||||
void averageBetweenBounds() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis(),
|
||||
Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void averageBelowMinimum() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 10L, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void averageAboveMaximum() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noAverage() {
|
||||
Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), null, Duration.ZERO);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class FeedNotModified {
|
||||
|
||||
@Nested
|
||||
class EmpiricalDisabled {
|
||||
@ParameterizedTest
|
||||
@ValueSource(longs = { 0, 1, 300, 86400000L })
|
||||
void withoutValidFor(long averageEntryInterval) {
|
||||
// averageEntryInterval is ignored when empirical is disabled
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), averageEntryInterval);
|
||||
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class EmpiricalEnabled {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Mockito.when(config.feedRefresh().intervalEmpirical()).thenReturn(true);
|
||||
calculator = new FeedRefreshIntervalCalculator(config, instantSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withNullPublishedDate() {
|
||||
Instant result = calculator.onFeedNotModified(null, 1L);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void with31DaysOldPublishedDate() {
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(31)), 1L);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void with15DaysOldPublishedDate() {
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(15)), 1L);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(2)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void with8DaysOldPublishedDate() {
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(8)), 1L);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class FiveDaysOld {
|
||||
@Test
|
||||
void averageBetweenBounds() {
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis());
|
||||
Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void averageBelowMinimum() {
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), 10L);
|
||||
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void averageAboveMaximum() {
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noAverage() {
|
||||
Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), null);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class FetchError {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Mockito.when(config.feedRefresh().errors().retriesBeforeBackoff()).thenReturn(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lowErrorCount() {
|
||||
Instant result = calculator.onFetchError(1);
|
||||
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void highErrorCount() {
|
||||
Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1));
|
||||
|
||||
Instant result = calculator.onFetchError(5);
|
||||
Assertions.assertEquals(NOW.plus(Duration.ofHours(3)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void veryHighErrorCount() {
|
||||
Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1));
|
||||
|
||||
Instant result = calculator.onFetchError(100000);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class TooManyRequests {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Mockito.when(config.feedRefresh().errors().retriesBeforeBackoff()).thenReturn(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withRetryAfterZero() {
|
||||
Instant result = calculator.onTooManyRequests(NOW, 1);
|
||||
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withRetryAfterLowerThanInterval() {
|
||||
Instant retryAfter = NOW.plus(DEFAULT_INTERVAL.minusSeconds(10));
|
||||
Instant result = calculator.onTooManyRequests(retryAfter, 1);
|
||||
Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withRetryAfterBetweenBounds() {
|
||||
Instant retryAfter = NOW.plus(DEFAULT_INTERVAL.plusSeconds(10));
|
||||
Instant result = calculator.onTooManyRequests(retryAfter, 1);
|
||||
Assertions.assertEquals(retryAfter, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withRetryAfterGreaterThanMaxInterval() {
|
||||
Instant retryAfter = NOW.plus(MAX_INTERVAL.plusSeconds(10));
|
||||
Instant result = calculator.onTooManyRequests(retryAfter, 1);
|
||||
Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,22 @@ class FeedCleanerTest {
|
||||
Assertions.assertEquals("<source>T´l´phone ′</source>", feedCleaner.replaceHtmlEntitiesWithNumericEntities(source));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveDoctype() {
|
||||
String source = "<!DOCTYPE html><html><head></head><body></body></html>";
|
||||
Assertions.assertEquals("<html><head></head><body></body></html>", feedCleaner.removeDoctypeDeclarations(source));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveMultilineDoctype() {
|
||||
String source = """
|
||||
<!DOCTYPE
|
||||
html
|
||||
>
|
||||
<html><head></head><body></body></html>""";
|
||||
Assertions.assertEquals("""
|
||||
|
||||
<html><head></head><body></body></html>""", feedCleaner.removeDoctypeDeclarations(source));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.commafeed.backend.urlprovider;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class YoutubeFeedURLProviderTest {
|
||||
|
||||
private final YoutubeFeedURLProvider provider = new YoutubeFeedURLProvider();
|
||||
|
||||
@Test
|
||||
void matchesYoutubeChannelURL() {
|
||||
Assertions.assertEquals("https://www.youtube.com/feeds/videos.xml?channel_id=abc",
|
||||
provider.get("https://www.youtube.com/channel/abc", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotmatchYoutubeChannelURL() {
|
||||
Assertions.assertNull(provider.get("https://www.anothersite.com/channel/abc", null));
|
||||
Assertions.assertNull(provider.get("https://www.youtube.com/user/abc", null));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -14,6 +16,7 @@ import org.mockserver.integration.ClientAndServer;
|
||||
import org.mockserver.model.HttpRequest;
|
||||
import org.mockserver.model.HttpResponse;
|
||||
|
||||
import com.commafeed.frontend.model.Entries;
|
||||
import com.microsoft.playwright.Browser;
|
||||
import com.microsoft.playwright.Locator;
|
||||
import com.microsoft.playwright.Page;
|
||||
@@ -22,6 +25,7 @@ import com.microsoft.playwright.assertions.PlaywrightAssertions;
|
||||
import com.microsoft.playwright.options.AriaRole;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
|
||||
@QuarkusTest
|
||||
class ReadingIT {
|
||||
@@ -40,11 +44,15 @@ class ReadingIT {
|
||||
.respond(HttpResponse.response()
|
||||
.withBody(IOUtils.toString(getClass().getResource("/feed/rss.xml"), StandardCharsets.UTF_8))
|
||||
.withDelay(TimeUnit.MILLISECONDS, 100));
|
||||
|
||||
RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
playwright.close();
|
||||
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -70,18 +78,28 @@ class ReadingIT {
|
||||
sidebar.getByText(Pattern.compile("CommaFeed test feed\\d+")).click();
|
||||
|
||||
// we have two unread entries
|
||||
PlaywrightAssertions.assertThat(main.locator(".mantine-Paper-root")).hasCount(2);
|
||||
PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(2);
|
||||
|
||||
// click on first entry
|
||||
main.getByText("Item 1").click();
|
||||
PlaywrightAssertions.assertThat(main.getByText("Item 1 description")).hasCount(1);
|
||||
PlaywrightAssertions.assertThat(main.getByText("Item 2 description")).hasCount(0);
|
||||
|
||||
// wait for the entry to be marked as read since the UI is updated immediately while the entry is marked as read in the background
|
||||
Awaitility.await()
|
||||
.atMost(15, TimeUnit.SECONDS)
|
||||
.until(() -> RestAssured.given()
|
||||
.get("rest/category/entries?id=all&readType=unread")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.as(Entries.class), e -> e.getEntries().size() == 1);
|
||||
|
||||
// click on subscription
|
||||
sidebar.getByText(Pattern.compile("CommaFeed test feed\\d+")).click();
|
||||
sidebar.getByText(Pattern.compile("CommaFeed test feed\\d*")).click();
|
||||
|
||||
// only one unread entry now
|
||||
PlaywrightAssertions.assertThat(main.locator(".mantine-Paper-root")).hasCount(1);
|
||||
PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(1);
|
||||
|
||||
// click on second entry
|
||||
main.getByText("Item 2").click();
|
||||
|
||||
@@ -13,6 +13,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,6 +42,11 @@ class WebSocketIT extends BaseIT {
|
||||
RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionClosedIfNotLoggedIn() throws DeploymentException, IOException {
|
||||
AtomicBoolean connected = new AtomicBoolean();
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.commafeed.integration.rest;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -25,6 +26,11 @@ class AdminIT extends BaseIT {
|
||||
RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Users {
|
||||
@Test
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.util.Objects;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -38,6 +39,11 @@ class FeedIT extends BaseIT {
|
||||
RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Fetch {
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.commafeed.integration.rest;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -37,6 +38,11 @@ class FeverIT extends BaseIT {
|
||||
this.userId = user.getId();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidApiKey() {
|
||||
FeverResponse response = fetch("feeds", "invalid-key");
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.commafeed.integration.rest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -29,6 +30,11 @@ class UserIT extends BaseIT {
|
||||
mailbox.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword() {
|
||||
PasswordResetRequest req = new PasswordResetRequest();
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.commafeed.integration.servlet;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.hamcrest.CoreMatchers;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -20,6 +21,11 @@ class CustomCodeIT extends BaseIT {
|
||||
RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
// get settings
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.commafeed.integration.servlet;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -18,6 +19,11 @@ class NextUnreadIT extends BaseIT {
|
||||
RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
|
||||
2
pom.xml
2
pom.xml
@@ -5,7 +5,7 @@
|
||||
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>5.3.5</version>
|
||||
<version>5.6.0</version>
|
||||
<name>CommaFeed</name>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"config:recommended",
|
||||
"customManagers:mavenPropertyVersions",
|
||||
"customManagers:biomeVersions",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
":automergePatch",
|
||||
":automergeBranch",
|
||||
":automergeRequireAllStatusChecks",
|
||||
@@ -31,7 +32,7 @@
|
||||
"matchDatasources": "docker",
|
||||
"matchPackageNames": "ibm-semeru-runtimes",
|
||||
"versioning": "regex:^open-(?<major>\\d+)?(\\.(?<minor>\\d+))?(\\.(?<patch>\\d+))?([\\._+](?<build>(\\d\\.?)+))?(-(?<compatibility>.*))?$",
|
||||
"allowedVersions": "/^open-(?:8|11|17|21)(?:\\.|-|$)/"
|
||||
"allowedVersions": "/^open-(?:8|11|17|21|25)(?:\\.|-|$)/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user