Compare commits

...

61 Commits
5.5.0 ... 5.6.1

Author SHA1 Message Date
Athou
42e4575cb7 release 5.6.1 2025-02-23 20:49:01 +01:00
Jérémie Panzer
28a4bb403a Merge pull request #1691 from dcelasun/arch-package
Mention Arch package
2025-02-23 20:47:57 +01:00
Athou
cca3c907db documentation is now hosted on github pages 2025-02-23 20:23:02 +01:00
Athou
1a5b932742 upload generated documentation to github pages 2025-02-23 20:05:04 +01:00
Jérémie Panzer
a1d3f3008a Merge pull request #1693 from Athou/renovate/ncipollo-release-action-digest
chore(deps): update ncipollo/release-action digest to 440c8c1
2025-02-23 08:22:14 +01:00
Jérémie Panzer
902f2efbd2 Merge pull request #1694 from Athou/renovate/vitest-mock-extended-3.x
chore(deps): update dependency vitest-mock-extended to v3
2025-02-23 08:21:47 +01:00
renovate[bot]
2e534af146 chore(deps): update dependency vitest-mock-extended to v3 2025-02-22 17:50:06 +00:00
renovate[bot]
23ca30c3c2 chore(deps): update ncipollo/release-action digest to 440c8c1 2025-02-22 17:49:45 +00:00
Athou
517eedad00 Merge branch 'generated-properties' 2025-02-22 11:12:16 +01:00
Jérémie Panzer
216ea1fb42 Merge pull request #1692 from Athou/renovate/actions-upload-artifact-digest
chore(deps): update actions/upload-artifact digest to 4cec3d8
2025-02-22 11:11:34 +01:00
Athou
640d1a0ce3 add a tool to generate a properties file from configuration (#1691) 2025-02-22 08:03:01 +01:00
renovate[bot]
bba7425b5f chore(deps): update actions/upload-artifact digest to 4cec3d8 2025-02-21 22:15:42 +00:00
D. Can Celasun
7a1a49bfb4 Mention Arch package 2025-02-21 13:02:59 +00:00
Jérémie Panzer
e451e6698c Merge pull request #1690 from Athou/renovate/org.apache.maven.plugins-maven-compiler-plugin-3.x
chore(deps): update dependency org.apache.maven.plugins:maven-compiler-plugin to v3.14.0
2025-02-21 13:52:18 +01:00
renovate[bot]
9af3f21404 chore(deps): update dependency org.apache.maven.plugins:maven-compiler-plugin to v3.14.0 2025-02-21 09:32:56 +00:00
renovate[bot]
7b14a9c0c2 chore(deps): update dependency com.diffplug.spotless:spotless-maven-plugin to v2.44.3 2025-02-21 02:12:51 +00:00
Jérémie Panzer
0b65cc9510 Merge pull request #1689 from Athou/renovate/vite-plugin-checker-0.x
chore(deps): update dependency vite-plugin-checker to ^0.9.0
2025-02-20 18:29:10 +01:00
renovate[bot]
7879ab9b61 chore(deps): update dependency vite-plugin-checker to ^0.9.0 2025-02-20 15:11:34 +00:00
Athou
e6bebcafb3 allow iframes in feed entries (#1688) 2025-02-20 10:07:45 +01:00
renovate[bot]
3b465cebb7 fix(deps): update quarkus.version to v3.18.4 2025-02-19 20:28:09 +00:00
renovate[bot]
aeb211be06 chore(deps): update dependency vite to ^6.1.1 2025-02-19 18:26:04 +00:00
Jérémie Panzer
ad992aea7b Merge pull request #1687 from Athou/renovate/docker-build-push-action-digest
chore(deps): update docker/build-push-action digest to 0adf995
2025-02-19 19:25:11 +01:00
renovate[bot]
d848f72a0b chore(deps): update docker/build-push-action digest to 0adf995 2025-02-19 17:00:18 +00:00
Athou
0db087908d remove "jakarta.ws.rs.WebApplicationException" from the errors displayed in the client 2025-02-19 13:08:43 +01:00
Jérémie Panzer
42138d04d6 Merge pull request #1685 from Athou/renovate/react-router-monorepo
fix(deps): update dependency react-router-dom to ^7.2.0
2025-02-19 10:16:26 +01:00
renovate[bot]
4522a9d0d5 fix(deps): update dependency react-router-dom to ^7.2.0 2025-02-19 06:32:35 +00:00
Jérémie Panzer
7440fcad0e Merge pull request #1686 from Athou/renovate/react-icons-5.x
fix(deps): update dependency react-icons to ^5.5.0
2025-02-19 07:31:05 +01:00
renovate[bot]
fc51c1882f fix(deps): update dependency react-icons to ^5.5.0 2025-02-19 05:47:14 +00:00
renovate[bot]
e24498b31f chore(deps): update dependency vitest to ^3.0.6 2025-02-18 14:27:16 +00:00
Athou
60fdc79563 don't expose rome's FeedException 2025-02-18 08:57:24 +01:00
Athou
6729ebc6ea throw a specific exception if we can't parse the url's content (#1684) 2025-02-18 07:41:02 +01:00
Athou
c8ff216ce5 README update 2025-02-17 21:23:38 +01:00
Jérémie Panzer
98c4150cfe Merge pull request #1683 from Athou/renovate/mantine-monorepo
fix(deps): update mantine monorepo to ^7.17.0 (minor)
2025-02-17 21:11:53 +01:00
renovate[bot]
128332d710 fix(deps): update mantine monorepo to ^7.17.0 2025-02-17 19:31:07 +00:00
renovate[bot]
eabcb519a4 chore(deps): update react monorepo 2025-02-17 11:01:27 +00:00
renovate[bot]
5e14cead3d chore(deps): lock file maintenance 2025-02-17 01:44:54 +00:00
renovate[bot]
b601f938ff chore(deps): update ibm-semeru-runtimes docker tag to open-21.0.6_7-jre (#1682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-16 21:26:14 +00:00
renovate[bot]
4acfda32d0 chore(deps): update dependency @types/react to ^19.0.9 2025-02-16 13:16:31 +00:00
Athou
54da4e6839 release 5.6.0 2025-02-16 14:14:30 +01:00
Athou
3a6b4c588c PRs and renovate now build the docker images regardless of the branch/tag 2025-02-15 10:20:39 +01:00
Athou
48071b9fd1 PRs now build the docker images but don't push them 2025-02-15 10:18:16 +01:00
Athou
f519aa039f block local addresses to prevent SSRF attacks 2025-02-14 16:20:04 +01:00
Athou
dc3e5476a1 reload the tree when we receive a websocket notification about an unknown feed 2025-02-14 16:16:07 +01:00
Athou
903035ecfc formatting 2025-02-14 16:16:07 +01:00
Athou
13ad57da10 make sure the tree has been reloaded before navigating to the new feed subscription 2025-02-14 16:16:06 +01:00
Athou
44bc24c22a ubuntu-22.04-arm is supposed to be more stable 2025-02-14 16:16:06 +01:00
Athou
97f90405fc try to fix flaky IT test 2025-02-14 16:16:06 +01:00
renovate[bot]
0fc2a0b022 fix(deps): update dependency @monaco-editor/react to ^4.7.0 2025-02-13 19:39:46 +00:00
Jérémie Panzer
89eb641704 Merge pull request #1679 from Athou/renovate/graalvm-setup-graalvm-digest
chore(deps): update graalvm/setup-graalvm digest to b0cb26a
2025-02-13 06:22:29 +01:00
renovate[bot]
c53da9f631 chore(deps): update graalvm/setup-graalvm digest to b0cb26a 2025-02-12 22:32:09 +00:00
renovate[bot]
998868e63a fix(deps): update quarkus.version to v3.18.3 2025-02-12 18:26:21 +00:00
Athou
93f22d2351 reduce max interval to 4h 2025-02-12 18:17:39 +01:00
Athou
c3782bd7d2 also constrain to lower bound 2025-02-12 18:04:56 +01:00
Athou
f330349397 update documentation 2025-02-12 17:31:46 +01:00
Athou
99c973c8c2 change the default value of empirical interval calculation (#1677) 2025-02-12 17:26:38 +01:00
Athou
469420b5bf feed refresh engine previously hardcoded values are now configurable (#1677) 2025-02-12 17:08:20 +01:00
Athou
bde556d41f start to back off when we repeatedly receive a 429 2025-02-12 08:00:27 +01:00
Jérémie Panzer
bf6c2d7beb Merge pull request #1678 from Athou/renovate/node-22.x
chore(deps): update node.js to v22.14.0
2025-02-11 16:03:32 +01:00
renovate[bot]
fa62ca21e0 chore(deps): update node.js to v22.14.0 2025-02-11 11:47:44 +00:00
renovate[bot]
7dcf76da84 fix(deps): update dependency interweave to ^13.1.1 2025-02-10 21:40:32 +01:00
renovate[bot]
3dc80fa762 chore(deps): lock file maintenance 2025-02-10 01:47:04 +00:00
35 changed files with 1771 additions and 1595 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
os: [ "ubuntu-latest", "ubuntu-24.04-arm", "windows-latest" ]
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
database: [ "h2", "postgresql", "mysql", "mariadb" ]
runs-on: ${{ matrix.os }}
@@ -32,7 +32,7 @@ jobs:
# Setup
- name: Set up GraalVM
uses: graalvm/setup-graalvm@aafbedb8d382ed0ca6167d3a051415f20c859274 # v1
uses: graalvm/setup-graalvm@b0cb26a8da53cb3e97cdc0c827d8e3071240e730 # v1
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm"
@@ -46,24 +46,44 @@ jobs:
- name: Build with Maven
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
# Build pages
- name: Copy generated markdown documentation to /documentation
run: mkdir documentation && cp ./commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md ./documentation/README.md
- name: Generate pages
uses: wranders/markdown-to-pages-action@8d8a750832932ac785f5424c8c5543aa0b26bb9a # v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
out_path: target/pages
files: |-
README.md
documentation/README.md
# Upload artifacts
- name: Upload cross-platform app
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 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@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
with:
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
path: commafeed-server/target/commafeed-*-runner*
- name: Upload pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
with:
path: target/pages
docker:
runs-on: ubuntu-latest
needs: build
if: ${{ github.ref_type == 'tag' || github.ref_name == 'master' }}
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
strategy:
matrix:
@@ -110,53 +130,71 @@ jobs:
# 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 }}
## tags
## build but don't push for PRs and renovate
- name: Docker build - native
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # 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@0adf9959216b96bec444f325f1e493d4aa344497 # 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
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6
if: ${{ github.ref_type == 'tag' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.native
push: true
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
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6
if: ${{ github.ref_type == 'tag' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.jvm
push: true
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
## master
## build and push master
- name: Docker build and push master - native
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6
if: ${{ github.ref_name == 'master' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.native
push: true
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
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6
if: ${{ github.ref_name == 'master' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.jvm
push: true
push: ${{ env.DOCKERHUB_USERNAME != '' }}
platforms: linux/amd64,linux/arm64/v8
tags: athou/commafeed:master-${{ matrix.database }}-jvm
@@ -192,12 +230,23 @@ jobs:
version: ${{ github.ref_name }}
- name: Create GitHub release
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1
with:
name: CommaFeed ${{ github.ref_name }}
body: ${{ steps.changelog_reader.outputs.changes }}
artifacts: ./artifacts/*
update-dockerhub-description:
runs-on: ubuntu-latest
needs: release
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
- name: Update Docker Hub Description
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
with:
@@ -206,3 +255,18 @@ jobs:
repository: athou/commafeed
short-description: ${{ github.event.repository.description }}
readme-filepath: commafeed-server/src/main/docker/README.md
deploy-pages:
runs-on: ubuntu-latest
needs: release
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
id: deployment

View File

@@ -1,5 +1,17 @@
# Changelog
## [5.6.1]
- Restore support for iframes in feed entries (#1688)
- There is now a package available for Arch Linux thanks to @dcelasun (#1691)
## [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

View File

@@ -48,10 +48,10 @@ system and database of choice.
There are two types of packages:
- The `linux-x86_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
- The `linux-x86_64`, `linux-aarch_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
directly.
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
platforms and is started with `java -jar quarkus-run.jar`.
platforms but requires a JRE and is started with `java -jar quarkus-run.jar`.
If available for your operating system, the native package is recommended because it has a faster startup time and lower
memory usage.
@@ -73,6 +73,10 @@ When the build is complete:
- if you used the native profile, the executable is located at
`commafeed-server/target/commafeed-<version>-<database>-<platform>-<arch>-runner[.exe]`
### Distribution packages
- Arch Linux users can use [the CommaFeed package on AUR](https://aur.archlinux.org/pkgbase/commafeed), which builds native binaries with GraalVM for all supported databases.
## Configuration
CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in
@@ -100,7 +104,7 @@ There are multiple ways to configure CommaFeed:
The properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
All [CommaFeed settings](commafeed-server/doc/commafeed.md) are optional and have sensible default values.
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are optional and have sensible default values.
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup,
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the

File diff suppressed because it is too large Load Diff

View File

@@ -17,20 +17,20 @@
"dependencies": {
"@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-rc.0",
"@mantine/core": "^7.17.0",
"@mantine/form": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/notifications": "^7.17.0",
"@mantine/spotlight": "^7.17.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",
"interweave": "^13.1.1",
"monaco-editor": "^0.52.2",
"mousetrap": "^1.6.5",
"react": "^19.0.0",
@@ -40,10 +40,10 @@
"react-dom": "^19.0.0",
"react-draggable": "^4.4.6",
"react-ga4": "^2.1.0",
"react-icons": "^5.4.0",
"react-icons": "^5.5.0",
"react-infinite-scroller": "^1.2.6",
"react-redux": "^9.2.0",
"react-router-dom": "^7.1.5",
"react-router-dom": "^7.2.0",
"react-swipeable": "^7.0.2",
"redoc": "^2.4.0",
"style-to-object": "^1.0.8",
@@ -57,9 +57,12 @@
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
"@lingui/cli": "^5.2.0",
"@lingui/vite-plugin": "^5.2.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/mousetrap": "^1.6.15",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroller": "^1.2.5",
"@types/throttle-debounce": "^5.0.2",
"@types/tinycon": "^0.6.7",
@@ -68,16 +71,13 @@
"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": "^6.1.1",
"vite-plugin-checker": "^0.9.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.5",
"vitest-mock-extended": "^2.0.2"
"vitest": "^3.0.6",
"vitest-mock-extended": "^3.0.1"
},
"overrides": {
"interweave": {
"react": "^19.0.0"
},
"react-infinite-scroller": {
"react": "^19.0.0"
}

View File

@@ -6,14 +6,14 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.5.0</version>
<version>5.6.1</version>
</parent>
<artifactId>commafeed-client</artifactId>
<name>CommaFeed Client</name>
<properties>
<!-- renovate: datasource=node-version depName=node -->
<node.version>v22.13.1</node.version>
<node.version>v22.14.0</node.version>
<!-- renovate: datasource=npm depName=npm -->
<npm.version>11.1.0</npm.version>
</properties>

View File

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

View File

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

View File

@@ -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,
})
)
}
}
)

View File

@@ -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",
(

View File

@@ -0,0 +1,27 @@
import { MantineProvider } from "@mantine/core"
import { render } from "@testing-library/react"
import { Content } from "components/content/Content"
import React from "react"
import { describe, expect, it } from "vitest"
describe("Content component", () => {
it("renders basic content", () => {
const { container } = render(<Content content="<p>Hello World</p>" />, { wrapper: MantineProvider })
expect(container.querySelector("p")).toHaveTextContent("Hello World")
})
it("renders highlighted text when highlight prop is provided", () => {
const { container } = render(<Content content="Hello World" highlight="World" />, { wrapper: MantineProvider })
expect(container.querySelector("mark")).toHaveTextContent("World")
})
it("renders iframe tag when included in content", () => {
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider })
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
})
it("does not render unsupported tags", () => {
const { container } = render(<Content content='<script>alert("test")</script>' />, { wrapper: MantineProvider })
expect(container.querySelector("script")).toBeNull()
})
})

View File

@@ -4,7 +4,7 @@ import { calculatePlaceholderSize } from "app/utils"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
import escapeStringRegexp from "escape-string-regexp"
import { type ChildrenNode, Interweave, type MatchResponse, Matcher, type Node, type TransformCallback } from "interweave"
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, type MatchResponse, Matcher, type Node, type TransformCallback } from "interweave"
import React from "react"
import styleToObject from "style-to-object"
import { tss } from "tss"
@@ -88,6 +88,9 @@ class HighlightMatcher extends Matcher {
}
}
// allow iframe tag
const allowList = [...ALLOWED_TAG_LIST, "iframe"]
// memoize component because Interweave is costly
const Content = React.memo((props: ContentProps) => {
const { classes } = useStyles()
@@ -96,7 +99,7 @@ const Content = React.memo((props: ContentProps) => {
return (
<BasicHtmlStyles>
<Box className={classes.content}>
<Interweave content={props.content} transform={transform} matchers={matchers} />
<Interweave content={props.content} transform={transform} matchers={matchers} allowList={allowList} />
</Box>
</BasicHtmlStyles>
)

View File

@@ -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))
},
})

View File

@@ -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],
})

View File

@@ -0,0 +1,16 @@
import "@testing-library/jest-dom"
import { vi } from "vitest"
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})

View File

@@ -52,5 +52,7 @@ export default defineConfig(() => ({
},
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/setupTests.ts",
},
}))

View File

@@ -1,799 +0,0 @@
🔒: Configuration property fixed at build time - All other configuration properties are overridable at runtime
<table>
<thead>
<tr>
<th align="left">Configuration property</th>
<th>Type</th>
<th>Default</th>
</tr>
</thead>
<tbody>
<tr>
<td>
`commafeed.hide-from-web-crawlers`
Whether to expose a robots.txt file that disallows web crawlers and search engine indexers.
Environment variable: `COMMAFEED_HIDE_FROM_WEB_CRAWLERS`</td>
<td>
boolean
</td>
<td>
`true`
</td>
</tr>
<tr>
<td>
`commafeed.image-proxy-enabled`
If enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser.
This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
Environment variable: `COMMAFEED_IMAGE_PROXY_ENABLED`</td>
<td>
boolean
</td>
<td>
`false`
</td>
</tr>
<tr>
<td>
`commafeed.password-recovery-enabled`
Enable password recovery via email.
Quarkus mailer will need to be configured.
Environment variable: `COMMAFEED_PASSWORD_RECOVERY_ENABLED`</td>
<td>
boolean
</td>
<td>
`false`
</td>
</tr>
<tr>
<td>
`commafeed.announcement`
Message displayed in a notification at the bottom of the page.
Environment variable: `COMMAFEED_ANNOUNCEMENT`</td>
<td>
string
</td>
<td>
</td>
</tr>
<tr>
<td>
`commafeed.google-analytics-tracking-code`
Google Analytics tracking code.
Environment variable: `COMMAFEED_GOOGLE_ANALYTICS_TRACKING_CODE`</td>
<td>
string
</td>
<td>
</td>
</tr>
<tr>
<td>
`commafeed.google-auth-key`
Google Auth key for fetching Youtube channel favicons.
Environment variable: `COMMAFEED_GOOGLE_AUTH_KEY`</td>
<td>
string
</td>
<td>
</td>
</tr>
<thead>
<tr>
<th align="left" colspan="3">
HTTP client configuration
</th>
</tr>
</thead>
<tr>
<td>
`commafeed.http-client.user-agent`
User-Agent string that will be used by the http client, leave empty for the default one.
Environment variable: `COMMAFEED_HTTP_CLIENT_USER_AGENT`</td>
<td>
string
</td>
<td>
</td>
</tr>
<tr>
<td>
`commafeed.http-client.connect-timeout`
Time to wait for a connection to be established.
Environment variable: `COMMAFEED_HTTP_CLIENT_CONNECT_TIMEOUT`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`5S`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.ssl-handshake-timeout`
Time to wait for SSL handshake to complete.
Environment variable: `COMMAFEED_HTTP_CLIENT_SSL_HANDSHAKE_TIMEOUT`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`5S`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.socket-timeout`
Time to wait between two packets before timeout.
Environment variable: `COMMAFEED_HTTP_CLIENT_SOCKET_TIMEOUT`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`10S`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.response-timeout`
Time to wait for the full response to be received.
Environment variable: `COMMAFEED_HTTP_CLIENT_RESPONSE_TIMEOUT`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`10S`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.connection-time-to-live`
Time to live for a connection in the pool.
Environment variable: `COMMAFEED_HTTP_CLIENT_CONNECTION_TIME_TO_LIVE`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`30S`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.idle-connections-eviction-interval`
Time between eviction runs for idle connections.
Environment variable: `COMMAFEED_HTTP_CLIENT_IDLE_CONNECTIONS_EVICTION_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>
`1M`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.max-response-size`
If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed.
Environment variable: `COMMAFEED_HTTP_CLIENT_MAX_RESPONSE_SIZE`</td>
<td>
MemorySize [🛈](#memory-size-note-anchor)
</td>
<td>
`5M`
</td>
</tr>
<thead>
<tr>
<th align="left" colspan="3">
&nbsp;&nbsp;&nbsp;&nbsp;HTTP client cache configuration
</th>
</tr>
</thead>
<tr>
<td>
`commafeed.http-client.cache.enabled`
Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when subscribing to a feed for the
first time or when clicking "fetch all my feeds now").
Environment variable: `COMMAFEED_HTTP_CLIENT_CACHE_ENABLED`</td>
<td>
boolean
</td>
<td>
`true`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.cache.maximum-memory-size`
Maximum amount of memory the cache can use.
Environment variable: `COMMAFEED_HTTP_CLIENT_CACHE_MAXIMUM_MEMORY_SIZE`</td>
<td>
MemorySize [🛈](#memory-size-note-anchor)
</td>
<td>
`10M`
</td>
</tr>
<tr>
<td>
`commafeed.http-client.cache.expiration`
Duration after which an entry is removed from the cache.
Environment variable: `COMMAFEED_HTTP_CLIENT_CACHE_EXPIRATION`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`1M`
</td>
</tr>
<thead>
<tr>
<th align="left" colspan="3">
Feed refresh engine settings
</th>
</tr>
</thead>
<tr>
<td>
`commafeed.feed-refresh.interval`
Amount of time CommaFeed will wait before refreshing the same feed.
Environment variable: `COMMAFEED_FEED_REFRESH_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>
`5M`
</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.
See <code>FeedRefreshIntervalCalculator</code> for details.
Environment variable: `COMMAFEED_FEED_REFRESH_INTERVAL_EMPIRICAL`</td>
<td>
boolean
</td>
<td>
`false`
</td>
</tr>
<tr>
<td>
`commafeed.feed-refresh.http-threads`
Amount of http threads used to fetch feeds.
Environment variable: `COMMAFEED_FEED_REFRESH_HTTP_THREADS`</td>
<td>
int
</td>
<td>
`3`
</td>
</tr>
<tr>
<td>
`commafeed.feed-refresh.database-threads`
Amount of threads used to insert new entries in the database.
Environment variable: `COMMAFEED_FEED_REFRESH_DATABASE_THREADS`</td>
<td>
int
</td>
<td>
`1`
</td>
</tr>
<tr>
<td>
`commafeed.feed-refresh.user-inactivity-period`
Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log in again.
0 to disable.
Environment variable: `COMMAFEED_FEED_REFRESH_USER_INACTIVITY_PERIOD`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`0S`
</td>
</tr>
<tr>
<td>
`commafeed.feed-refresh.filtering-expression-evaluation-timeout`
Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have timed out.
Environment variable: `COMMAFEED_FEED_REFRESH_FILTERING_EXPRESSION_EVALUATION_TIMEOUT`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`500MS`
</td>
</tr>
<tr>
<td>
`commafeed.feed-refresh.force-refresh-cooldown-duration`
Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds.
Environment variable: `COMMAFEED_FEED_REFRESH_FORCE_REFRESH_COOLDOWN_DURATION`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`0S`
</td>
</tr>
<thead>
<tr>
<th align="left" colspan="3">
Database settings
</th>
</tr>
</thead>
<tr>
<td>
`commafeed.database.query-timeout`
Timeout applied to all database queries.
0 to disable.
Environment variable: `COMMAFEED_DATABASE_QUERY_TIMEOUT`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`0S`
</td>
</tr>
<thead>
<tr>
<th align="left" colspan="3">
&nbsp;&nbsp;&nbsp;&nbsp;Database cleanup settings
</th>
</tr>
</thead>
<tr>
<td>
`commafeed.database.cleanup.entries-max-age`
Maximum age of feed entries in the database. Older entries will be deleted.
0 to disable.
Environment variable: `COMMAFEED_DATABASE_CLEANUP_ENTRIES_MAX_AGE`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`365D`
</td>
</tr>
<tr>
<td>
`commafeed.database.cleanup.statuses-max-age`
Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted.
0 to disable.
Environment variable: `COMMAFEED_DATABASE_CLEANUP_STATUSES_MAX_AGE`</td>
<td>
[Duration](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) [🛈](#duration-note-anchor)
</td>
<td>
`0S`
</td>
</tr>
<tr>
<td>
`commafeed.database.cleanup.max-feed-capacity`
Maximum number of entries per feed to keep in the database.
0 to disable.
Environment variable: `COMMAFEED_DATABASE_CLEANUP_MAX_FEED_CAPACITY`</td>
<td>
int
</td>
<td>
`500`
</td>
</tr>
<tr>
<td>
`commafeed.database.cleanup.max-feeds-per-user`
Limit the number of feeds a user can subscribe to.
0 to disable.
Environment variable: `COMMAFEED_DATABASE_CLEANUP_MAX_FEEDS_PER_USER`</td>
<td>
int
</td>
<td>
`0`
</td>
</tr>
<tr>
<td>
`commafeed.database.cleanup.batch-size`
Rows to delete per query while cleaning up old entries.
Environment variable: `COMMAFEED_DATABASE_CLEANUP_BATCH_SIZE`</td>
<td>
int
</td>
<td>
`100`
</td>
</tr>
<thead>
<tr>
<th align="left" colspan="3">
Users settings
</th>
</tr>
</thead>
<tr>
<td>
`commafeed.users.allow-registrations`
Whether to let users create accounts for themselves.
Environment variable: `COMMAFEED_USERS_ALLOW_REGISTRATIONS`</td>
<td>
boolean
</td>
<td>
`false`
</td>
</tr>
<tr>
<td>
`commafeed.users.strict-password-policy`
Whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char).
Environment variable: `COMMAFEED_USERS_STRICT_PASSWORD_POLICY`</td>
<td>
boolean
</td>
<td>
`true`
</td>
</tr>
<tr>
<td>
`commafeed.users.create-demo-account`
Whether to create a demo account the first time the app starts.
Environment variable: `COMMAFEED_USERS_CREATE_DEMO_ACCOUNT`</td>
<td>
boolean
</td>
<td>
`false`
</td>
</tr>
<thead>
<tr>
<th align="left" colspan="3">
Websocket settings
</th>
</tr>
</thead>
<tr>
<td>
`commafeed.websocket.enabled`
Enable websocket connection so the server can notify web clients that there are new entries for feeds.
Environment variable: `COMMAFEED_WEBSOCKET_ENABLED`</td>
<td>
boolean
</td>
<td>
`true`
</td>
</tr>
<tr>
<td>
`commafeed.websocket.ping-interval`
Interval at which the client will send a ping message on the websocket to keep the connection alive.
Environment variable: `COMMAFEED_WEBSOCKET_PING_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>
`15M`
</td>
</tr>
<tr>
<td>
`commafeed.websocket.tree-reload-interval`
If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at this interval.
Environment variable: `COMMAFEED_WEBSOCKET_TREE_RELOAD_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>
`30S`
</td>
</tr>
</tbody>
</table>
<a name="duration-note-anchor"></a>
> [!NOTE]
> ### About the Duration format
>
> To write duration values, use the standard `java.time.Duration` format.
> See the [Duration#parse()](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html#parse(java.lang.CharSequence)) Java API documentation] for more information.
>
> You can also use a simplified format, starting with a number:
>
> * If the value is only a number, it represents time in seconds.
> * If the value is a number followed by `ms`, it represents time in milliseconds.
>
> In other cases, the simplified format is translated to the `java.time.Duration` format for parsing:
>
> * If the value is a number followed by `h`, `m`, or `s`, it is prefixed with `PT`.
> * If the value is a number followed by `d`, it is prefixed with `P`.
<a name="memory-size-note-anchor"></a>
> [!NOTE]
> ### About the MemorySize format
>
> A size configuration option recognizes strings in this format (shown as a regular expression): `[0-9]+[KkMmGgTtPpEeZzYy]?`.
>
> If no suffix is given, assume bytes.

View File

@@ -6,13 +6,13 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.5.0</version>
<version>5.6.1</version>
</parent>
<artifactId>commafeed-server</artifactId>
<name>CommaFeed Server</name>
<properties>
<quarkus.version>3.18.2</quarkus.version>
<quarkus.version>3.18.4</quarkus.version>
<querydsl.version>6.10.1</querydsl.version>
<rome.version>2.1.0</rome.version>
<swagger.version>2.2.28</swagger.version>
@@ -115,6 +115,34 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-extension-processor</artifactId>
<version>${quarkus.version}</version>
</dependency>
</dependencies>
<configuration>
<includePluginDependencies>true</includePluginDependencies>
<mainClass>com.commafeed.tools.CommaFeedPropertiesGenerator</mainClass>
<arguments>
<argument>${project.build.directory}</argument>
</arguments>
</configuration>
<executions>
<execution>
<phase>process-test-resources</phase>
<goals>
<goal>java</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
@@ -270,7 +298,7 @@
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.44.2</version>
<version>2.44.3</version>
<?m2e ignore?>
<executions>
<execution>
@@ -297,7 +325,7 @@
<dependency>
<groupId>com.commafeed</groupId>
<artifactId>commafeed-client</artifactId>
<version>5.5.0</version>
<version>5.6.1</version>
</dependency>
<!-- compile-time processors -->

View File

@@ -1,4 +1,4 @@
FROM ibm-semeru-runtimes:open-21.0.5_11-jre
FROM ibm-semeru-runtimes:open-21.0.6_7-jre
EXPOSE 8082
RUN mkdir -p /commafeed/data

View File

@@ -68,7 +68,7 @@ CommaFeed also supports:
## Configuration
All [CommaFeed settings](https://github.com/Athou/commafeed/blob/master/commafeed-server/doc/commafeed.md) are
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are
optional and have sensible default values.
Settings are overrideable with environment variables. For instance, `commafeed.feed-refresh.interval-empirical` can be

View File

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

View File

@@ -2,7 +2,9 @@ package com.commafeed.backend;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.time.Duration;
import java.time.Instant;
import java.time.InstantSource;
@@ -10,8 +12,11 @@ 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;
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.config.TlsConfig;
@@ -66,6 +71,7 @@ public class HttpGetter {
private final InstantSource instantSource;
private final CloseableHttpClient client;
private final Cache<HttpRequest, HttpResponse> cache;
private final DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE;
public HttpGetter(CommaFeedConfiguration config, InstantSource instantSource, CommaFeedVersion version, MetricRegistry metrics) {
this.config = config;
@@ -89,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, TooManyRequestsException {
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, TooManyRequestsException {
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);
@@ -141,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());
@@ -229,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();
@@ -243,6 +280,7 @@ public class HttpGetter {
.setDefaultTlsConfig(TlsConfig.custom().setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout())).build())
.setMaxConnPerRoute(poolSize)
.setMaxConnTotal(poolSize)
.setDnsResolver(dnsResolver)
.build();
}
@@ -279,6 +317,22 @@ public class HttpGetter {
.build();
}
public static class SchemeNotAllowedException extends Exception {
private static final long serialVersionUID = 1L;
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);
}
}
@Getter
public static class NotModifiedException extends Exception {
private static final long serialVersionUID = 1L;

View File

@@ -11,8 +11,10 @@ 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;
@@ -92,7 +94,8 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
return new Favicon(bytes, contentType);
}
private byte[] fetchForUser(String googleAuthKey, String userId) throws IOException, NotModifiedException, TooManyRequestsException {
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)
@@ -102,7 +105,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
}
private byte[] fetchForChannel(String googleAuthKey, String channelId)
throws IOException, NotModifiedException, TooManyRequestsException {
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", "snippet")
.queryParam("key", googleAuthKey)
@@ -112,7 +115,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
}
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
throws IOException, NotModifiedException, TooManyRequestsException {
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
.queryParam("part", "snippet")
.queryParam("key", googleAuthKey)

View File

@@ -10,14 +10,16 @@ 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.FeedParser.FeedParsingException;
import com.commafeed.backend.feed.parser.FeedParserResult;
import com.commafeed.backend.urlprovider.FeedURLProvider;
import com.rometools.rome.io.FeedException;
import io.quarkus.arc.All;
import jakarta.inject.Singleton;
@@ -41,8 +43,8 @@ public class FeedFetcher {
}
public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag,
Instant lastPublishedDate, String lastContentHash)
throws FeedException, IOException, NotModifiedException, TooManyRequestsException {
Instant lastPublishedDate, String lastContentHash) throws FeedParsingException, IOException, NotModifiedException,
TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException, NoFeedFoundException {
log.debug("Fetching feed {}", feedUrl);
HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
@@ -51,17 +53,17 @@ public class FeedFetcher {
FeedParserResult parserResult;
try {
parserResult = parser.parse(result.getUrlAfterRedirect(), content);
} catch (FeedException e) {
} catch (FeedParsingException e) {
if (extractFeedUrlFromHtml) {
String extractedUrl = extractFeedUrl(urlProviders, feedUrl, new String(result.getContent(), StandardCharsets.UTF_8));
if (org.apache.commons.lang3.StringUtils.isNotBlank(extractedUrl)) {
if (StringUtils.isNotBlank(extractedUrl)) {
feedUrl = extractedUrl;
result = getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build());
content = result.getContent();
parserResult = parser.parse(result.getUrlAfterRedirect(), content);
} else {
throw e;
throw new NoFeedFoundException(e);
}
} else {
throw e;
@@ -109,4 +111,12 @@ public class FeedFetcher {
String contentHash, Duration validFor) {
}
public static class NoFeedFoundException extends Exception {
private static final long serialVersionUID = 1L;
public NoFeedFoundException(Throwable cause) {
super("This URL does not point to an RSS feed or a website with an RSS feed.", cause);
}
}
}

View File

@@ -2,89 +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) {
Instant defaultRefreshInterval = getDefaultRefreshInterval();
if (retryAfter == null) {
return defaultRefreshInterval;
}
return ObjectUtils.max(retryAfter, defaultRefreshInterval);
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));
}
}

View File

@@ -6,7 +6,6 @@ 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;
@@ -78,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) {
@@ -104,7 +102,7 @@ public class FeedRefreshWorker {
feed.setErrorCount(feed.getErrorCount() + 1);
feed.setMessage("Server indicated that we are sending too many requests");
feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter()));
feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount()));
return new FeedRefreshWorkerResult(feed, Collections.emptyList());
} catch (Exception e) {

View File

@@ -35,7 +35,6 @@ import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.feed.synd.SyndLink;
import com.rometools.rome.feed.synd.SyndLinkImpl;
import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.SyndFeedInput;
import jakarta.inject.Singleton;
@@ -56,12 +55,12 @@ public class FeedParser {
private final EncodingDetector encodingDetector;
private final FeedCleaner feedCleaner;
public FeedParserResult parse(String feedUrl, byte[] xml) throws FeedException {
public FeedParserResult parse(String feedUrl, byte[] xml) throws FeedParsingException {
try {
Charset encoding = encodingDetector.getEncoding(xml);
String xmlString = feedCleaner.trimInvalidXmlCharacters(new String(xml, encoding));
if (xmlString == null) {
throw new FeedException("Input string is null for url " + feedUrl);
throw new FeedParsingException("Input string is null for url " + feedUrl);
}
xmlString = feedCleaner.replaceHtmlEntitiesWithNumericEntities(xmlString);
xmlString = feedCleaner.removeDoctypeDeclarations(xmlString);
@@ -81,8 +80,10 @@ public class FeedParser {
Long averageEntryInterval = averageTimeBetweenEntries(entries);
return new FeedParserResult(title, link, lastPublishedDate, averageEntryInterval, lastEntryDate, entries);
} catch (FeedParsingException e) {
throw e;
} catch (Exception e) {
throw new FeedException(String.format("Could not parse feed from %s : %s", feedUrl, e.getMessage()), e);
throw new FeedParsingException(String.format("Could not parse feed from %s : %s", feedUrl, e.getMessage()), e);
}
}
@@ -268,4 +269,16 @@ public class FeedParser {
return (long) stats.getMean();
}
public static class FeedParsingException extends Exception {
private static final long serialVersionUID = 1L;
public FeedParsingException(String message) {
super(message);
}
public FeedParsingException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -264,10 +264,7 @@ public class FeedREST {
info = fetchFeedInternal(req.getUrl());
} catch (Exception e) {
Throwable cause = Throwables.getRootCause(e);
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity(cause.getClass().getName() + ": " + cause.getMessage())
.type(MediaType.TEXT_PLAIN)
.build();
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(cause.getMessage()).type(MediaType.TEXT_PLAIN).build();
}
return Response.ok(info).build();
}

View File

@@ -0,0 +1,80 @@
package com.commafeed.tools;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import com.commafeed.CommaFeedConfiguration;
import io.quarkus.annotation.processor.Outputs;
import io.quarkus.annotation.processor.documentation.config.model.AbstractConfigItem;
import io.quarkus.annotation.processor.documentation.config.model.ConfigProperty;
import io.quarkus.annotation.processor.documentation.config.model.ConfigRoot;
import io.quarkus.annotation.processor.documentation.config.model.ConfigSection;
import io.quarkus.annotation.processor.documentation.config.model.JavadocElements;
import io.quarkus.annotation.processor.documentation.config.model.ResolvedModel;
import io.quarkus.annotation.processor.documentation.config.util.JacksonMappers;
/**
* This class generates an application.properties file with all the properties from {@link CommaFeedConfiguration}.
*
* This is useful for people who want to be able to configure CommaFeed without having to look at the code or the documentation, or for
* distribution packages that want to provide a default configuration file.
*
**/
public class CommaFeedPropertiesGenerator {
private final List<String> lines = new ArrayList<>();
public static void main(String[] args) throws Exception {
new CommaFeedPropertiesGenerator().generate(args);
}
private void generate(String[] args) throws IOException {
Path targetPath = Paths.get(args[0]);
ResolvedModel resolvedModel = JacksonMappers.yamlObjectReader()
.readValue(targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_MODEL).toFile(), ResolvedModel.class);
JavadocElements javadocElements = JacksonMappers.yamlObjectReader()
.readValue(targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_JAVADOC).toFile(), JavadocElements.class);
for (ConfigRoot configRoot : resolvedModel.getConfigRoots()) {
for (AbstractConfigItem item : configRoot.getItems()) {
handleAbstractConfigItem(item, javadocElements);
}
}
Files.writeString(targetPath.resolve("quarkus-generated-doc").resolve("application.properties"), String.join("\n", lines));
}
private void handleAbstractConfigItem(AbstractConfigItem item, JavadocElements javadocElements) {
if (item.isSection()) {
handleSection((ConfigSection) item, javadocElements);
} else {
handleProperty((ConfigProperty) item, javadocElements);
}
}
private void handleSection(ConfigSection section, JavadocElements javadocElements) {
for (AbstractConfigItem item : section.getItems()) {
handleAbstractConfigItem(item, javadocElements);
}
}
private void handleProperty(ConfigProperty property, JavadocElements javadocElements) {
String key = property.getPath().property();
String description = javadocElements.elements()
.get(property.getSourceType() + "." + property.getSourceElementName())
.description()
.replace("\n", "\n# ");
String defaultValue = Optional.ofNullable(property.getDefaultValue()).orElse("").toLowerCase();
lines.add("# " + description);
lines.add(key + "=" + defaultValue);
lines.add("");
}
}

View File

@@ -50,6 +50,7 @@ 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

View File

@@ -1,22 +0,0 @@
package com.commafeed;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class CommaFeedConfigurationTest {
@Test
void verifyMarkdownDocIsUpToDate() throws IOException {
String versionedDocumentationFile = FileUtils.readFileToString(new File("doc/commafeed.md"), StandardCharsets.UTF_8);
String generatedDocumentationFile = FileUtils.readFileToString(new File("target/quarkus-generated-doc/config/commafeed-server.md"),
StandardCharsets.UTF_8);
Assertions.assertLinesMatch(versionedDocumentationFile.lines(), generatedDocumentationFile.lines());
}
}

View File

@@ -236,7 +236,7 @@ class HttpGetterTest {
}
@Test
void cacheSubsequentCalls() throws IOException, NotModifiedException, TooManyRequestsException {
void cacheSubsequentCalls() throws Exception {
AtomicInteger calls = new AtomicInteger();
this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> {
@@ -302,17 +302,16 @@ class HttpGetterTest {
class Compression {
@Test
void deflate() throws IOException, NotModifiedException, TooManyRequestsException {
void deflate() throws Exception {
supportsCompression("deflate", DeflaterOutputStream::new);
}
@Test
void gzip() throws IOException, NotModifiedException, TooManyRequestsException {
void gzip() throws Exception {
supportsCompression("gzip", GZIPOutputStream::new);
}
void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction)
throws IOException, NotModifiedException, TooManyRequestsException {
void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction) throws Exception {
String body = "my body";
HttpGetterTest.this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> {
@@ -340,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"));
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -3,6 +3,7 @@ package com.commafeed.e2e;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.apache.commons.io.IOUtils;
import org.apache.hc.core5.http.HttpStatus;
@@ -73,8 +74,8 @@ class ReadingIT {
main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Next")).click();
main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe").setExact(true)).click();
// click on subscription, "2" is actually the unread count
sidebar.getByText("CommaFeed test feed2").click();
// click on subscription
sidebar.getByText(Pattern.compile("CommaFeed test feed\\d+")).click();
// we have two unread entries
PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(2);
@@ -94,8 +95,8 @@ class ReadingIT {
.extract()
.as(Entries.class), e -> e.getEntries().size() == 1);
// click on subscription, "1" is actually the unread count
sidebar.getByText("CommaFeed test feed1").click();
// click on subscription
sidebar.getByText(Pattern.compile("CommaFeed test feed\\d*")).click();
// only one unread entry now
PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(1);

View File

@@ -5,7 +5,7 @@
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.5.0</version>
<version>5.6.1</version>
<name>CommaFeed</name>
<packaging>pom</packaging>
@@ -19,7 +19,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<version>3.14.0</version>
<configuration>
<parameters>true</parameters>