normalize line endings

This commit is contained in:
Athou
2025-03-10 08:38:21 +01:00
parent ec4554c76e
commit fb7f041454
223 changed files with 18091 additions and 18093 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
* text eol=lf
*.cmd text eol=crlf

36
.github/stale.yml vendored
View File

@@ -1,19 +1,19 @@
# Number of days of inactivity before an issue becomes stale # Number of days of inactivity before an issue becomes stale
daysUntilStale: 60 daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed # Number of days of inactivity before a stale issue is closed
daysUntilClose: 7 daysUntilClose: 7
# Issues with these labels will never be considered stale # Issues with these labels will never be considered stale
exemptLabels: exemptLabels:
- pinned - pinned
- security - security
- enhancement - enhancement
- bug - bug
# Label to use when marking an issue as stale # Label to use when marking an issue as stale
staleLabel: wontfix staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable
markComment: > markComment: >
This issue has been automatically marked as stale because it has not had This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you recent activity. It will be closed if no further activity occurs. Thank you
for your contributions. for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable # Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false closeComment: false

View File

@@ -1,272 +1,269 @@
name: ci name: ci
permissions: permissions:
contents: read contents: read
on: on:
push: push:
pull_request: pull_request:
env: env:
JAVA_VERSION: 21 JAVA_VERSION: 21
DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_SUMMARY: false
jobs: jobs:
build: build:
if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes
strategy: strategy:
matrix: matrix:
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ] os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
database: [ "h2", "postgresql", "mysql", "mariadb" ] database: [ "h2", "postgresql", "mysql", "mariadb" ]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
# Checkout # Checkout
- name: Configure git to checkout as-is - name: Checkout
run: git config --global core.autocrlf false uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
- name: Checkout fetch-depth: 0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: # Setup
fetch-depth: 0 - name: Set up GraalVM
uses: graalvm/setup-graalvm@01ed653ac833fe80569f1ef9f25585ba2811baab # v1
# Setup with:
- name: Set up GraalVM java-version: ${{ env.JAVA_VERSION }}
uses: graalvm/setup-graalvm@01ed653ac833fe80569f1ef9f25585ba2811baab # v1 distribution: "graalvm"
with: cache: "maven"
java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm" - name: Install Playwright dependencies
cache: "maven" run: sudo apt-get install -y libgbm1
if: matrix.os != 'windows-latest'
- name: Install Playwright dependencies
run: sudo apt-get install -y libgbm1 # Build & Test
if: matrix.os != 'windows-latest' - 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 & Test
- name: Build with Maven # Build pages
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }} - name: Copy generated markdown documentation to /documentation
run: mkdir documentation && cp ./commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md ./documentation/README.md
# Build pages
- name: Copy generated markdown documentation to /documentation - name: Generate pages
run: mkdir documentation && cp ./commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md ./documentation/README.md uses: wranders/markdown-to-pages-action@8d8a750832932ac785f5424c8c5543aa0b26bb9a # v1
with:
- name: Generate pages token: ${{ secrets.GITHUB_TOKEN }}
uses: wranders/markdown-to-pages-action@8d8a750832932ac785f5424c8c5543aa0b26bb9a # v1 out_path: target/pages
with: files: |-
token: ${{ secrets.GITHUB_TOKEN }} README.md
out_path: target/pages documentation/README.md
files: |-
README.md # Upload artifacts
documentation/README.md - name: Upload cross-platform app
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
# Upload artifacts if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
- name: Upload cross-platform app with:
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 name: commafeed-${{ matrix.database }}-jvm
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database path: commafeed-server/target/commafeed-*.zip
with:
name: commafeed-${{ matrix.database }}-jvm - name: Upload native executable
path: commafeed-server/target/commafeed-*.zip uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
with:
- name: Upload native executable name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 path: commafeed-server/target/commafeed-*-runner*
with:
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }} - name: Upload pages
path: commafeed-server/target/commafeed-*-runner* uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
- name: Upload pages with:
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 path: target/pages
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
with: docker:
path: target/pages runs-on: ubuntu-latest
needs: build
docker: env:
runs-on: ubuntu-latest DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
needs: build
env: strategy:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} matrix:
database: [ "h2", "postgresql", "mysql", "mariadb" ]
strategy:
matrix: steps:
database: [ "h2", "postgresql", "mysql", "mariadb" ] # Checkout
- name: Checkout
steps: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
# Checkout with:
- name: Checkout fetch-depth: 0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: # Setup
fetch-depth: 0 - name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
# Setup
- name: Set up QEMU - name: Set up Docker Buildx
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
- name: Set up Docker Buildx - name: Install required packages
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 run: sudo apt-get install -y rename unzip
- name: Install required packages # Prepare artifacts
run: sudo apt-get install -y rename unzip - name: Download artifacts
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4
# Prepare artifacts with:
- name: Download artifacts pattern: commafeed-${{ matrix.database }}-*
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 path: ./artifacts
with: merge-multiple: true
pattern: commafeed-${{ matrix.database }}-*
path: ./artifacts - name: Set the exec flag on the native executables
merge-multiple: true run: chmod +x artifacts/*-runner
- name: Set the exec flag on the native executables - name: Rename native executables to match buildx TARGETARCH
run: chmod +x artifacts/*-runner run: |
rename 's/x86_64/amd64/g' artifacts/*
- name: Rename native executables to match buildx TARGETARCH rename 's/aarch_64/arm64/g' artifacts/*
run: |
rename 's/x86_64/amd64/g' artifacts/* - name: Unzip jvm package
rename 's/aarch_64/arm64/g' artifacts/* run: |
unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package
- name: Unzip jvm package rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/*
run: |
unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package # Docker
rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/* - name: Login to Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
# Docker if: ${{ env.DOCKERHUB_USERNAME != '' }}
- name: Login to Container Registry with:
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 username: ${{ secrets.DOCKERHUB_USERNAME }}
if: ${{ env.DOCKERHUB_USERNAME != '' }} password: ${{ secrets.DOCKERHUB_TOKEN }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }} ## build but don't push for PRs and renovate
password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Docker build - native
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
## build but don't push for PRs and renovate with:
- name: Docker build - native context: .
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 file: commafeed-server/src/main/docker/Dockerfile.native
with: push: false
context: . platforms: linux/amd64,linux/arm64/v8
file: commafeed-server/src/main/docker/Dockerfile.native
push: false - name: Docker build - jvm
platforms: linux/amd64,linux/arm64/v8 uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
with:
- name: Docker build - jvm context: .
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 file: commafeed-server/src/main/docker/Dockerfile.jvm
with: push: false
context: . platforms: linux/amd64,linux/arm64/v8
file: commafeed-server/src/main/docker/Dockerfile.jvm
push: false ## build and push tag
platforms: linux/amd64,linux/arm64/v8 - name: Docker build and push tag - native
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
## build and push tag if: ${{ github.ref_type == 'tag' }}
- name: Docker build and push tag - native with:
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 context: .
if: ${{ github.ref_type == 'tag' }} file: commafeed-server/src/main/docker/Dockerfile.native
with: push: ${{ env.DOCKERHUB_USERNAME != '' }}
context: . platforms: linux/amd64,linux/arm64/v8
file: commafeed-server/src/main/docker/Dockerfile.native tags: |
push: ${{ env.DOCKERHUB_USERNAME != '' }} athou/commafeed:latest-${{ matrix.database }}
platforms: linux/amd64,linux/arm64/v8 athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
tags: |
athou/commafeed:latest-${{ matrix.database }} - name: Docker build and push tag - jvm
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }} uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
if: ${{ github.ref_type == 'tag' }}
- name: Docker build and push tag - jvm with:
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 context: .
if: ${{ github.ref_type == 'tag' }} file: commafeed-server/src/main/docker/Dockerfile.jvm
with: push: ${{ env.DOCKERHUB_USERNAME != '' }}
context: . platforms: linux/amd64,linux/arm64/v8
file: commafeed-server/src/main/docker/Dockerfile.jvm tags: |
push: ${{ env.DOCKERHUB_USERNAME != '' }} athou/commafeed:latest-${{ matrix.database }}-jvm
platforms: linux/amd64,linux/arm64/v8 athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
tags: |
athou/commafeed:latest-${{ matrix.database }}-jvm ## build and push master
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm - name: Docker build and push master - native
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
## build and push master if: ${{ github.ref_name == 'master' }}
- name: Docker build and push master - native with:
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 context: .
if: ${{ github.ref_name == 'master' }} file: commafeed-server/src/main/docker/Dockerfile.native
with: push: ${{ env.DOCKERHUB_USERNAME != '' }}
context: . platforms: linux/amd64,linux/arm64/v8
file: commafeed-server/src/main/docker/Dockerfile.native tags: athou/commafeed:master-${{ matrix.database }}
push: ${{ env.DOCKERHUB_USERNAME != '' }}
platforms: linux/amd64,linux/arm64/v8 - name: Docker build and push master - jvm
tags: athou/commafeed:master-${{ matrix.database }} uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
if: ${{ github.ref_name == 'master' }}
- name: Docker build and push master - jvm with:
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 context: .
if: ${{ github.ref_name == 'master' }} file: commafeed-server/src/main/docker/Dockerfile.jvm
with: push: ${{ env.DOCKERHUB_USERNAME != '' }}
context: . platforms: linux/amd64,linux/arm64/v8
file: commafeed-server/src/main/docker/Dockerfile.jvm tags: athou/commafeed:master-${{ matrix.database }}-jvm
push: ${{ env.DOCKERHUB_USERNAME != '' }}
platforms: linux/amd64,linux/arm64/v8 release:
tags: athou/commafeed:master-${{ matrix.database }}-jvm runs-on: ubuntu-latest
needs:
release: - build
runs-on: ubuntu-latest - docker
needs: permissions:
- build contents: write
- docker if: github.ref_type == 'tag'
permissions:
contents: write steps:
if: github.ref_type == 'tag' - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
steps: with:
- name: Checkout fetch-depth: 0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: - name: Download artifacts
fetch-depth: 0 uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4
with:
- name: Download artifacts pattern: commafeed-*
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 path: ./artifacts
with: merge-multiple: true
pattern: commafeed-*
path: ./artifacts - name: Set the exec flag on the native executables
merge-multiple: true run: chmod +x artifacts/*-runner
- name: Set the exec flag on the native executables - name: Extract Changelog Entry
run: chmod +x artifacts/*-runner uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2
id: changelog_reader
- name: Extract Changelog Entry with:
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2 version: ${{ github.ref_name }}
id: changelog_reader
with: - name: Create GitHub release
version: ${{ github.ref_name }} uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1
with:
- name: Create GitHub release name: CommaFeed ${{ github.ref_name }}
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1 body: ${{ steps.changelog_reader.outputs.changes }}
with: artifacts: ./artifacts/*
name: CommaFeed ${{ github.ref_name }}
body: ${{ steps.changelog_reader.outputs.changes }}
artifacts: ./artifacts/* update-dockerhub-description:
runs-on: ubuntu-latest
needs: release
update-dockerhub-description:
runs-on: ubuntu-latest steps:
needs: release - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
steps: with:
- name: Checkout fetch-depth: 0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: - name: Update Docker Hub Description
fetch-depth: 0 uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
with:
- name: Update Docker Hub Description username: ${{ secrets.DOCKERHUB_USERNAME }}
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 password: ${{ secrets.DOCKERHUB_TOKEN }}
with: repository: athou/commafeed
username: ${{ secrets.DOCKERHUB_USERNAME }} short-description: ${{ github.event.repository.description }}
password: ${{ secrets.DOCKERHUB_TOKEN }} readme-filepath: commafeed-server/src/main/docker/README.md
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
deploy-pages: permissions:
runs-on: ubuntu-latest pages: write
needs: release id-token: write
permissions: environment:
pages: write name: github-pages
id-token: write url: ${{ steps.deployment.outputs.page_url }}
environment:
name: github-pages steps:
url: ${{ steps.deployment.outputs.page_url }} - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
id: deployment
steps:
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
id: deployment

View File

@@ -1,18 +1,18 @@
# Licensed to the Apache Software Foundation (ASF) under one # Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file # or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information # distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file # regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the # to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance # "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at # with the License. You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, # Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an # software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
distributionType=only-script distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

View File

@@ -1,466 +1,466 @@
# Changelog # Changelog
## [5.6.1] ## [5.6.1]
- Restore support for iframes in feed entries (#1688) - Restore support for iframes in feed entries (#1688)
- There is now a package available for Arch Linux thanks to @dcelasun (#1691) - There is now a package available for Arch Linux thanks to @dcelasun (#1691)
## [5.6.0] ## [5.6.0]
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677) - To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677)
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677) - Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users - Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could 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 - If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header
## [5.5.0] ## [5.5.0]
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header - CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header
- Audio enclosures (e.g. podcasts) now fill available entry width - Audio enclosures (e.g. podcasts) now fill available entry width
- Fix an issue with some labels not correctly internationalized - Fix an issue with some labels not correctly internationalized
## [5.4.0] ## [5.4.0]
- An arm64 native executable is now available for download on the releases page - An arm64 native executable is now available for download on the releases page
- The native executable Docker image now supports arm64 - The native executable Docker image now supports arm64
- Fixed an issue with feeds that declared an invalid DOCTYPE (#1260) - Fixed an issue with feeds that declared an invalid DOCTYPE (#1260)
## [5.3.6] ## [5.3.6]
- Ignore invalid Cache-Control header values (#1619) - Ignore invalid Cache-Control header values (#1619)
## [5.3.5] ## [5.3.5]
- Fixed an issue with the aspect ratio of images of some feeds (#1595) - Fixed an issue with the aspect ratio of images of some feeds (#1595)
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615) - CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615)
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618) - Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618)
## [5.3.4] ## [5.3.4]
- Added support for Internationalized Domain Names (#1588) - Added support for Internationalized Domain Names (#1588)
## [5.3.3] ## [5.3.3]
- Removed image bottom margins (#1587) - Removed image bottom margins (#1587)
## [5.3.2] ## [5.3.2]
- Fixed an issue that could cause some images from not being rendered correctly (#1587) - Fixed an issue that could cause some images from not being rendered correctly (#1587)
## [5.3.1] ## [5.3.1]
- Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572) - Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572)
## [5.3.0] ## [5.3.0]
- Added a setting to set a cooldown on the "fetch all my feeds" action, disabled by default (#1556) - Added a setting to set a cooldown on the "fetch all my feeds" action, disabled by default (#1556)
- Fixed an issue that could cause entries to not correctly load when using the "next" header button (#1557) - Fixed an issue that could cause entries to not correctly load when using the "next" header button (#1557)
## [5.2.0] ## [5.2.0]
- Added an option to keep a number of entries above the selected entry when scrolling - Added an option to keep a number of entries above the selected entry when scrolling
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431) - Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour) - Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557) - Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
## [5.1.1] ## [5.1.1]
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544) - Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543) - When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543)
## [5.1.0] ## [5.1.0]
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518) - Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
- Fixed an issue that could prevent the app from starting on some systems (#1532) - Fixed an issue that could prevent the app from starting on some systems (#1532)
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date - Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date
- Reduced database cleanup log verbosity - Reduced database cleanup log verbosity
## [5.0.2] ## [5.0.2]
- Fix favicon fetching for Youtube channels in native mode when Google auth key is set - Fix favicon fetching for Youtube channels in native mode when Google auth key is set
- Fix an error that appears in the logs when fetching some favicons - Fix an error that appears in the logs when fetching some favicons
## [5.0.1] ## [5.0.1]
- Configure native compilation to support older CPU architectures (#1524) - Configure native compilation to support older CPU architectures (#1524)
## [5.0.0] ## [5.0.0]
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
the [announcement](https://github.com/Athou/commafeed/discussions/1517). the [announcement](https://github.com/Athou/commafeed/discussions/1517).
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
0.3s) and very low memory footprint (< 50M). 0.3s) and very low memory footprint (< 50M).
- CommaFeed now has a different package for each supported database. - CommaFeed now has a different package for each supported database.
- If you are deploying CommaFeed with a precompiled package, please - If you are deploying CommaFeed with a precompiled package, please
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#download-a-precompiled-package). read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#download-a-precompiled-package).
- If you are building CommaFeed from sources, please - If you are building CommaFeed from sources, please
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#build-from-sources). read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#build-from-sources).
- If you are using the Docker image, please read the instructions on - If you are using the Docker image, please read the instructions on
the [Docker Hub page](https://hub.docker.com/r/athou/commafeed). the [Docker Hub page](https://hub.docker.com/r/athou/commafeed).
- Due to the switch to Quarkus, the way CommaFeed is configured is very different (the `config.yml` file is gone). - Due to the switch to Quarkus, the way CommaFeed is configured is very different (the `config.yml` file is gone).
Please Please
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#configuration). read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#configuration).
Note that a lot of configuration elements have been removed or renamed and are now nested/grouped by feature. Note that a lot of configuration elements have been removed or renamed and are now nested/grouped by feature.
- Added a setting to prevent parsing large feeds to avoid out of memory errors. The default is 5MB. - Added a setting to prevent parsing large feeds to avoid out of memory errors. The default is 5MB.
- Use a different icon for filtering unread entries and marking an entry as read (#1506) - Use a different icon for filtering unread entries and marking an entry as read (#1506)
- Added various HTML attributes to ease custom JS/CSS customization (#1507) - Added various HTML attributes to ease custom JS/CSS customization (#1507)
- The Redis cache has been removed. There have been multiple enhancements to the feed refresh engine and it is no longer - The Redis cache has been removed. There have been multiple enhancements to the feed refresh engine and it is no longer
needed, even for instances with a large number of feeds. needed, even for instances with a large number of feeds.
- The H2 migration tool that automatically upgrades H2 databases from format 2 to 3 has been removed. If you're using - The H2 migration tool that automatically upgrades H2 databases from format 2 to 3 has been removed. If you're using
the H2 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.0. the H2 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.0.
## [4.6.0] ## [4.6.0]
- switched from Temurin to OpenJ9 as the JVM used in the Docker image, resulting in memory usage reduction by up to 50% - switched from Temurin to OpenJ9 as the JVM used in the Docker image, resulting in memory usage reduction by up to 50%
- fix an issue that could cause old entries to reappear if they were updated by their author (#1486) - fix an issue that could cause old entries to reappear if they were updated by their author (#1486)
- show all entries regardless of their read status when searching with keywords, even if the ui is configured to show - show all entries regardless of their read status when searching with keywords, even if the ui is configured to show
unread entries only unread entries only
## [4.5.0] ## [4.5.0]
- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of - significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of
entries (#1452) entries (#1452)
- fix a race condition where a feed could be refreshed before it was created in the database - fix a race condition where a feed could be refreshed before it was created in the database
- fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using - fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using
mysql/mariadb mysql/mariadb
- fix an error when trying to mark all starred entries as read - fix an error when trying to mark all starred entries as read
- remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast - remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast
- remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd - remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd
like it back) like it back)
## [4.4.1] ## [4.4.1]
- fix vertical scrolling issues with Safari (#1168) - fix vertical scrolling issues with Safari (#1168)
- the default value for new users for the "star entry" button and the "open in new tab" button in the entry headers is - the default value for new users for the "star entry" button and the "open in new tab" button in the entry headers is
now "on desktop" instead of "always" now "on desktop" instead of "always"
- the "keyboard shortcuts" help page now shows "Cmd" instead of "Ctrl" on macOS (#1389) - the "keyboard shortcuts" help page now shows "Cmd" instead of "Ctrl" on macOS (#1389)
- remove a superfluous feed fetch when subscribing to a feed (#1431) - remove a superfluous feed fetch when subscribing to a feed (#1431)
- the Docker image now uses Java 21 - the Docker image now uses Java 21
## [4.4.0] ## [4.4.0]
- add support for sharing using the browser native capabilities if available (#1255) - add support for sharing using the browser native capabilities if available (#1255)
- add a button in the entry headers to star an entry (#1025) - add a button in the entry headers to star an entry (#1025)
- add a button in the entry headers to open links in a new tab (#1333) - add a button in the entry headers to open links in a new tab (#1333)
- add two options in the settings to toggle those buttons - add two options in the settings to toggle those buttons
- accept .opml file extension when importing and export with the .opml extension - accept .opml file extension when importing and export with the .opml extension
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read ( - the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read (
older than `keepStatusDays`) (#1303) older than `keepStatusDays`) (#1303)
## [4.3.3] ## [4.3.3]
- fix OPML import (#1279) - fix OPML import (#1279)
## [4.3.2] ## [4.3.2]
- added support for unix sockets (#1278) - added support for unix sockets (#1278)
## [4.3.1] ## [4.3.1]
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database and the database - fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database and the database
timezone is not UTC (#1239) timezone is not UTC (#1239)
- videos in enclosures can no longer have a width larger than the page (#1240) - videos in enclosures can no longer have a width larger than the page (#1240)
## [4.3.0] ## [4.3.0]
- h2 (the embedded database) has been upgraded to 2.2.224 - h2 (the embedded database) has been upgraded to 2.2.224
- this version uses a different file format than 2.1.x, the first time you start CommaFeed with this version, the - this version uses a different file format than 2.1.x, the first time you start CommaFeed with this version, the
database will be automatically converted to the new format database will be automatically converted to the new format
- add a setting to completely disable scrolling to selected entry (#1157) - add a setting to completely disable scrolling to selected entry (#1157)
- add a css class reflecting the current view mode to ease custom css rules (#1232) - add a css class reflecting the current view mode to ease custom css rules (#1232)
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239) - fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239)
## [4.2.1] ## [4.2.1]
- fix an issue that caused the tree to show an incorrect unread count after a websocket notification because entries - fix an issue that caused the tree to show an incorrect unread count after a websocket notification because entries
that were already marked as read by a filtering expression were not ignored (#1191) that were already marked as read by a filtering expression were not ignored (#1191)
## [4.2.0] ## [4.2.0]
- add a setting to display the action buttons in the footer instead of in the header on mobile (#1121) - add a setting to display the action buttons in the footer instead of in the header on mobile (#1121)
- the websocket notification now contains everything needed to update the UI, the client no longer needs to make an API - the websocket notification now contains everything needed to update the UI, the client no longer needs to make an API
call to get the latest data when receiving the notification call to get the latest data when receiving the notification
- add a workaround to the Fever API for the Unread iOS app (#1188) - add a workaround to the Fever API for the Unread iOS app (#1188)
- fix an issue that caused dates to be saved incorrectly if the database server and the application server were in - fix an issue that caused dates to be saved incorrectly if the database server and the application server were in
different timezones (#1187) different timezones (#1187)
## [4.1.0] ## [4.1.0]
- it is now possible to open the sidebar on mobile by swiping to the right (#1098) - it is now possible to open the sidebar on mobile by swiping to the right (#1098)
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar - swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
- the full hierarchy of categories are now displayed in the category dropdown (#1045) - the full hierarchy of categories are now displayed in the category dropdown (#1045)
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. - added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
to 365 days to 365 days
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login - if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
page instead of the welcome page when not logged in (#1185) page instead of the welcome page when not logged in (#1185)
- the sidebar resizer is no longer shown in the middle of the screen on mobile - the sidebar resizer is no longer shown in the middle of the screen on mobile
- when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load - when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load
- the demo account (if enabled) cannot register custom javascript code anymore - the demo account (if enabled) cannot register custom javascript code anymore
- removed the usage of `toSorted` in the client because older browsers do not support it (#1183) - removed the usage of `toSorted` in the client because older browsers do not support it (#1183)
- the openapi documentation is no longer cached by the browser so you always have access to the latest version - the openapi documentation is no longer cached by the browser so you always have access to the latest version
- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server - added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server
with limited memory with limited memory
- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184) - fixed an issue that caused users without an email address set to be unable to edit their profile (#1184)
## [4.0.0] ## [4.0.0]
- migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required - migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required
- entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when - entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when
marking all entries as read marking all entries as read
- your custom sidebar width is now persisted in the local storage of your browser - your custom sidebar width is now persisted in the local storage of your browser
- there is now a third color scheme option in addition to light and dark: system (follows the system color scheme) - there is now a third color scheme option in addition to light and dark: system (follows the system color scheme)
- added support for youtube playlist favicons - added support for youtube playlist favicons
- custom JS code is now executed when the app is done loading instead of when the page is loaded - custom JS code is now executed when the app is done loading instead of when the page is loaded
- the favicon is now correctly returned for feeds that return an invalid content type - the favicon is now correctly returned for feeds that return an invalid content type
- the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each - the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each
request, reducing CPU usage request, reducing CPU usage
- updated UI library Mantine to 7.0, improving performance - updated UI library Mantine to 7.0, improving performance
- the h2 embedded database is now compacted on shutdown to reclaim unused space - the h2 embedded database is now compacted on shutdown to reclaim unused space
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is - the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0) recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
- migrated documentation from swagger 2 to openapi 3 - migrated documentation from swagger 2 to openapi 3
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser - added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be - the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
configured (see config.yml.example) configured (see config.yml.example)
- the websocket connection now works correctly when the context root of the application is not "/" - the websocket connection now works correctly when the context root of the application is not "/"
- unstable pubsubhubbub support was removed - unstable pubsubhubbub support was removed
## [3.10.1] ## [3.10.1]
- swap next and previous buttons (#1159) - swap next and previous buttons (#1159)
- unread count for subscriptions will now be shortened starting at 10k instead of 1k - unread count for subscriptions will now be shortened starting at 10k instead of 1k
- increased websocket ping interval to just under a minute to reduce data and battery usage on mobile - increased websocket ping interval to just under a minute to reduce data and battery usage on mobile
- only refresh subscription tree on a timer if websocket connection is unavailable - only refresh subscription tree on a timer if websocket connection is unavailable
- the Docker image now uses less memory by returning unused memory to the OS - the Docker image now uses less memory by returning unused memory to the OS
- add support for Java 21 - add support for Java 21
## [3.10.0] ## [3.10.0]
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in - added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in
Settings -> Profile) Settings -> Profile)
- long entry titles are no longer shortened in the detailed view - long entry titles are no longer shortened in the detailed view
- added the "s" keyboard shortcut to star/unstar entries - added the "s" keyboard shortcut to star/unstar entries
- http sessions are now stored in the database (they were stored on disk before) - http sessions are now stored in the database (they were stored on disk before)
- fixed an issue that made it impossible to override the database url in a config.yml mounted in the Docker image - fixed an issue that made it impossible to override the database url in a config.yml mounted in the Docker image
## [3.9.0] ## [3.9.0]
- improve performance by disabling the loader when nothing is loading (most noticeable on mobile) - improve performance by disabling the loader when nothing is loading (most noticeable on mobile)
- added a setting to disable the 'mark all as read' confirmation - added a setting to disable the 'mark all as read' confirmation
- added a setting to disable the custom context menu - added a setting to disable the custom context menu
- if the custom context is enabled, it can still be disabled by pressing the shift key - if the custom context is enabled, it can still be disabled by pressing the shift key
- the announcement feature is now working again and supports html ('announcement' configuration element in config.yml) - the announcement feature is now working again and supports html ('announcement' configuration element in config.yml)
- add support for MariaDB 11+ - add support for MariaDB 11+
- fix entry header shortly rendered as mobile on desktop, causing a small visual glitch - fix entry header shortly rendered as mobile on desktop, causing a small visual glitch
- fix an issue that could cause a feed to not refresh correctly if the url was very long - fix an issue that could cause a feed to not refresh correctly if the url was very long
- database cleanup batch size is now configurable - database cleanup batch size is now configurable
- css parsing errors are no longer logged to the standard output - css parsing errors are no longer logged to the standard output
- fix small errors in the api documentation - fix small errors in the api documentation
## [3.8.1] ## [3.8.1]
- in expanded mode, don't scroll when clicking on the body of the current entry - in expanded mode, don't scroll when clicking on the body of the current entry
- improve content cleanup task performance for instances with a very large number of feeds - improve content cleanup task performance for instances with a very large number of feeds
## [3.8.0] ## [3.8.0]
- add previous and next buttons in the toolbar - add previous and next buttons in the toolbar
- add a setting to always scroll selected entry to the top of the page, even if it fits entirely on screen - add a setting to always scroll selected entry to the top of the page, even if it fits entirely on screen
- clicking on the body of an entry in expanded mode selects it and marks it as read - clicking on the body of an entry in expanded mode selects it and marks it as read
- add rich text editor with autocomplete for custom css and js code in settings (desktop only) - add rich text editor with autocomplete for custom css and js code in settings (desktop only)
- dramatically improve performance while scrolling - dramatically improve performance while scrolling
- fix broken welcome page mobile layout - fix broken welcome page mobile layout
- format dates in user locale instead of GMT in relative date popups - format dates in user locale instead of GMT in relative date popups
## [3.7.0] ## [3.7.0]
- the sidebar is now resizable - the sidebar is now resizable
- added the "f" keyboard shortcut to hide the sidebar - added the "f" keyboard shortcut to hide the sidebar
- added tooltips to relative dates with the exact date - added tooltips to relative dates with the exact date
- add a setting to hide commafeed from search engines (exposes a robots.txt file, enabled by default) - add a setting to hide commafeed from search engines (exposes a robots.txt file, enabled by default)
- the browser extension unread count now updates when articles are marked as read/unread in the app - the browser extension unread count now updates when articles are marked as read/unread in the app
- The "b" keyboard shortcut now works as expected on Chrome but requires the browser extension to be installed - The "b" keyboard shortcut now works as expected on Chrome but requires the browser extension to be installed
- dark mode has been disabled on the api documentation page as it was unreadable - dark mode has been disabled on the api documentation page as it was unreadable
- improvement to the feed refresh queuing logic when "heavy load" mode is enabled - improvement to the feed refresh queuing logic when "heavy load" mode is enabled
- fix a bug that could prevent feeds and categories from being edited - fix a bug that could prevent feeds and categories from being edited
## [3.6.0] ## [3.6.0]
- add a button to open CommaFeed in a new tab and a button to open options when using the browser extension - add a button to open CommaFeed in a new tab and a button to open options when using the browser extension
- clicking on the entry title in expanded mode now opens the link instead of doing nothing - clicking on the entry title in expanded mode now opens the link instead of doing nothing
- add tooltips to buttons when the mobile layout is used on desktop - add tooltips to buttons when the mobile layout is used on desktop
- redirect the user to the welcome page if the user was deleted from the database - redirect the user to the welcome page if the user was deleted from the database
- add link to api documentation on welcome page - add link to api documentation on welcome page
- the unread count is now correctly updated when using the "/next" bookmarklet while redis cache is enabled - the unread count is now correctly updated when using the "/next" bookmarklet while redis cache is enabled
## [3.5.0] ## [3.5.0]
- add compatibility with the new version of the CommaFeed browser extension - add compatibility with the new version of the CommaFeed browser extension
- disable pull-to-refresh on mobile as it messes with vertical scrolling - disable pull-to-refresh on mobile as it messes with vertical scrolling
- add css classes to feed entries to help with custom css rules - add css classes to feed entries to help with custom css rules
- api documentation page no longer requires users to be authenticated - api documentation page no longer requires users to be authenticated
- add a setting to limit the number of feeds a user can subscribe to - add a setting to limit the number of feeds a user can subscribe to
- add a setting to disable strict password policy - add a setting to disable strict password policy
- add feed refresh engine metrics - add feed refresh engine metrics
- fix redis timeouts - fix redis timeouts
## [3.4.0] ## [3.4.0]
- add support for arm64 docker images - add support for arm64 docker images
- add divider to visually separate read-only information from form on the profile settings page - add divider to visually separate read-only information from form on the profile settings page
- reduce javascript bundle size by 30% by loading only the necessary translations - reduce javascript bundle size by 30% by loading only the necessary translations
- add a standalone donate page with all ways to support CommaFeed - add a standalone donate page with all ways to support CommaFeed
- fix an issue introduced in 3.1.0 that could make CommaFeed not refresh feeds as fast as before on instances with lots - fix an issue introduced in 3.1.0 that could make CommaFeed not refresh feeds as fast as before on instances with lots
of feeds of feeds
- fix alignment of icon with text for category tree nodes - fix alignment of icon with text for category tree nodes
- fix alignment of burger button with the rest of the header on mobile - fix alignment of burger button with the rest of the header on mobile
## [3.3.2] ## [3.3.2]
- restore entry selection indicator (left orange border) that was lost with the mantine 6.x upgrade (3.3.0) - restore entry selection indicator (left orange border) that was lost with the mantine 6.x upgrade (3.3.0)
- add dividers to visually separate read-only information from forms on feed and category details pages - add dividers to visually separate read-only information from forms on feed and category details pages
- reduced javascript bundle size by 10% - reduced javascript bundle size by 10%
## [3.3.1] ## [3.3.1]
- fix long feed names not being shortened to respect tree max width - fix long feed names not being shortened to respect tree max width
## [3.3.0] ## [3.3.0]
- there are now database changes, rolling back to 2.x will no longer be possible - there are now database changes, rolling back to 2.x will no longer be possible
- restore support for user custom CSS rules - restore support for user custom CSS rules
- add support for user custom JS code that will be executed on page load - add support for user custom JS code that will be executed on page load
## [3.2.0] ## [3.2.0]
- restore the welcome page - restore the welcome page
- only apply hover effect for unread entries (same as commafeed v2) - only apply hover effect for unread entries (same as commafeed v2)
- move notifications at the bottom of the screen - move notifications at the bottom of the screen
- always use https for sharing urls - always use https for sharing urls
- add support for redis ACLs - add support for redis ACLs
- transition to google analytics v4 - transition to google analytics v4
## [3.1.0] ## [3.1.0]
- add an even more compact layout - add an even more compact layout
- restore hover effect from commafeed 2.x - restore hover effect from commafeed 2.x
- view mode (compact, expanded, ...) is now stored on the device so you can have a different view mode on desktop and - view mode (compact, expanded, ...) is now stored on the device so you can have a different view mode on desktop and
mobile mobile
- fix for the "Illegal attempt to associate a collection with two open sessions." error - fix for the "Illegal attempt to associate a collection with two open sessions." error
- feed fetching workflow is now orchestrated with rxjava, removing a lot of code - feed fetching workflow is now orchestrated with rxjava, removing a lot of code
## [3.0.1] ## [3.0.1]
- allow env variable substitution in config.yml - allow env variable substitution in config.yml
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its - e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its
value value
- allow env variable prefixed with `CF_` to override config.yml properties - allow env variable prefixed with `CF_` to override config.yml properties
- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true` - e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true`
## [3.0.0] ## [3.0.0]
- complete overhaul of the UI - complete overhaul of the UI
- backend and frontend are now in separate maven modules - backend and frontend are now in separate maven modules
- no changes to the api or the database - no changes to the api or the database
- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed - Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed
## [2.6.0] ## [2.6.0]
- add support for media content as a backup for missing content (useful for youtube feeds) - add support for media content as a backup for missing content (useful for youtube feeds)
- correctly follow http error code 308 redirects - correctly follow http error code 308 redirects
- fixed a bug that prevented users from deleting their account - fixed a bug that prevented users from deleting their account
- fixed a bug that made commafeed store entry contents multiple times - fixed a bug that made commafeed store entry contents multiple times
- fixed a bug that prevented the app to be used as an installed app on mobile devices if the context path of commafeed - fixed a bug that prevented the app to be used as an installed app on mobile devices if the context path of commafeed
was not "/" was not "/"
- fixed a bug that prevented entries from being "marked as read older than xxx" for a feed that was just added - fixed a bug that prevented entries from being "marked as read older than xxx" for a feed that was just added
- removed support for google+ and readability as those services no longer exist - removed support for google+ and readability as those services no longer exist
- removed support for deploying on openshift - removed support for deploying on openshift
- removed alphabetical sorting of entries because of really poor performance (title cannot be indexed) - removed alphabetical sorting of entries because of really poor performance (title cannot be indexed)
- improve performance for instances with the heavy load setting enabled by preventing CommaFeed from fetching feeds from - improve performance for instances with the heavy load setting enabled by preventing CommaFeed from fetching feeds from
users that did not log in for a long time users that did not log in for a long time
- various dependencies upgrades (notably dropwizard from 1.3 to 2.1) - various dependencies upgrades (notably dropwizard from 1.3 to 2.1)
- add support for mariadb - add support for mariadb
- add support for java17+ runtime - add support for java17+ runtime
- various security improvements - various security improvements
## [2.5.0] ## [2.5.0]
- unread count is now displayed in a favicon badge when supported - unread count is now displayed in a favicon badge when supported
- the user agent string for the bot fetching feeds is now configurable - the user agent string for the bot fetching feeds is now configurable
- feed parsing performance improvements - feed parsing performance improvements
- support for java9+ runtime - support for java9+ runtime
- can now properly start from an empty postgresql database - can now properly start from an empty postgresql database
## [2.4.0] ## [2.4.0]
- users were not able to change password or delete account - users were not able to change password or delete account
- fix api key generation - fix api key generation
- feed entries can now be sorted alphabetically - feed entries can now be sorted alphabetically
- fix facebook sharing - fix facebook sharing
- fix layout on iOS - fix layout on iOS
- postgresql driver update (fix for postgres 9.6) - postgresql driver update (fix for postgres 9.6)
- various internationalization fixes - various internationalization fixes
- security fixes - security fixes
## [2.3.0] ## [2.3.0]
- dropwizard upgrade 0.9.1 - dropwizard upgrade 0.9.1
- feed enclosures are hidden if they already displayed in the content - feed enclosures are hidden if they already displayed in the content
- fix youtube favicons - fix youtube favicons
- various internationalization fixes - various internationalization fixes
## [2.2.0] ## [2.2.0]
- fix youtube and instagram favicon fetching - fix youtube and instagram favicon fetching
- mark as read filter was lost when a feed was rearranged with drag&drop - mark as read filter was lost when a feed was rearranged with drag&drop
- feed entry categories are now displayed if available - feed entry categories are now displayed if available
- various performance and dependencies upgrades - various performance and dependencies upgrades
- java8 is now required - java8 is now required
## [2.1.0] ## [2.1.0]
- dropwizard upgrade to 0.8.0 - dropwizard upgrade to 0.8.0
- you have to remove the "app.contextPath" setting from your yml file, you can optionally use - you have to remove the "app.contextPath" setting from your yml file, you can optionally use
server.applicationContextPath instead server.applicationContextPath instead
- new setting app.maxFeedCapacity for deleting old entries - new setting app.maxFeedCapacity for deleting old entries
- ability to set filtering expressions for subscriptions to automatically mark new entries as read based on title, - ability to set filtering expressions for subscriptions to automatically mark new entries as read based on title,
content, author or url. content, author or url.
- ability to use !keyword or -keyword to exclude a keyword from a search query - ability to use !keyword or -keyword to exclude a keyword from a search query
- facebook feeds now show user favicon instead of facebook favicon - facebook feeds now show user favicon instead of facebook favicon
- new dark theme 'nightsky' - new dark theme 'nightsky'
## [2.0.3] ## [2.0.3]
- internet explorer ajax cache workaround - internet explorer ajax cache workaround
- categories are now deletable again - categories are now deletable again
- openshift support is back - openshift support is back
- youtube feeds now show user favicon instead of youtube favicon - youtube feeds now show user favicon instead of youtube favicon
## [2.0.2] ## [2.0.2]
- api using the api key is now working again - api using the api key is now working again
- context path is now configurable in config.yml (see app.contextPath in config.yml.example) - context path is now configurable in config.yml (see app.contextPath in config.yml.example)
- fix login on firefox when fields are autofilled by the browser - fix login on firefox when fields are autofilled by the browser
- fix scrolling of subscriptions list on mobile - fix scrolling of subscriptions list on mobile
- user is now logged in after registration - user is now logged in after registration
- fix link to documentation on home page and about page - fix link to documentation on home page and about page
- fields autocomplete is disabled on the profile page - fields autocomplete is disabled on the profile page
- users are able to delete their account again - users are able to delete their account again
- chinese and malaysian translation files are now correctly loaded - chinese and malaysian translation files are now correctly loaded
- software version in user-agent when fetching feeds is no longer hardcoded - software version in user-agent when fetching feeds is no longer hardcoded
- admin settings page is now read only, settings are configured in config.yml - admin settings page is now read only, settings are configured in config.yml
- added link to metrics on the admin settings page - added link to metrics on the admin settings page
- Rome (rss library) upgrade to 1.5.0 - Rome (rss library) upgrade to 1.5.0
## [2.0.1] ## [2.0.1]
- the redis pool no longer throws an exception when it is unable to aquire a new connection - the redis pool no longer throws an exception when it is unable to aquire a new connection
## [2.0.0] ## [2.0.0]
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory - The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
consumption and better overall performances. consumption and better overall performances.
See the README on how to build CommaFeed from now on. See the README on how to build CommaFeed from now on.
- CommaFeed should no longer fetch the same feed multiple times in a row - CommaFeed should no longer fetch the same feed multiple times in a row
- Users can use their username or email to log in - Users can use their username or email to log in

60
LICENSE
View File

@@ -1,31 +1,31 @@
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions. 1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. 2. Grant of Copyright License.
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. 3. Grant of Patent License.
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. 4. Redistribution.
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. 5. Submission of Contributions.
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. 6. Trademarks.
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. 7. Disclaimer of Warranty.
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. 8. Limitation of Liability.
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. 9. Accepting Warranty or Additional Liability.
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS

View File

@@ -1,96 +1,96 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>com.commafeed</groupId> <groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId> <artifactId>commafeed</artifactId>
<version>5.6.1</version> <version>5.6.1</version>
</parent> </parent>
<artifactId>commafeed-client</artifactId> <artifactId>commafeed-client</artifactId>
<name>CommaFeed Client</name> <name>CommaFeed Client</name>
<properties> <properties>
<!-- renovate: datasource=node-version depName=node --> <!-- renovate: datasource=node-version depName=node -->
<node.version>v22.14.0</node.version> <node.version>v22.14.0</node.version>
<!-- renovate: datasource=npm depName=npm --> <!-- renovate: datasource=npm depName=npm -->
<npm.version>11.2.0</npm.version> <npm.version>11.2.0</npm.version>
</properties> </properties>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
<groupId>com.github.eirslett</groupId> <groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId> <artifactId>frontend-maven-plugin</artifactId>
<version>1.15.1</version> <version>1.15.1</version>
<?m2e ignore?> <?m2e ignore?>
<executions> <executions>
<execution> <execution>
<id>install node and npm</id> <id>install node and npm</id>
<goals> <goals>
<goal>install-node-and-npm</goal> <goal>install-node-and-npm</goal>
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<nodeVersion>${node.version}</nodeVersion> <nodeVersion>${node.version}</nodeVersion>
<npmVersion>${npm.version}</npmVersion> <npmVersion>${npm.version}</npmVersion>
</configuration> </configuration>
</execution> </execution>
<execution> <execution>
<id>npm install</id> <id>npm install</id>
<goals> <goals>
<goal>npm</goal> <goal>npm</goal>
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<arguments>ci</arguments> <arguments>ci</arguments>
</configuration> </configuration>
</execution> </execution>
<execution> <execution>
<id>npm run test</id> <id>npm run test</id>
<goals> <goals>
<goal>npm</goal> <goal>npm</goal>
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<arguments>run test:ci</arguments> <arguments>run test:ci</arguments>
</configuration> </configuration>
</execution> </execution>
<execution> <execution>
<id>npm run build</id> <id>npm run build</id>
<goals> <goals>
<goal>npm</goal> <goal>npm</goal>
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<arguments>run build</arguments> <arguments>run build</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
<plugin> <plugin>
<artifactId>maven-resources-plugin</artifactId> <artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version> <version>3.3.1</version>
<executions> <executions>
<execution> <execution>
<id>copy web interface to resources</id> <id>copy web interface to resources</id>
<phase>prepare-package</phase> <phase>prepare-package</phase>
<goals> <goals>
<goal>copy-resources</goal> <goal>copy-resources</goal>
</goals> </goals>
<configuration> <configuration>
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory> <outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
<resources> <resources>
<resource> <resource>
<directory>dist</directory> <directory>dist</directory>
<filtering>false</filtering> <filtering>false</filtering>
</resource> </resource>
</resources> </resources>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,401 +1,401 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<profiles version="23"> <profiles version="23">
<profile kind="CodeFormatterProfile" name="CommaFeed" version="23"> <profile kind="CodeFormatterProfile" name="CommaFeed" version="23">
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_ellipsis" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_ellipsis" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_for_statment" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_for_statment" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_block_boundaries" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_block_boundaries" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_package" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_package" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/> <setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_enum_constant_declaration" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_enum_constant_declaration" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.align_with_spaces" value="false"/> <setting id="org.eclipse.jdt.core.formatter.align_with_spaces" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/> <setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_before_code_block" value="0"/> <setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_before_code_block" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_switch_case_expressions" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_switch_case_expressions" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="48"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="48"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.indent_root_tags" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.indent_root_tags" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_or_operator_multicatch" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_or_operator_multicatch" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.enabling_tag" value="@formatter:on"/> <setting id="org.eclipse.jdt.core.formatter.enabling_tag" value="@formatter:on"/>
<setting id="org.eclipse.jdt.core.formatter.comment.count_line_length_from_starting_position" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.count_line_length_from_starting_position" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_record_components" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_record_components" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_multiplicative_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_multiplicative_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameterized_type_references" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_parameterized_type_references" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_record_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_record_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_multiplicative_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_multiplicative_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.preserve_white_space_between_code_and_line_comments" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.preserve_white_space_between_code_and_line_comments" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.keep_enum_constant_declaration_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_enum_constant_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.align_variable_declarations_on_columns" value="false"/> <setting id="org.eclipse.jdt.core.formatter.align_variable_declarations_on_columns" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_union_type_in_multicatch" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_union_type_in_multicatch" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body" value="0"/> <setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_catch_clause" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_catch_clause" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_switch_case_expressions" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_switch_case_expressions" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_shift_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_shift_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_end_of_code_block" value="0"/> <setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_end_of_code_block" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_bitwise_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_bitwise_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line" value="true"/> <setting id="org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_type_parameters" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_type_parameters" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_loops" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_loops" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_switch_case_arrow_operator" value="false"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_switch_case_arrow_operator" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_unary_operator" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_unary_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.format_line_comment_starting_on_first_column" value="true"/> <setting id="org.eclipse.jdt.core.formatter.format_line_comment_starting_on_first_column" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_annotation" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_annotation" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_ellipsis" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_ellipsis" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_try_resources" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_try_resources" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_enum_constant" value="49"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_enum_constant" value="49"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.text_block_indentation" value="0"/> <setting id="org.eclipse.jdt.core.formatter.text_block_indentation" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.align_type_members_on_columns" value="false"/> <setting id="org.eclipse.jdt.core.formatter.align_type_members_on_columns" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_module_statements" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_module_statements" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.align_tags_names_descriptions" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.align_tags_names_descriptions" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.align_assignment_statements_on_columns" value="false"/> <setting id="org.eclipse.jdt.core.formatter.align_assignment_statements_on_columns" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_permitted_types" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_permitted_types" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression_chain" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression_chain" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_header" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.format_header" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_type_annotations" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_type_annotations" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_assertion_message_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_assertion_message_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_method_declaration" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_method_declaration" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.align_fields_grouping_blank_lines" value="2147483647"/> <setting id="org.eclipse.jdt.core.formatter.align_fields_grouping_blank_lines" value="2147483647"/>
<setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_javadoc_boundaries" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_javadoc_boundaries" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="80"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="80"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column" value="false"/> <setting id="org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_source_code" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.format_source_code" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_not_operator" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_not_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_type_annotation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_type_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_html" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.format_html" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_method_delcaration" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_method_delcaration" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_if" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_if" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.indent_empty_lines" value="false"/> <setting id="org.eclipse.jdt.core.formatter.indent_empty_lines" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_type_arguments" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_type_arguments" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_unary_operator" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_unary_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="48"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="48"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_package" value="49"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_package" value="49"/>
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="false"/> <setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_label" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_label" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_arrow_in_switch_case" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_arrow_in_switch_case" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_permitted_types_in_type_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_permitted_types_in_type_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_record_header" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_record_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_bitwise_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_bitwise_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.comment.javadoc_do_not_separate_block_tags" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.javadoc_do_not_separate_block_tags" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_try" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_try" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.indent_tag_description" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.indent_tag_description" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_record_constructor" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_record_constructor" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiple_fields" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_multiple_fields" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_shift_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_shift_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_shift_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_shift_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_record_components" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_record_components" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_outer_expressions_when_nested" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_outer_expressions_when_nested" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_for_loop_header" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_for_loop_header" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_additive_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_additive_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_getter_setter_on_one_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_simple_getter_setter_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_string_concatenation" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_string_concatenation" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.join_lines_in_comments" value="true"/> <setting id="org.eclipse.jdt.core.formatter.join_lines_in_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_record_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_record_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_relational_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_relational_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_import_groups" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_between_import_groups" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_logical_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_logical_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_method_invocation" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_method_invocation" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_record_declaration" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_record_declaration" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_switch_statement" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_switch_statement" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_arrow_in_switch_default" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_arrow_in_switch_default" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.disabling_tag" value="@formatter:off"/> <setting id="org.eclipse.jdt.core.formatter.disabling_tag" value="@formatter:off"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_enum_constants" value="48"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_enum_constants" value="48"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_end_of_method_body" value="0"/> <setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_end_of_method_body" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_if_while_statement" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_if_while_statement" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_arrow_in_switch_case" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_arrow_in_switch_case" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.keep_switch_body_block_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_switch_body_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column" value="false"/> <setting id="org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_block" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_block" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_switch_case_with_arrow" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_switch_case_with_arrow" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.align_tags_descriptions_grouped" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.align_tags_descriptions_grouped" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="140"/> <setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="140"/>
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/> <setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_record_constructor" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_record_constructor" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_relational_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_relational_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_record_declaration_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_record_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.compact_else_if" value="true"/> <setting id="org.eclipse.jdt.core.formatter.compact_else_if" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_try" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_try" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_parameter" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_parameter" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="1"/> <setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_additive_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_additive_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_string_concatenation" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_string_concatenation" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.align_selector_in_method_invocation_on_expression_first_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.align_selector_in_method_invocation_on_expression_first_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_record_declaration" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_record_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_switch_case_with_arrow_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_switch_case_with_arrow_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_switch_case_with_colon" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_switch_case_with_colon" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_after_code_block" value="0"/> <setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_after_code_block" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="80"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_type" value="49"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_type" value="49"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_local_variable" value="49"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_local_variable" value="49"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_arrow_in_switch_default" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_arrow_in_switch_default" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_between_different_tags" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_between_different_tags" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_additive_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_additive_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="true"/> <setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_field" value="49"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_field" value="49"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_conditional_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_conditional_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations" value="false"/> <setting id="org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_try_clause" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_try_clause" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_record_components" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_record_components" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/> <setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_bitwise_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_bitwise_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_try" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_try" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_try_resources" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_try_resources" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/> <setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_record_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_record_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration" value="48"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration" value="48"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_assignment_operator" value="false"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_assignment_operator" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_switch_case_with_arrow" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_switch_case_with_arrow" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_method" value="49"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_method" value="49"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_record_constructor_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_record_constructor_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_record_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_record_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assertion_message" value="0"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_assertion_message" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_logical_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_logical_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_record_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_record_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_relational_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_relational_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/> <setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="16"/> <setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_body" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_body" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="false"/> <setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_logical_operator" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_logical_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_statement_group_in_switch" value="0"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_between_statement_group_in_switch" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_lambda_declaration" value="common_lines"/> <setting id="org.eclipse.jdt.core.formatter.parentheses_positions_in_lambda_declaration" value="common_lines"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_permitted_types" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_permitted_types" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/> <setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/> <setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_multiplicative_operator" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_multiplicative_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/> <setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/> <setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="true"/> <setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_code_block" value="0"/> <setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_code_block" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="tab"/> <setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="tab"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_string_concatenation" value="true"/> <setting id="org.eclipse.jdt.core.formatter.wrap_before_string_concatenation" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="140"/> <setting id="org.eclipse.jdt.core.formatter.lineSplit" value="140"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation" value="do not insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch" value="insert"/> <setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch" value="insert"/>
</profile> </profile>
</profiles> </profiles>

View File

@@ -1,7 +1,7 @@
#Organize Import Order #Organize Import Order
#Wed Jan 29 15:15:04 CET 2025 #Wed Jan 29 15:15:04 CET 2025
0=java 0=java
1=javax 1=javax
2=jakarta 2=jakarta
3=org 3=org
4=com 4=com

View File

@@ -1,3 +1,3 @@
#!/bin/sh #!/bin/sh
mvn exec:java -e -Dexec.classpathScope=test -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="codegen localhost:8082" mvn exec:java -e -Dexec.classpathScope=test -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="codegen localhost:8082"

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 https://maven.apache.org/xsd/assembly-2.2.0.xsd"> xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 https://maven.apache.org/xsd/assembly-2.2.0.xsd">
<id>zip-quarkus-app</id> <id>zip-quarkus-app</id>
<includeBaseDirectory>true</includeBaseDirectory> <includeBaseDirectory>true</includeBaseDirectory>
<baseDirectory>commafeed-${project.version}-${build.database}</baseDirectory> <baseDirectory>commafeed-${project.version}-${build.database}</baseDirectory>
<formats> <formats>
<format>zip</format> <format>zip</format>
</formats> </formats>
<fileSets> <fileSets>
<fileSet> <fileSet>
<directory>${project.build.directory}/quarkus-app</directory> <directory>${project.build.directory}/quarkus-app</directory>
<outputDirectory>/</outputDirectory> <outputDirectory>/</outputDirectory>
<includes> <includes>
<include>**/*</include> <include>**/*</include>
</includes> </includes>
</fileSet> </fileSet>
</fileSets> </fileSets>
</assembly> </assembly>

View File

@@ -1,95 +1,95 @@
# CommaFeed # CommaFeed
Official docker images for https://github.com/Athou/commafeed/ Official docker images for https://github.com/Athou/commafeed/
## Quickstart ## Quickstart
Start CommaFeed with a H2 embedded database. Then login as `admin/admin` on http://localhost:8082/ Start CommaFeed with a H2 embedded database. Then login as `admin/admin` on http://localhost:8082/
### docker ### docker
`docker run --name commafeed --detach --publish 8082:8082 --restart unless-stopped --volume /path/to/commafeed/db:/commafeed/data --memory 256M athou/commafeed:latest-h2` `docker run --name commafeed --detach --publish 8082:8082 --restart unless-stopped --volume /path/to/commafeed/db:/commafeed/data --memory 256M athou/commafeed:latest-h2`
### docker-compose ### docker-compose
``` ```
services: services:
commafeed: commafeed:
image: athou/commafeed:latest-h2 image: athou/commafeed:latest-h2
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /path/to/commafeed/db:/commafeed/data - /path/to/commafeed/db:/commafeed/data
deploy: deploy:
resources: resources:
limits: limits:
memory: 256M memory: 256M
ports: ports:
- 8082:8082 - 8082:8082
``` ```
## Advanced ## Advanced
While using the H2 embedded database is perfectly fine for small instances, you may want to have more control over the While using the H2 embedded database is perfectly fine for small instances, you may want to have more control over the
database. Here's an example that uses PostgreSQL (note the image tag change from `latest-h2` to `latest-postgresql`): database. Here's an example that uses PostgreSQL (note the image tag change from `latest-h2` to `latest-postgresql`):
``` ```
services: services:
commafeed: commafeed:
image: athou/commafeed:latest-postgresql image: athou/commafeed:latest-postgresql
restart: unless-stopped restart: unless-stopped
environment: environment:
- QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgresql:5432/commafeed - QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgresql:5432/commafeed
- QUARKUS_DATASOURCE_USERNAME=commafeed - QUARKUS_DATASOURCE_USERNAME=commafeed
- QUARKUS_DATASOURCE_PASSWORD=commafeed - QUARKUS_DATASOURCE_PASSWORD=commafeed
deploy: deploy:
resources: resources:
limits: limits:
memory: 256M memory: 256M
ports: ports:
- 8082:8082 - 8082:8082
postgresql: postgresql:
image: postgres:latest image: postgres:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: commafeed POSTGRES_USER: commafeed
POSTGRES_PASSWORD: commafeed POSTGRES_PASSWORD: commafeed
POSTGRES_DB: commafeed POSTGRES_DB: commafeed
volumes: volumes:
- /path/to/commafeed/db:/var/lib/postgresql/data - /path/to/commafeed/db:/var/lib/postgresql/data
``` ```
CommaFeed also supports: CommaFeed also supports:
- MySQL: - MySQL:
`QUARKUS_DATASOURCE_JDBC_URL=jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` `QUARKUS_DATASOURCE_JDBC_URL=jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC`
- MariaDB: - MariaDB:
`QUARKUS_DATASOURCE_JDBC_URL=jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` `QUARKUS_DATASOURCE_JDBC_URL=jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC`
## Configuration ## Configuration
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are
optional and have sensible default values. optional and have sensible default values.
Settings are overrideable with environment variables. For instance, `commafeed.feed-refresh.interval-empirical` can be Settings are overrideable with environment variables. For instance, `commafeed.feed-refresh.interval-empirical` can be
set with the `COMMAFEED_FEED_REFRESH_INTERVAL_EMPIRICAL` variable. set with the `COMMAFEED_FEED_REFRESH_INTERVAL_EMPIRICAL` variable.
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup, 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 meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
`QUARKUS_HTTP_AUTH_SESSION_ENCRYPTION_KEY` variable to a fixed value (min. 16 characters). `QUARKUS_HTTP_AUTH_SESSION_ENCRYPTION_KEY` variable to a fixed value (min. 16 characters).
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config). All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
### Updates ### Updates
When CommaFeed is up and running, you can subscribe to [this feed](https://github.com/Athou/commafeed/releases.atom) to be notified of new releases. When CommaFeed is up and running, you can subscribe to [this feed](https://github.com/Athou/commafeed/releases.atom) to be notified of new releases.
## Docker tags ## Docker tags
Tags are of the form `<version>-<database>[-jvm]` where: Tags are of the form `<version>-<database>[-jvm]` where:
- `<version>` is either: - `<version>` is either:
- a specific CommaFeed version (e.g. `5.0.0`) - a specific CommaFeed version (e.g. `5.0.0`)
- `latest` (always points to the latest version) - `latest` (always points to the latest version)
- `master` (always points to the latest git commit) - `master` (always points to the latest git commit)
- `<database>` is the database to use (`h2`, `postgresql`, `mysql` or `mariadb`) - `<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. - `-jvm` is optional and indicates that CommaFeed is running on a JVM, and not compiled natively.

View File

@@ -1,41 +1,41 @@
package com.commafeed; package com.commafeed;
import jakarta.enterprise.event.Observes; import jakarta.enterprise.event.Observes;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.feed.FeedRefreshEngine; import com.commafeed.backend.feed.FeedRefreshEngine;
import com.commafeed.backend.service.db.DatabaseStartupService; import com.commafeed.backend.service.db.DatabaseStartupService;
import com.commafeed.backend.task.TaskScheduler; import com.commafeed.backend.task.TaskScheduler;
import com.commafeed.security.password.PasswordConstraintValidator; import com.commafeed.security.password.PasswordConstraintValidator;
import io.quarkus.runtime.ShutdownEvent; import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent; import io.quarkus.runtime.StartupEvent;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@Singleton @Singleton
@RequiredArgsConstructor @RequiredArgsConstructor
public class CommaFeedApplication { public class CommaFeedApplication {
public static final String USERNAME_ADMIN = "admin"; public static final String USERNAME_ADMIN = "admin";
public static final String USERNAME_DEMO = "demo"; public static final String USERNAME_DEMO = "demo";
private final DatabaseStartupService databaseStartupService; private final DatabaseStartupService databaseStartupService;
private final FeedRefreshEngine feedRefreshEngine; private final FeedRefreshEngine feedRefreshEngine;
private final TaskScheduler taskScheduler; private final TaskScheduler taskScheduler;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
public void start(@Observes StartupEvent ev) { public void start(@Observes StartupEvent ev) {
PasswordConstraintValidator.setStrict(config.users().strictPasswordPolicy()); PasswordConstraintValidator.setStrict(config.users().strictPasswordPolicy());
databaseStartupService.populateInitialData(); databaseStartupService.populateInitialData();
feedRefreshEngine.start(); feedRefreshEngine.start();
taskScheduler.start(); taskScheduler.start();
} }
public void stop(@Observes ShutdownEvent ev) { public void stop(@Observes ShutdownEvent ev) {
feedRefreshEngine.stop(); feedRefreshEngine.stop();
taskScheduler.stop(); taskScheduler.stop();
} }
} }

View File

@@ -1,366 +1,366 @@
package com.commafeed; package com.commafeed;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import com.commafeed.backend.feed.FeedRefreshIntervalCalculator; import com.commafeed.backend.feed.FeedRefreshIntervalCalculator;
import io.quarkus.runtime.annotations.ConfigDocSection; import io.quarkus.runtime.annotations.ConfigDocSection;
import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigPhase;
import io.quarkus.runtime.annotations.ConfigRoot; import io.quarkus.runtime.annotations.ConfigRoot;
import io.quarkus.runtime.configuration.MemorySize; import io.quarkus.runtime.configuration.MemorySize;
import io.smallrye.config.ConfigMapping; import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault; import io.smallrye.config.WithDefault;
/** /**
* CommaFeed configuration * CommaFeed configuration
* *
* Default values are for production, they can be overridden in application.properties for other profiles * Default values are for production, they can be overridden in application.properties for other profiles
*/ */
@ConfigMapping(prefix = "commafeed") @ConfigMapping(prefix = "commafeed")
@ConfigRoot(phase = ConfigPhase.RUN_TIME) @ConfigRoot(phase = ConfigPhase.RUN_TIME)
public interface CommaFeedConfiguration { public interface CommaFeedConfiguration {
/** /**
* Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. * Whether to expose a robots.txt file that disallows web crawlers and search engine indexers.
*/ */
@WithDefault("true") @WithDefault("true")
boolean hideFromWebCrawlers(); boolean hideFromWebCrawlers();
/** /**
* If enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser. * 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. * This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
*/ */
@WithDefault("false") @WithDefault("false")
boolean imageProxyEnabled(); boolean imageProxyEnabled();
/** /**
* Enable password recovery via email. * Enable password recovery via email.
* *
* Quarkus mailer will need to be configured. * Quarkus mailer will need to be configured.
*/ */
@WithDefault("false") @WithDefault("false")
boolean passwordRecoveryEnabled(); boolean passwordRecoveryEnabled();
/** /**
* Message displayed in a notification at the bottom of the page. * Message displayed in a notification at the bottom of the page.
*/ */
Optional<String> announcement(); Optional<String> announcement();
/** /**
* Google Analytics tracking code. * Google Analytics tracking code.
*/ */
Optional<String> googleAnalyticsTrackingCode(); Optional<String> googleAnalyticsTrackingCode();
/** /**
* Google Auth key for fetching Youtube channel favicons. * Google Auth key for fetching Youtube channel favicons.
*/ */
Optional<String> googleAuthKey(); Optional<String> googleAuthKey();
/** /**
* HTTP client configuration * HTTP client configuration
*/ */
@ConfigDocSection @ConfigDocSection
HttpClient httpClient(); HttpClient httpClient();
/** /**
* Feed refresh engine settings. * Feed refresh engine settings.
*/ */
@ConfigDocSection @ConfigDocSection
FeedRefresh feedRefresh(); FeedRefresh feedRefresh();
/** /**
* Database settings. * Database settings.
*/ */
@ConfigDocSection @ConfigDocSection
Database database(); Database database();
/** /**
* Users settings. * Users settings.
*/ */
@ConfigDocSection @ConfigDocSection
Users users(); Users users();
/** /**
* Websocket settings. * Websocket settings.
*/ */
@ConfigDocSection @ConfigDocSection
Websocket websocket(); Websocket websocket();
interface HttpClient { interface HttpClient {
/** /**
* User-Agent string that will be used by the http client, leave empty for the default one. * User-Agent string that will be used by the http client, leave empty for the default one.
*/ */
Optional<String> userAgent(); Optional<String> userAgent();
/** /**
* Time to wait for a connection to be established. * Time to wait for a connection to be established.
*/ */
@WithDefault("5s") @WithDefault("5s")
Duration connectTimeout(); Duration connectTimeout();
/** /**
* Time to wait for SSL handshake to complete. * Time to wait for SSL handshake to complete.
*/ */
@WithDefault("5s") @WithDefault("5s")
Duration sslHandshakeTimeout(); Duration sslHandshakeTimeout();
/** /**
* Time to wait between two packets before timeout. * Time to wait between two packets before timeout.
*/ */
@WithDefault("10s") @WithDefault("10s")
Duration socketTimeout(); Duration socketTimeout();
/** /**
* Time to wait for the full response to be received. * Time to wait for the full response to be received.
*/ */
@WithDefault("10s") @WithDefault("10s")
Duration responseTimeout(); Duration responseTimeout();
/** /**
* Time to live for a connection in the pool. * Time to live for a connection in the pool.
*/ */
@WithDefault("30s") @WithDefault("30s")
Duration connectionTimeToLive(); Duration connectionTimeToLive();
/** /**
* Time between eviction runs for idle connections. * Time between eviction runs for idle connections.
*/ */
@WithDefault("1m") @WithDefault("1m")
Duration idleConnectionsEvictionInterval(); Duration idleConnectionsEvictionInterval();
/** /**
* If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. * If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed.
*/ */
@WithDefault("5M") @WithDefault("5M")
MemorySize maxResponseSize(); MemorySize maxResponseSize();
/** /**
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal * Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal
* resources. * 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 * 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. * your CommaFeed instance.
*/ */
@WithDefault("true") @WithDefault("true")
boolean blockLocalAddresses(); boolean blockLocalAddresses();
/** /**
* HTTP client cache configuration * HTTP client cache configuration
*/ */
@ConfigDocSection @ConfigDocSection
HttpClientCache cache(); HttpClientCache cache();
} }
interface HttpClientCache { interface HttpClientCache {
/** /**
* 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 * 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"). * first time or when clicking "fetch all my feeds now").
*/ */
@WithDefault("true") @WithDefault("true")
boolean enabled(); boolean enabled();
/** /**
* Maximum amount of memory the cache can use. * Maximum amount of memory the cache can use.
*/ */
@WithDefault("10M") @WithDefault("10M")
MemorySize maximumMemorySize(); MemorySize maximumMemorySize();
/** /**
* Duration after which an entry is removed from the cache. * Duration after which an entry is removed from the cache.
*/ */
@WithDefault("1m") @WithDefault("1m")
Duration expiration(); Duration expiration();
} }
interface FeedRefresh { interface FeedRefresh {
/** /**
* Default amount of time CommaFeed will wait before refreshing a feed. * Default amount of time CommaFeed will wait before refreshing a feed.
*/ */
@WithDefault("5m") @WithDefault("5m")
Duration interval(); Duration interval();
/** /**
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when: * Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
* *
* <ul> * <ul>
* <li>an error occurs while refreshing a feed and we're backing off exponentially</li> * <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 Cache-Control header from the feed</li>
* <li>we receive a Retry-After header from the feed</li> * <li>we receive a Retry-After header from the feed</li>
* </ul> * </ul>
*/ */
@WithDefault("4h") @WithDefault("4h")
Duration maxInterval(); Duration maxInterval();
/** /**
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since * 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 * 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`). * (`commafeed.feed-refresh.interval`) and the maximum refresh interval (`commafeed.feed-refresh.max-interval`).
* *
* See {@link FeedRefreshIntervalCalculator} for details. * See {@link FeedRefreshIntervalCalculator} for details.
*/ */
@WithDefault("true") @WithDefault("true")
boolean intervalEmpirical(); boolean intervalEmpirical();
/** /**
* Feed refresh engine error handling settings. * Feed refresh engine error handling settings.
*/ */
@ConfigDocSection @ConfigDocSection
FeedRefreshErrorHandling errors(); FeedRefreshErrorHandling errors();
/** /**
* Amount of http threads used to fetch feeds. * Amount of http threads used to fetch feeds.
*/ */
@Min(1) @Min(1)
@WithDefault("3") @WithDefault("3")
int httpThreads(); int httpThreads();
/** /**
* Amount of threads used to insert new entries in the database. * Amount of threads used to insert new entries in the database.
*/ */
@Min(1) @Min(1)
@WithDefault("1") @WithDefault("1")
int databaseThreads(); int databaseThreads();
/** /**
* Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log in again. * Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log in again.
* *
* 0 to disable. * 0 to disable.
*/ */
@WithDefault("0") @WithDefault("0")
Duration userInactivityPeriod(); Duration userInactivityPeriod();
/** /**
* Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have timed out. * Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have timed out.
*/ */
@WithDefault("500ms") @WithDefault("500ms")
Duration filteringExpressionEvaluationTimeout(); Duration filteringExpressionEvaluationTimeout();
/** /**
* Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds. * Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds.
*/ */
@WithDefault("0") @WithDefault("0")
Duration forceRefreshCooldownDuration(); Duration forceRefreshCooldownDuration();
} }
interface FeedRefreshErrorHandling { interface FeedRefreshErrorHandling {
/** /**
* Number of retries before backoff is applied. * Number of retries before backoff is applied.
*/ */
@Min(0) @Min(0)
@WithDefault("3") @WithDefault("3")
int retriesBeforeBackoff(); int retriesBeforeBackoff();
/** /**
* Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last successful fetch. * Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last successful fetch.
*/ */
@WithDefault("1h") @WithDefault("1h")
Duration backoffInterval(); Duration backoffInterval();
} }
interface Database { interface Database {
/** /**
* Timeout applied to all database queries. * Timeout applied to all database queries.
* *
* 0 to disable. * 0 to disable.
*/ */
@WithDefault("0") @WithDefault("0")
Duration queryTimeout(); Duration queryTimeout();
/** /**
* Database cleanup settings. * Database cleanup settings.
*/ */
@ConfigDocSection @ConfigDocSection
Cleanup cleanup(); Cleanup cleanup();
interface Cleanup { interface Cleanup {
/** /**
* Maximum age of feed entries in the database. Older entries will be deleted. * Maximum age of feed entries in the database. Older entries will be deleted.
* *
* 0 to disable. * 0 to disable.
*/ */
@WithDefault("365d") @WithDefault("365d")
Duration entriesMaxAge(); Duration entriesMaxAge();
/** /**
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted. * Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted.
* *
* 0 to disable. * 0 to disable.
*/ */
@WithDefault("0") @WithDefault("0")
Duration statusesMaxAge(); Duration statusesMaxAge();
/** /**
* Maximum number of entries per feed to keep in the database. * Maximum number of entries per feed to keep in the database.
* *
* 0 to disable. * 0 to disable.
*/ */
@WithDefault("500") @WithDefault("500")
int maxFeedCapacity(); int maxFeedCapacity();
/** /**
* Limit the number of feeds a user can subscribe to. * Limit the number of feeds a user can subscribe to.
* *
* 0 to disable. * 0 to disable.
*/ */
@WithDefault("0") @WithDefault("0")
int maxFeedsPerUser(); int maxFeedsPerUser();
/** /**
* Rows to delete per query while cleaning up old entries. * Rows to delete per query while cleaning up old entries.
*/ */
@Positive @Positive
@WithDefault("100") @WithDefault("100")
int batchSize(); int batchSize();
default Instant statusesInstantThreshold() { default Instant statusesInstantThreshold() {
return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null; return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null;
} }
} }
} }
interface Users { interface Users {
/** /**
* Whether to let users create accounts for themselves. * Whether to let users create accounts for themselves.
*/ */
@WithDefault("false") @WithDefault("false")
boolean allowRegistrations(); boolean allowRegistrations();
/** /**
* Whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char). * Whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char).
*/ */
@WithDefault("true") @WithDefault("true")
boolean strictPasswordPolicy(); boolean strictPasswordPolicy();
/** /**
* Whether to create a demo account the first time the app starts. * Whether to create a demo account the first time the app starts.
*/ */
@WithDefault("false") @WithDefault("false")
boolean createDemoAccount(); boolean createDemoAccount();
} }
interface Websocket { interface Websocket {
/** /**
* Enable websocket connection so the server can notify web clients that there are new entries for feeds. * Enable websocket connection so the server can notify web clients that there are new entries for feeds.
*/ */
@WithDefault("true") @WithDefault("true")
boolean enabled(); boolean enabled();
/** /**
* Interval at which the client will send a ping message on the websocket to keep the connection alive. * Interval at which the client will send a ping message on the websocket to keep the connection alive.
*/ */
@WithDefault("15m") @WithDefault("15m")
Duration pingInterval(); Duration pingInterval();
/** /**
* If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at this interval. * If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at this interval.
*/ */
@WithDefault("30s") @WithDefault("30s")
Duration treeReloadInterval(); Duration treeReloadInterval();
} }
} }

View File

@@ -1,25 +1,25 @@
package com.commafeed; package com.commafeed;
import java.time.InstantSource; import java.time.InstantSource;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
@Singleton @Singleton
public class CommaFeedProducers { public class CommaFeedProducers {
@Produces @Produces
@Singleton @Singleton
public InstantSource instantSource() { public InstantSource instantSource() {
return InstantSource.system(); return InstantSource.system();
} }
@Produces @Produces
@Singleton @Singleton
public MetricRegistry metricRegistry() { public MetricRegistry metricRegistry() {
return new MetricRegistry(); return new MetricRegistry();
} }
} }

View File

@@ -1,32 +1,32 @@
package com.commafeed; package com.commafeed;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Properties; import java.util.Properties;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import lombok.Getter; import lombok.Getter;
@Singleton @Singleton
@Getter @Getter
public class CommaFeedVersion { public class CommaFeedVersion {
private final String version; private final String version;
private final String gitCommit; private final String gitCommit;
public CommaFeedVersion() { public CommaFeedVersion() {
Properties properties = new Properties(); Properties properties = new Properties();
try (InputStream stream = getClass().getResourceAsStream("/git.properties")) { try (InputStream stream = getClass().getResourceAsStream("/git.properties")) {
if (stream != null) { if (stream != null) {
properties.load(stream); properties.load(stream);
} }
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
this.version = properties.getProperty("git.build.version", "unknown"); this.version = properties.getProperty("git.build.version", "unknown");
this.gitCommit = properties.getProperty("git.commit.id.abbrev", "unknown"); this.gitCommit = properties.getProperty("git.commit.id.abbrev", "unknown");
} }
} }

View File

@@ -1,50 +1,50 @@
package com.commafeed; package com.commafeed;
import jakarta.annotation.Priority; import jakarta.annotation.Priority;
import jakarta.validation.ValidationException; import jakarta.validation.ValidationException;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.RestResponse.Status; import org.jboss.resteasy.reactive.RestResponse.Status;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.UnauthorizedException; import io.quarkus.security.UnauthorizedException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Provider @Provider
@Priority(1) @Priority(1)
public class ExceptionMappers { public class ExceptionMappers {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
@ServerExceptionMapper(UnauthorizedException.class) @ServerExceptionMapper(UnauthorizedException.class)
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) { public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
return RestResponse.status(RestResponse.Status.UNAUTHORIZED, return RestResponse.status(RestResponse.Status.UNAUTHORIZED,
new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations())); new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
} }
@ServerExceptionMapper(AuthenticationFailedException.class) @ServerExceptionMapper(AuthenticationFailedException.class)
public RestResponse<AuthenticationFailed> authenticationFailed(AuthenticationFailedException e) { public RestResponse<AuthenticationFailed> authenticationFailed(AuthenticationFailedException e) {
return RestResponse.status(RestResponse.Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage())); return RestResponse.status(RestResponse.Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()));
} }
@ServerExceptionMapper(ValidationException.class) @ServerExceptionMapper(ValidationException.class)
public RestResponse<ValidationFailed> validationFailed(ValidationException e) { public RestResponse<ValidationFailed> validationFailed(ValidationException e) {
return RestResponse.status(Status.BAD_REQUEST, new ValidationFailed(e.getMessage())); return RestResponse.status(Status.BAD_REQUEST, new ValidationFailed(e.getMessage()));
} }
@RegisterForReflection @RegisterForReflection
public record UnauthorizedResponse(String message, boolean allowRegistrations) { public record UnauthorizedResponse(String message, boolean allowRegistrations) {
} }
@RegisterForReflection @RegisterForReflection
public record AuthenticationFailed(String message) { public record AuthenticationFailed(String message) {
} }
@RegisterForReflection @RegisterForReflection
public record ValidationFailed(String message) { public record ValidationFailed(String message) {
} }
} }

View File

@@ -1,29 +1,29 @@
package com.commafeed; package com.commafeed;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.codahale.metrics.json.MetricsModule; import com.codahale.metrics.json.MetricsModule;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.quarkus.jackson.ObjectMapperCustomizer; import io.quarkus.jackson.ObjectMapperCustomizer;
@Singleton @Singleton
public class JacksonCustomizer implements ObjectMapperCustomizer { public class JacksonCustomizer implements ObjectMapperCustomizer {
@Override @Override
public void customize(ObjectMapper objectMapper) { public void customize(ObjectMapper objectMapper) {
objectMapper.registerModule(new JavaTimeModule()); objectMapper.registerModule(new JavaTimeModule());
// read and write instants as milliseconds instead of nanoseconds // read and write instants as milliseconds instead of nanoseconds
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true) objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
.configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false) .configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); .configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);
// add support for serializing metrics // add support for serializing metrics
objectMapper.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.SECONDS, false)); objectMapper.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.SECONDS, false));
} }
} }

View File

@@ -1,226 +1,226 @@
package com.commafeed; package com.commafeed;
import com.codahale.metrics.Counter; import com.codahale.metrics.Counter;
import com.codahale.metrics.Gauge; import com.codahale.metrics.Gauge;
import com.codahale.metrics.Histogram; import com.codahale.metrics.Histogram;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer; import com.codahale.metrics.Timer;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection( @RegisterForReflection(
targets = { targets = {
// metrics // metrics
MetricRegistry.class, Meter.class, Gauge.class, Counter.class, Timer.class, Histogram.class, MetricRegistry.class, Meter.class, Gauge.class, Counter.class, Timer.class, Histogram.class,
// rome // rome
java.util.Date.class, com.rometools.opml.feed.synd.impl.TreeCategoryImpl.class, java.util.Date.class, com.rometools.opml.feed.synd.impl.TreeCategoryImpl.class,
com.rometools.rome.feed.synd.SyndFeedImpl.class, com.rometools.rome.feed.module.DCSubjectImpl.class, com.rometools.rome.feed.synd.SyndFeedImpl.class, com.rometools.rome.feed.module.DCSubjectImpl.class,
com.rometools.rome.feed.synd.SyndEntryImpl.class, com.rometools.modules.psc.types.SimpleChapter.class, com.rometools.rome.feed.synd.SyndEntryImpl.class, com.rometools.modules.psc.types.SimpleChapter.class,
com.rometools.rome.feed.synd.SyndCategoryImpl.class, com.rometools.rome.feed.synd.SyndImageImpl.class, com.rometools.rome.feed.synd.SyndCategoryImpl.class, com.rometools.rome.feed.synd.SyndImageImpl.class,
com.rometools.rome.feed.synd.SyndContentImpl.class, com.rometools.rome.feed.synd.SyndEnclosureImpl.class, com.rometools.rome.feed.synd.SyndContentImpl.class, com.rometools.rome.feed.synd.SyndEnclosureImpl.class,
// rome cloneable // rome cloneable
com.rometools.modules.activitystreams.types.Article.class, com.rometools.modules.activitystreams.types.Audio.class, com.rometools.modules.activitystreams.types.Article.class, com.rometools.modules.activitystreams.types.Audio.class,
com.rometools.modules.activitystreams.types.Bookmark.class, com.rometools.modules.activitystreams.types.Comment.class, com.rometools.modules.activitystreams.types.Bookmark.class, com.rometools.modules.activitystreams.types.Comment.class,
com.rometools.modules.activitystreams.types.Event.class, com.rometools.modules.activitystreams.types.File.class, com.rometools.modules.activitystreams.types.Event.class, com.rometools.modules.activitystreams.types.File.class,
com.rometools.modules.activitystreams.types.Folder.class, com.rometools.modules.activitystreams.types.List.class, com.rometools.modules.activitystreams.types.Folder.class, com.rometools.modules.activitystreams.types.List.class,
com.rometools.modules.activitystreams.types.Note.class, com.rometools.modules.activitystreams.types.Person.class, com.rometools.modules.activitystreams.types.Note.class, com.rometools.modules.activitystreams.types.Person.class,
com.rometools.modules.activitystreams.types.Photo.class, com.rometools.modules.activitystreams.types.PhotoAlbum.class, com.rometools.modules.activitystreams.types.Photo.class, com.rometools.modules.activitystreams.types.PhotoAlbum.class,
com.rometools.modules.activitystreams.types.Place.class, com.rometools.modules.activitystreams.types.Playlist.class, com.rometools.modules.activitystreams.types.Place.class, com.rometools.modules.activitystreams.types.Playlist.class,
com.rometools.modules.activitystreams.types.Product.class, com.rometools.modules.activitystreams.types.Review.class, com.rometools.modules.activitystreams.types.Product.class, com.rometools.modules.activitystreams.types.Review.class,
com.rometools.modules.activitystreams.types.Service.class, com.rometools.modules.activitystreams.types.Song.class, com.rometools.modules.activitystreams.types.Service.class, com.rometools.modules.activitystreams.types.Song.class,
com.rometools.modules.activitystreams.types.Status.class, com.rometools.modules.base.types.DateTimeRange.class, com.rometools.modules.activitystreams.types.Status.class, com.rometools.modules.base.types.DateTimeRange.class,
com.rometools.modules.base.types.FloatUnit.class, com.rometools.modules.base.types.GenderEnumeration.class, com.rometools.modules.base.types.FloatUnit.class, com.rometools.modules.base.types.GenderEnumeration.class,
com.rometools.modules.base.types.IntUnit.class, com.rometools.modules.base.types.PriceTypeEnumeration.class, com.rometools.modules.base.types.IntUnit.class, com.rometools.modules.base.types.PriceTypeEnumeration.class,
com.rometools.modules.base.types.ShippingType.class, com.rometools.modules.base.types.ShortDate.class, com.rometools.modules.base.types.ShippingType.class, com.rometools.modules.base.types.ShortDate.class,
com.rometools.modules.base.types.Size.class, com.rometools.modules.base.types.YearType.class, com.rometools.modules.base.types.Size.class, com.rometools.modules.base.types.YearType.class,
com.rometools.modules.content.ContentItem.class, com.rometools.modules.georss.GeoRSSPoint.class, com.rometools.modules.content.ContentItem.class, com.rometools.modules.georss.GeoRSSPoint.class,
com.rometools.modules.georss.geometries.Envelope.class, com.rometools.modules.georss.geometries.LineString.class, com.rometools.modules.georss.geometries.Envelope.class, com.rometools.modules.georss.geometries.LineString.class,
com.rometools.modules.georss.geometries.LinearRing.class, com.rometools.modules.georss.geometries.Point.class, com.rometools.modules.georss.geometries.LinearRing.class, com.rometools.modules.georss.geometries.Point.class,
com.rometools.modules.georss.geometries.Polygon.class, com.rometools.modules.georss.geometries.Position.class, com.rometools.modules.georss.geometries.Polygon.class, com.rometools.modules.georss.geometries.Position.class,
com.rometools.modules.georss.geometries.PositionList.class, com.rometools.modules.mediarss.types.MediaGroup.class, com.rometools.modules.georss.geometries.PositionList.class, com.rometools.modules.mediarss.types.MediaGroup.class,
com.rometools.modules.mediarss.types.Metadata.class, com.rometools.modules.mediarss.types.Thumbnail.class, com.rometools.modules.mediarss.types.Metadata.class, com.rometools.modules.mediarss.types.Thumbnail.class,
com.rometools.modules.opensearch.entity.OSQuery.class, com.rometools.modules.photocast.types.PhotoDate.class, com.rometools.modules.opensearch.entity.OSQuery.class, com.rometools.modules.photocast.types.PhotoDate.class,
com.rometools.modules.sle.types.DateValue.class, com.rometools.modules.sle.types.Group.class, com.rometools.modules.sle.types.DateValue.class, com.rometools.modules.sle.types.Group.class,
com.rometools.modules.sle.types.NumberValue.class, com.rometools.modules.sle.types.Sort.class, com.rometools.modules.sle.types.NumberValue.class, com.rometools.modules.sle.types.Sort.class,
com.rometools.modules.sle.types.StringValue.class, com.rometools.modules.yahooweather.types.Astronomy.class, com.rometools.modules.sle.types.StringValue.class, com.rometools.modules.yahooweather.types.Astronomy.class,
com.rometools.modules.yahooweather.types.Atmosphere.class, com.rometools.modules.yahooweather.types.Condition.class, com.rometools.modules.yahooweather.types.Atmosphere.class, com.rometools.modules.yahooweather.types.Condition.class,
com.rometools.modules.yahooweather.types.Forecast.class, com.rometools.modules.yahooweather.types.Location.class, com.rometools.modules.yahooweather.types.Forecast.class, com.rometools.modules.yahooweather.types.Location.class,
com.rometools.modules.yahooweather.types.Units.class, com.rometools.modules.yahooweather.types.Wind.class, com.rometools.modules.yahooweather.types.Units.class, com.rometools.modules.yahooweather.types.Wind.class,
com.rometools.opml.feed.opml.Attribute.class, com.rometools.opml.feed.opml.Opml.class, com.rometools.opml.feed.opml.Attribute.class, com.rometools.opml.feed.opml.Opml.class,
com.rometools.opml.feed.opml.Outline.class, com.rometools.rome.feed.atom.Category.class, com.rometools.opml.feed.opml.Outline.class, com.rometools.rome.feed.atom.Category.class,
com.rometools.rome.feed.atom.Content.class, com.rometools.rome.feed.atom.Entry.class, com.rometools.rome.feed.atom.Content.class, com.rometools.rome.feed.atom.Entry.class,
com.rometools.rome.feed.atom.Feed.class, com.rometools.rome.feed.atom.Generator.class, com.rometools.rome.feed.atom.Feed.class, com.rometools.rome.feed.atom.Generator.class,
com.rometools.rome.feed.atom.Link.class, com.rometools.rome.feed.atom.Person.class, com.rometools.rome.feed.atom.Link.class, com.rometools.rome.feed.atom.Person.class,
com.rometools.rome.feed.rss.Category.class, com.rometools.rome.feed.rss.Channel.class, com.rometools.rome.feed.rss.Category.class, com.rometools.rome.feed.rss.Channel.class,
com.rometools.rome.feed.rss.Cloud.class, com.rometools.rome.feed.rss.Content.class, com.rometools.rome.feed.rss.Cloud.class, com.rometools.rome.feed.rss.Content.class,
com.rometools.rome.feed.rss.Description.class, com.rometools.rome.feed.rss.Enclosure.class, com.rometools.rome.feed.rss.Description.class, com.rometools.rome.feed.rss.Enclosure.class,
com.rometools.rome.feed.rss.Guid.class, com.rometools.rome.feed.rss.Image.class, com.rometools.rome.feed.rss.Item.class, com.rometools.rome.feed.rss.Guid.class, com.rometools.rome.feed.rss.Image.class, com.rometools.rome.feed.rss.Item.class,
com.rometools.rome.feed.rss.Source.class, com.rometools.rome.feed.rss.TextInput.class, com.rometools.rome.feed.rss.Source.class, com.rometools.rome.feed.rss.TextInput.class,
com.rometools.rome.feed.synd.SyndLinkImpl.class, com.rometools.rome.feed.synd.SyndPersonImpl.class, com.rometools.rome.feed.synd.SyndLinkImpl.class, com.rometools.rome.feed.synd.SyndPersonImpl.class,
java.util.ArrayList.class, java.util.ArrayList.class,
// rome modules // rome modules
com.rometools.modules.sse.modules.Conflict.class, com.rometools.modules.sse.modules.Conflicts.class, com.rometools.modules.sse.modules.Conflict.class, com.rometools.modules.sse.modules.Conflicts.class,
com.rometools.modules.cc.CreativeCommonsImpl.class, com.rometools.modules.feedpress.modules.FeedpressModuleImpl.class, com.rometools.modules.cc.CreativeCommonsImpl.class, com.rometools.modules.feedpress.modules.FeedpressModuleImpl.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleImpl.class, com.rometools.modules.sse.modules.Sharing.class, com.rometools.modules.opensearch.impl.OpenSearchModuleImpl.class, com.rometools.modules.sse.modules.Sharing.class,
com.rometools.modules.georss.SimpleModuleImpl.class, com.rometools.modules.atom.modules.AtomLinkModuleImpl.class, com.rometools.modules.georss.SimpleModuleImpl.class, com.rometools.modules.atom.modules.AtomLinkModuleImpl.class,
com.rometools.modules.itunes.EntryInformationImpl.class, com.rometools.modules.sse.modules.Update.class, com.rometools.modules.itunes.EntryInformationImpl.class, com.rometools.modules.sse.modules.Update.class,
com.rometools.modules.photocast.PhotocastModuleImpl.class, com.rometools.modules.itunes.FeedInformationImpl.class, com.rometools.modules.photocast.PhotocastModuleImpl.class, com.rometools.modules.itunes.FeedInformationImpl.class,
com.rometools.modules.yahooweather.YWeatherModuleImpl.class, com.rometools.modules.feedburner.FeedBurnerImpl.class, com.rometools.modules.yahooweather.YWeatherModuleImpl.class, com.rometools.modules.feedburner.FeedBurnerImpl.class,
com.rometools.modules.sse.modules.Related.class, com.rometools.modules.fyyd.modules.FyydModuleImpl.class, com.rometools.modules.sse.modules.Related.class, com.rometools.modules.fyyd.modules.FyydModuleImpl.class,
com.rometools.modules.psc.modules.PodloveSimpleChapterModuleImpl.class, com.rometools.modules.thr.ThreadingModuleImpl.class, com.rometools.modules.psc.modules.PodloveSimpleChapterModuleImpl.class, com.rometools.modules.thr.ThreadingModuleImpl.class,
com.rometools.modules.sse.modules.Sync.class, com.rometools.modules.sle.SimpleListExtensionImpl.class, com.rometools.modules.sse.modules.Sync.class, com.rometools.modules.sle.SimpleListExtensionImpl.class,
com.rometools.modules.slash.SlashImpl.class, com.rometools.modules.sse.modules.History.class, com.rometools.modules.slash.SlashImpl.class, com.rometools.modules.sse.modules.History.class,
com.rometools.modules.georss.GMLModuleImpl.class, com.rometools.modules.base.CustomTagsImpl.class, com.rometools.modules.georss.GMLModuleImpl.class, com.rometools.modules.base.CustomTagsImpl.class,
com.rometools.modules.base.GoogleBaseImpl.class, com.rometools.modules.sle.SleEntryImpl.class, com.rometools.modules.base.GoogleBaseImpl.class, com.rometools.modules.sle.SleEntryImpl.class,
com.rometools.modules.mediarss.MediaEntryModuleImpl.class, com.rometools.modules.content.ContentModuleImpl.class, com.rometools.modules.mediarss.MediaEntryModuleImpl.class, com.rometools.modules.content.ContentModuleImpl.class,
com.rometools.modules.georss.W3CGeoModuleImpl.class, com.rometools.rome.feed.module.DCModuleImpl.class, com.rometools.modules.georss.W3CGeoModuleImpl.class, com.rometools.rome.feed.module.DCModuleImpl.class,
com.rometools.modules.mediarss.MediaModuleImpl.class, com.rometools.rome.feed.module.SyModuleImpl.class, com.rometools.modules.mediarss.MediaModuleImpl.class, com.rometools.rome.feed.module.SyModuleImpl.class,
// extracted from all 3 rome.properties files of rome library // extracted from all 3 rome.properties files of rome library
com.rometools.rome.io.impl.RSS090Parser.class, com.rometools.rome.io.impl.RSS091NetscapeParser.class, com.rometools.rome.io.impl.RSS090Parser.class, com.rometools.rome.io.impl.RSS091NetscapeParser.class,
com.rometools.rome.io.impl.RSS091UserlandParser.class, com.rometools.rome.io.impl.RSS092Parser.class, com.rometools.rome.io.impl.RSS091UserlandParser.class, com.rometools.rome.io.impl.RSS092Parser.class,
com.rometools.rome.io.impl.RSS093Parser.class, com.rometools.rome.io.impl.RSS094Parser.class, com.rometools.rome.io.impl.RSS093Parser.class, com.rometools.rome.io.impl.RSS094Parser.class,
com.rometools.rome.io.impl.RSS10Parser.class, com.rometools.rome.io.impl.RSS20wNSParser.class, com.rometools.rome.io.impl.RSS10Parser.class, com.rometools.rome.io.impl.RSS20wNSParser.class,
com.rometools.rome.io.impl.RSS20Parser.class, com.rometools.rome.io.impl.Atom10Parser.class, com.rometools.rome.io.impl.RSS20Parser.class, com.rometools.rome.io.impl.Atom10Parser.class,
com.rometools.rome.io.impl.Atom03Parser.class, com.rometools.rome.io.impl.Atom03Parser.class,
com.rometools.rome.io.impl.SyModuleParser.class, com.rometools.rome.io.impl.DCModuleParser.class, com.rometools.rome.io.impl.SyModuleParser.class, com.rometools.rome.io.impl.DCModuleParser.class,
com.rometools.rome.io.impl.RSS090Generator.class, com.rometools.rome.io.impl.RSS091NetscapeGenerator.class, com.rometools.rome.io.impl.RSS090Generator.class, com.rometools.rome.io.impl.RSS091NetscapeGenerator.class,
com.rometools.rome.io.impl.RSS091UserlandGenerator.class, com.rometools.rome.io.impl.RSS092Generator.class, com.rometools.rome.io.impl.RSS091UserlandGenerator.class, com.rometools.rome.io.impl.RSS092Generator.class,
com.rometools.rome.io.impl.RSS093Generator.class, com.rometools.rome.io.impl.RSS094Generator.class, com.rometools.rome.io.impl.RSS093Generator.class, com.rometools.rome.io.impl.RSS094Generator.class,
com.rometools.rome.io.impl.RSS10Generator.class, com.rometools.rome.io.impl.RSS20Generator.class, com.rometools.rome.io.impl.RSS10Generator.class, com.rometools.rome.io.impl.RSS20Generator.class,
com.rometools.rome.io.impl.Atom10Generator.class, com.rometools.rome.io.impl.Atom03Generator.class, com.rometools.rome.io.impl.Atom10Generator.class, com.rometools.rome.io.impl.Atom03Generator.class,
com.rometools.rome.feed.synd.impl.ConverterForAtom10.class, com.rometools.rome.feed.synd.impl.ConverterForAtom03.class, com.rometools.rome.feed.synd.impl.ConverterForAtom10.class, com.rometools.rome.feed.synd.impl.ConverterForAtom03.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS090.class, com.rometools.rome.feed.synd.impl.ConverterForRSS090.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS091Netscape.class, com.rometools.rome.feed.synd.impl.ConverterForRSS091Netscape.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS091Userland.class, com.rometools.rome.feed.synd.impl.ConverterForRSS091Userland.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS092.class, com.rometools.rome.feed.synd.impl.ConverterForRSS093.class, com.rometools.rome.feed.synd.impl.ConverterForRSS092.class, com.rometools.rome.feed.synd.impl.ConverterForRSS093.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS094.class, com.rometools.rome.feed.synd.impl.ConverterForRSS10.class, com.rometools.rome.feed.synd.impl.ConverterForRSS094.class, com.rometools.rome.feed.synd.impl.ConverterForRSS10.class,
com.rometools.rome.feed.synd.impl.ConverterForRSS20.class, com.rometools.rome.feed.synd.impl.ConverterForRSS20.class,
com.rometools.modules.mediarss.io.RSS20YahooParser.class, com.rometools.modules.mediarss.io.RSS20YahooParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.content.io.ContentModuleParser.class, com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.class,
com.rometools.modules.itunes.io.ITunesParserOldNamespace.class, com.rometools.modules.itunes.io.ITunesParserOldNamespace.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ModuleParser.class, com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ModuleParser.class,
com.rometools.modules.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.class, com.rometools.modules.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.class,
com.rometools.modules.fyyd.io.FyydParser.class, com.rometools.modules.fyyd.io.FyydParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.content.io.ContentModuleParser.class, com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.class,
com.rometools.modules.itunes.io.ITunesParserOldNamespace.class, com.rometools.modules.itunes.io.ITunesParserOldNamespace.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ModuleParser.class, com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ModuleParser.class,
com.rometools.modules.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.class, com.rometools.modules.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.class,
com.rometools.modules.fyyd.io.FyydParser.class, com.rometools.modules.fyyd.io.FyydParser.class,
com.rometools.modules.cc.io.ModuleParserRSS1.class, com.rometools.modules.content.io.ContentModuleParser.class, com.rometools.modules.cc.io.ModuleParserRSS1.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class,
com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class,
com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class,
com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class,
com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.feedpress.io.FeedpressParser.class, com.rometools.modules.fyyd.io.FyydParser.class, com.rometools.modules.feedpress.io.FeedpressParser.class, com.rometools.modules.fyyd.io.FyydParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class, com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.content.io.ContentModuleParser.class, com.rometools.modules.slash.io.SlashModuleParser.class, com.rometools.modules.content.io.ContentModuleParser.class, com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.itunes.io.ITunesParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.atom.io.AtomModuleParser.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class,
com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.georss.W3CGeoParser.class,
com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.itunes.io.ITunesParserOldNamespace.class, com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.itunes.io.ITunesParserOldNamespace.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ItemParser.class, com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.sle.io.ItemParser.class,
com.rometools.modules.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.yahooweather.io.WeatherModuleParser.class,
com.rometools.modules.psc.io.PodloveSimpleChapterParser.class, com.rometools.modules.psc.io.PodloveSimpleChapterParser.class,
com.rometools.modules.cc.io.ModuleParserRSS1.class, com.rometools.modules.base.io.GoogleBaseParser.class, com.rometools.modules.cc.io.ModuleParserRSS1.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.content.io.ContentModuleParser.class, com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.content.io.ContentModuleParser.class,
com.rometools.modules.slash.io.SlashModuleParser.class, com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class, com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.slash.io.SlashModuleParser.class, com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class, com.rometools.modules.cc.io.ModuleParserRSS2.class, com.rometools.modules.base.io.GoogleBaseParser.class,
com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.slash.io.SlashModuleParser.class, com.rometools.modules.base.io.CustomTagParser.class, com.rometools.modules.slash.io.SlashModuleParser.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class, com.rometools.modules.opensearch.impl.OpenSearchModuleParser.class, com.rometools.modules.georss.SimpleParser.class,
com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class, com.rometools.modules.georss.W3CGeoParser.class, com.rometools.modules.photocast.io.Parser.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class, com.rometools.modules.mediarss.io.AlternateMediaModuleParser.class,
com.rometools.modules.thr.io.ThreadingModuleParser.class, com.rometools.modules.psc.io.PodloveSimpleChapterParser.class, com.rometools.modules.thr.io.ThreadingModuleParser.class, com.rometools.modules.psc.io.PodloveSimpleChapterParser.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class, com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.itunes.io.ITunesGenerator.class, com.rometools.modules.itunes.io.ITunesGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.atom.io.AtomModuleGenerator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.atom.io.AtomModuleGenerator.class,
com.rometools.modules.sle.io.ModuleGenerator.class, com.rometools.modules.yahooweather.io.WeatherModuleGenerator.class, com.rometools.modules.sle.io.ModuleGenerator.class, com.rometools.modules.yahooweather.io.WeatherModuleGenerator.class,
com.rometools.modules.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.class, com.rometools.modules.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.class,
com.rometools.modules.content.io.ContentModuleGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class,
com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.georss.W3CGeoGenerator.class,
com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class,
com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.georss.W3CGeoGenerator.class,
com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.modules.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.class, com.rometools.modules.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.GoogleBaseGenerator.class, com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.GoogleBaseGenerator.class,
com.rometools.modules.base.io.CustomTagGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class, com.rometools.modules.base.io.CustomTagGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.slash.io.SlashModuleGenerator.class, com.rometools.modules.itunes.io.ITunesGenerator.class, com.rometools.modules.slash.io.SlashModuleGenerator.class, com.rometools.modules.itunes.io.ITunesGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.atom.io.AtomModuleGenerator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.atom.io.AtomModuleGenerator.class,
com.rometools.modules.yahooweather.io.WeatherModuleGenerator.class, com.rometools.modules.yahooweather.io.WeatherModuleGenerator.class,
com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class, com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class,
com.rometools.modules.base.io.GoogleBaseGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class, com.rometools.modules.base.io.GoogleBaseGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class,
com.rometools.modules.slash.io.SlashModuleGenerator.class, com.rometools.modules.slash.io.SlashModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.GoogleBaseGenerator.class, com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.GoogleBaseGenerator.class,
com.rometools.modules.base.io.CustomTagGenerator.class, com.rometools.modules.slash.io.SlashModuleGenerator.class, com.rometools.modules.base.io.CustomTagGenerator.class, com.rometools.modules.slash.io.SlashModuleGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.CustomTagGenerator.class, com.rometools.modules.cc.io.CCModuleGenerator.class, com.rometools.modules.base.io.CustomTagGenerator.class,
com.rometools.modules.slash.io.SlashModuleGenerator.class, com.rometools.modules.slash.io.SlashModuleGenerator.class,
com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class, com.rometools.modules.opensearch.impl.OpenSearchModuleGenerator.class, com.rometools.modules.georss.SimpleGenerator.class,
com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class, com.rometools.modules.georss.W3CGeoGenerator.class, com.rometools.modules.photocast.io.Generator.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.thr.io.ThreadingModuleGenerator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.thr.io.ThreadingModuleGenerator.class,
com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class, com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class,
com.rometools.modules.mediarss.io.MediaModuleParser.class, com.rometools.modules.mediarss.io.MediaModuleParser.class,
com.rometools.modules.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.mediarss.io.MediaModuleGenerator.class,
com.rometools.opml.io.impl.OPML10Generator.class, com.rometools.opml.io.impl.OPML20Generator.class, com.rometools.opml.io.impl.OPML10Generator.class, com.rometools.opml.io.impl.OPML20Generator.class,
com.rometools.opml.io.impl.OPML10Parser.class, com.rometools.opml.io.impl.OPML20Parser.class, com.rometools.opml.io.impl.OPML10Parser.class, com.rometools.opml.io.impl.OPML20Parser.class,
com.rometools.opml.feed.synd.impl.ConverterForOPML10.class, com.rometools.opml.feed.synd.impl.ConverterForOPML20.class, }) com.rometools.opml.feed.synd.impl.ConverterForOPML10.class, com.rometools.opml.feed.synd.impl.ConverterForOPML20.class, })
public class NativeImageClasses { public class NativeImageClasses {
} }

View File

@@ -1,29 +1,29 @@
package com.commafeed.backend; package com.commafeed.backend;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import com.google.common.hash.HashFunction; import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing; import com.google.common.hash.Hashing;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
@UtilityClass @UtilityClass
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public class Digests { public class Digests {
public static String sha1Hex(byte[] input) { public static String sha1Hex(byte[] input) {
return hashBytesToHex(Hashing.sha1(), input); return hashBytesToHex(Hashing.sha1(), input);
} }
public static String sha1Hex(String input) { public static String sha1Hex(String input) {
return hashBytesToHex(Hashing.sha1(), input.getBytes(StandardCharsets.UTF_8)); return hashBytesToHex(Hashing.sha1(), input.getBytes(StandardCharsets.UTF_8));
} }
public static String md5Hex(String input) { public static String md5Hex(String input) {
return hashBytesToHex(Hashing.md5(), input.getBytes(StandardCharsets.UTF_8)); return hashBytesToHex(Hashing.md5(), input.getBytes(StandardCharsets.UTF_8));
} }
private static String hashBytesToHex(HashFunction function, byte[] input) { private static String hashBytesToHex(HashFunction function, byte[] input) {
return function.hashBytes(input).toString(); return function.hashBytes(input).toString();
} }
} }

View File

@@ -1,428 +1,428 @@
package com.commafeed.backend; package com.commafeed.backend;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.URI; import java.net.URI;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.InstantSource; import java.time.InstantSource;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.stream.Stream; import java.util.stream.Stream;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.ws.rs.core.CacheControl; import jakarta.ws.rs.core.CacheControl;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.SystemDefaultDnsResolver;
import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.config.TlsConfig; import org.apache.hc.client5.http.config.TlsConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.protocol.RedirectLocations; import org.apache.hc.client5.http.protocol.RedirectLocations;
import org.apache.hc.client5.http.utils.DateUtils; import org.apache.hc.client5.http.utils.DateUtils;
import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout; import org.apache.hc.core5.util.Timeout;
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate; import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConfiguration.HttpClientCache; import com.commafeed.CommaFeedConfiguration.HttpClientCache;
import com.commafeed.CommaFeedVersion; import com.commafeed.CommaFeedVersion;
import com.google.common.cache.Cache; import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.io.ByteStreams; import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders; import com.google.common.net.HttpHeaders;
import lombok.Builder; import lombok.Builder;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.Value; import lombok.Value;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import nl.altindag.ssl.SSLFactory; import nl.altindag.ssl.SSLFactory;
import nl.altindag.ssl.apache5.util.Apache5SslUtils; import nl.altindag.ssl.apache5.util.Apache5SslUtils;
/** /**
* Smart HTTP getter: handles gzip, ssl, last modified and etag headers * Smart HTTP getter: handles gzip, ssl, last modified and etag headers
*/ */
@Singleton @Singleton
@Slf4j @Slf4j
public class HttpGetter { public class HttpGetter {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final InstantSource instantSource; private final InstantSource instantSource;
private final CloseableHttpClient client; private final CloseableHttpClient client;
private final Cache<HttpRequest, HttpResponse> cache; private final Cache<HttpRequest, HttpResponse> cache;
private final DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE; private final DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE;
public HttpGetter(CommaFeedConfiguration config, InstantSource instantSource, CommaFeedVersion version, MetricRegistry metrics) { public HttpGetter(CommaFeedConfiguration config, InstantSource instantSource, CommaFeedVersion version, MetricRegistry metrics) {
this.config = config; this.config = config;
this.instantSource = instantSource; this.instantSource = instantSource;
PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config); PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config);
String userAgent = config.httpClient() String userAgent = config.httpClient()
.userAgent() .userAgent()
.orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion())); .orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
this.client = newClient(connectionManager, userAgent, config.httpClient().idleConnectionsEvictionInterval()); this.client = newClient(connectionManager, userAgent, config.httpClient().idleConnectionsEvictionInterval());
this.cache = newCache(config); this.cache = newCache(config);
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "max"), () -> connectionManager.getTotalStats().getMax()); metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "max"), () -> connectionManager.getTotalStats().getMax());
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "size"), metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "size"),
() -> connectionManager.getTotalStats().getAvailable() + connectionManager.getTotalStats().getLeased()); () -> connectionManager.getTotalStats().getAvailable() + connectionManager.getTotalStats().getLeased());
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "leased"), () -> connectionManager.getTotalStats().getLeased()); metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "leased"), () -> connectionManager.getTotalStats().getLeased());
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "pending"), () -> connectionManager.getTotalStats().getPending()); metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "pending"), () -> connectionManager.getTotalStats().getPending());
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size()); metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "memoryUsage"), metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "memoryUsage"),
() -> cache == null ? 0 : cache.asMap().values().stream().mapToInt(e -> e.content != null ? e.content.length : 0).sum()); () -> cache == null ? 0 : cache.asMap().values().stream().mapToInt(e -> e.content != null ? e.content.length : 0).sum());
} }
public HttpResult get(String url) public HttpResult get(String url)
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException {
return get(HttpRequest.builder(url).build()); return get(HttpRequest.builder(url).build());
} }
public HttpResult get(HttpRequest request) public HttpResult get(HttpRequest request)
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException {
URI uri = URI.create(request.getUrl()); URI uri = URI.create(request.getUrl());
ensureHttpScheme(uri.getScheme()); ensureHttpScheme(uri.getScheme());
if (config.httpClient().blockLocalAddresses()) { if (config.httpClient().blockLocalAddresses()) {
ensurePublicAddress(uri.getHost()); ensurePublicAddress(uri.getHost());
} }
final HttpResponse response; final HttpResponse response;
if (cache == null) { if (cache == null) {
response = invoke(request); response = invoke(request);
} else { } else {
try { try {
response = cache.get(request, () -> invoke(request)); response = cache.get(request, () -> invoke(request));
} catch (ExecutionException e) { } catch (ExecutionException e) {
if (e.getCause() instanceof IOException ioe) { if (e.getCause() instanceof IOException ioe) {
throw ioe; throw ioe;
} else { } else {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
} }
int code = response.getCode(); int code = response.getCode();
if (code == HttpStatus.SC_TOO_MANY_REQUESTS || code == HttpStatus.SC_SERVICE_UNAVAILABLE && response.getRetryAfter() != null) { if (code == HttpStatus.SC_TOO_MANY_REQUESTS || code == HttpStatus.SC_SERVICE_UNAVAILABLE && response.getRetryAfter() != null) {
throw new TooManyRequestsException(response.getRetryAfter()); throw new TooManyRequestsException(response.getRetryAfter());
} }
if (code == HttpStatus.SC_NOT_MODIFIED) { if (code == HttpStatus.SC_NOT_MODIFIED) {
throw new NotModifiedException("'304 - not modified' http code received"); throw new NotModifiedException("'304 - not modified' http code received");
} }
if (code >= 300) { if (code >= 300) {
throw new HttpResponseException(code, "Server returned HTTP error code " + code); throw new HttpResponseException(code, "Server returned HTTP error code " + code);
} }
String lastModifiedHeader = response.getLastModifiedHeader(); String lastModifiedHeader = response.getLastModifiedHeader();
if (lastModifiedHeader != null && lastModifiedHeader.equals(request.getLastModified())) { if (lastModifiedHeader != null && lastModifiedHeader.equals(request.getLastModified())) {
throw new NotModifiedException("lastModifiedHeader is the same"); throw new NotModifiedException("lastModifiedHeader is the same");
} }
String eTagHeader = response.getETagHeader(); String eTagHeader = response.getETagHeader();
if (eTagHeader != null && eTagHeader.equals(request.getETag())) { if (eTagHeader != null && eTagHeader.equals(request.getETag())) {
throw new NotModifiedException("eTagHeader is the same"); throw new NotModifiedException("eTagHeader is the same");
} }
Duration validFor = Optional.ofNullable(response.getCacheControl()) Duration validFor = Optional.ofNullable(response.getCacheControl())
.filter(cc -> cc.getMaxAge() >= 0) .filter(cc -> cc.getMaxAge() >= 0)
.map(cc -> Duration.ofSeconds(cc.getMaxAge())) .map(cc -> Duration.ofSeconds(cc.getMaxAge()))
.orElse(Duration.ZERO); .orElse(Duration.ZERO);
return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader, return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader,
response.getUrlAfterRedirect(), validFor); response.getUrlAfterRedirect(), validFor);
} }
private void ensureHttpScheme(String scheme) throws SchemeNotAllowedException { private void ensureHttpScheme(String scheme) throws SchemeNotAllowedException {
if (!"http".equals(scheme) && !"https".equals(scheme)) { if (!"http".equals(scheme) && !"https".equals(scheme)) {
throw new SchemeNotAllowedException(scheme); throw new SchemeNotAllowedException(scheme);
} }
} }
private void ensurePublicAddress(String host) throws HostNotAllowedException, UnknownHostException { private void ensurePublicAddress(String host) throws HostNotAllowedException, UnknownHostException {
if (host == null) { if (host == null) {
throw new HostNotAllowedException(null); throw new HostNotAllowedException(null);
} }
InetAddress[] addresses = dnsResolver.resolve(host); InetAddress[] addresses = dnsResolver.resolve(host);
if (Stream.of(addresses).anyMatch(this::isPrivateAddress)) { if (Stream.of(addresses).anyMatch(this::isPrivateAddress)) {
throw new HostNotAllowedException(host); throw new HostNotAllowedException(host);
} }
} }
private boolean isPrivateAddress(InetAddress address) { private boolean isPrivateAddress(InetAddress address) {
return address.isSiteLocalAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isLoopbackAddress() return address.isSiteLocalAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isLoopbackAddress()
|| address.isMulticastAddress(); || address.isMulticastAddress();
} }
private HttpResponse invoke(HttpRequest request) throws IOException { private HttpResponse invoke(HttpRequest request) throws IOException {
log.debug("fetching {}", request.getUrl()); log.debug("fetching {}", request.getUrl());
HttpClientContext context = HttpClientContext.create(); HttpClientContext context = HttpClientContext.create();
context.setRequestConfig(RequestConfig.custom() context.setRequestConfig(RequestConfig.custom()
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout())) .setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
// causes issues with some feeds // causes issues with some feeds
// see https://github.com/Athou/commafeed/issues/1572 // see https://github.com/Athou/commafeed/issues/1572
// and https://issues.apache.org/jira/browse/HTTPCLIENT-2344 // and https://issues.apache.org/jira/browse/HTTPCLIENT-2344
.setProtocolUpgradeEnabled(false) .setProtocolUpgradeEnabled(false)
.build()); .build());
return client.execute(request.toClassicHttpRequest(), context, resp -> { return client.execute(request.toClassicHttpRequest(), context, resp -> {
byte[] content = resp.getEntity() == null ? null byte[] content = resp.getEntity() == null ? null
: toByteArray(resp.getEntity(), config.httpClient().maxResponseSize().asLongValue()); : toByteArray(resp.getEntity(), config.httpClient().maxResponseSize().asLongValue());
int code = resp.getCode(); int code = resp.getCode();
String lastModifiedHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED)) String lastModifiedHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
.map(NameValuePair::getValue) .map(NameValuePair::getValue)
.map(StringUtils::trimToNull) .map(StringUtils::trimToNull)
.orElse(null); .orElse(null);
String eTagHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG)) String eTagHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG))
.map(NameValuePair::getValue) .map(NameValuePair::getValue)
.map(StringUtils::trimToNull) .map(StringUtils::trimToNull)
.orElse(null); .orElse(null);
CacheControl cacheControl = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL)) CacheControl cacheControl = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL))
.map(NameValuePair::getValue) .map(NameValuePair::getValue)
.map(StringUtils::trimToNull) .map(StringUtils::trimToNull)
.map(HttpGetter::toCacheControl) .map(HttpGetter::toCacheControl)
.orElse(null); .orElse(null);
Instant retryAfter = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER)) Instant retryAfter = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER))
.map(NameValuePair::getValue) .map(NameValuePair::getValue)
.map(StringUtils::trimToNull) .map(StringUtils::trimToNull)
.map(this::toInstant) .map(this::toInstant)
.orElse(null); .orElse(null);
String contentType = Optional.ofNullable(resp.getEntity()).map(HttpEntity::getContentType).orElse(null); String contentType = Optional.ofNullable(resp.getEntity()).map(HttpEntity::getContentType).orElse(null);
String urlAfterRedirect = Optional.ofNullable(context.getRedirectLocations()) String urlAfterRedirect = Optional.ofNullable(context.getRedirectLocations())
.map(RedirectLocations::getAll) .map(RedirectLocations::getAll)
.map(l -> Iterables.getLast(l, null)) .map(l -> Iterables.getLast(l, null))
.map(URI::toString) .map(URI::toString)
.orElse(request.getUrl()); .orElse(request.getUrl());
return new HttpResponse(code, lastModifiedHeader, eTagHeader, cacheControl, retryAfter, content, contentType, urlAfterRedirect); return new HttpResponse(code, lastModifiedHeader, eTagHeader, cacheControl, retryAfter, content, contentType, urlAfterRedirect);
}); });
} }
private static CacheControl toCacheControl(String headerValue) { private static CacheControl toCacheControl(String headerValue) {
try { try {
return CacheControlDelegate.INSTANCE.fromString(headerValue); return CacheControlDelegate.INSTANCE.fromString(headerValue);
} catch (Exception e) { } catch (Exception e) {
log.debug("Invalid Cache-Control header: {}", headerValue); log.debug("Invalid Cache-Control header: {}", headerValue);
return null; return null;
} }
} }
private Instant toInstant(String headerValue) { private Instant toInstant(String headerValue) {
if (headerValue == null) { if (headerValue == null) {
return null; return null;
} }
if (StringUtils.isNumeric(headerValue)) { if (StringUtils.isNumeric(headerValue)) {
return instantSource.instant().plusSeconds(Long.parseLong(headerValue)); return instantSource.instant().plusSeconds(Long.parseLong(headerValue));
} }
return DateUtils.parseStandardDate(headerValue); return DateUtils.parseStandardDate(headerValue);
} }
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException { private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
if (entity.getContentLength() > maxBytes) { if (entity.getContentLength() > maxBytes) {
throw new IOException( throw new IOException(
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)".formatted(entity.getContentLength(), maxBytes)); "Response size (%s bytes) exceeds the maximum allowed size (%s bytes)".formatted(entity.getContentLength(), maxBytes));
} }
try (InputStream input = entity.getContent()) { try (InputStream input = entity.getContent()) {
if (input == null) { if (input == null) {
return null; return null;
} }
byte[] bytes = ByteStreams.limit(input, maxBytes).readAllBytes(); byte[] bytes = ByteStreams.limit(input, maxBytes).readAllBytes();
if (bytes.length == maxBytes) { if (bytes.length == maxBytes) {
throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes)); throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes));
} }
return bytes; return bytes;
} }
} }
private PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) { private PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) {
SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build(); SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
int poolSize = config.feedRefresh().httpThreads(); int poolSize = config.feedRefresh().httpThreads();
return PoolingHttpClientConnectionManagerBuilder.create() return PoolingHttpClientConnectionManagerBuilder.create()
.setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory)) .setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory))
.setDefaultConnectionConfig(ConnectionConfig.custom() .setDefaultConnectionConfig(ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout())) .setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout())) .setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
.setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive())) .setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive()))
.build()) .build())
.setDefaultTlsConfig(TlsConfig.custom().setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout())).build()) .setDefaultTlsConfig(TlsConfig.custom().setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout())).build())
.setMaxConnPerRoute(poolSize) .setMaxConnPerRoute(poolSize)
.setMaxConnTotal(poolSize) .setMaxConnTotal(poolSize)
.setDnsResolver(dnsResolver) .setDnsResolver(dnsResolver)
.build(); .build();
} }
private static CloseableHttpClient newClient(HttpClientConnectionManager connectionManager, String userAgent, private static CloseableHttpClient newClient(HttpClientConnectionManager connectionManager, String userAgent,
Duration idleConnectionsEvictionInterval) { Duration idleConnectionsEvictionInterval) {
List<Header> headers = new ArrayList<>(); List<Header> headers = new ArrayList<>();
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en")); headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache")); headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache")); headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache"));
return HttpClientBuilder.create() return HttpClientBuilder.create()
.useSystemProperties() .useSystemProperties()
.disableAutomaticRetries() .disableAutomaticRetries()
.disableCookieManagement() .disableCookieManagement()
.setUserAgent(userAgent) .setUserAgent(userAgent)
.setDefaultHeaders(headers) .setDefaultHeaders(headers)
.setConnectionManager(connectionManager) .setConnectionManager(connectionManager)
.evictExpiredConnections() .evictExpiredConnections()
.evictIdleConnections(TimeValue.of(idleConnectionsEvictionInterval)) .evictIdleConnections(TimeValue.of(idleConnectionsEvictionInterval))
.build(); .build();
} }
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) { private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
HttpClientCache cacheConfig = config.httpClient().cache(); HttpClientCache cacheConfig = config.httpClient().cache();
if (!cacheConfig.enabled()) { if (!cacheConfig.enabled()) {
return null; return null;
} }
return CacheBuilder.newBuilder() return CacheBuilder.newBuilder()
.weigher((HttpRequest key, HttpResponse value) -> value.getContent() != null ? value.getContent().length : 0) .weigher((HttpRequest key, HttpResponse value) -> value.getContent() != null ? value.getContent().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue()) .maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration()) .expireAfterWrite(cacheConfig.expiration())
.build(); .build();
} }
public static class SchemeNotAllowedException extends Exception { public static class SchemeNotAllowedException extends Exception {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
public SchemeNotAllowedException(String scheme) { public SchemeNotAllowedException(String scheme) {
super("Scheme not allowed: " + scheme); super("Scheme not allowed: " + scheme);
} }
} }
public static class HostNotAllowedException extends Exception { public static class HostNotAllowedException extends Exception {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
public HostNotAllowedException(String host) { public HostNotAllowedException(String host) {
super("Host not allowed: " + host); super("Host not allowed: " + host);
} }
} }
@Getter @Getter
public static class NotModifiedException extends Exception { public static class NotModifiedException extends Exception {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* if the value of this header changed, this is its new value * if the value of this header changed, this is its new value
*/ */
private final String newLastModifiedHeader; private final String newLastModifiedHeader;
/** /**
* if the value of this header changed, this is its new value * if the value of this header changed, this is its new value
*/ */
private final String newEtagHeader; private final String newEtagHeader;
public NotModifiedException(String message) { public NotModifiedException(String message) {
this(message, null, null); this(message, null, null);
} }
public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) { public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) {
super(message); super(message);
this.newLastModifiedHeader = newLastModifiedHeader; this.newLastModifiedHeader = newLastModifiedHeader;
this.newEtagHeader = newEtagHeader; this.newEtagHeader = newEtagHeader;
} }
} }
@RequiredArgsConstructor @RequiredArgsConstructor
@Getter @Getter
public static class TooManyRequestsException extends Exception { public static class TooManyRequestsException extends Exception {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private final Instant retryAfter; private final Instant retryAfter;
} }
@Getter @Getter
public static class HttpResponseException extends IOException { public static class HttpResponseException extends IOException {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private final int code; private final int code;
public HttpResponseException(int code, String message) { public HttpResponseException(int code, String message) {
super(message); super(message);
this.code = code; this.code = code;
} }
} }
@Builder(builderMethodName = "") @Builder(builderMethodName = "")
@EqualsAndHashCode @EqualsAndHashCode
@Getter @Getter
public static class HttpRequest { public static class HttpRequest {
private String url; private String url;
private String lastModified; private String lastModified;
private String eTag; private String eTag;
public static HttpRequestBuilder builder(String url) { public static HttpRequestBuilder builder(String url) {
return new HttpRequestBuilder().url(url); return new HttpRequestBuilder().url(url);
} }
public ClassicHttpRequest toClassicHttpRequest() { public ClassicHttpRequest toClassicHttpRequest() {
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build(); ClassicHttpRequest req = ClassicRequestBuilder.get(url).build();
if (lastModified != null) { if (lastModified != null) {
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified); req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
} }
if (eTag != null) { if (eTag != null) {
req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag); req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
} }
return req; return req;
} }
} }
@Value @Value
private static class HttpResponse { private static class HttpResponse {
int code; int code;
String lastModifiedHeader; String lastModifiedHeader;
String eTagHeader; String eTagHeader;
CacheControl cacheControl; CacheControl cacheControl;
Instant retryAfter; Instant retryAfter;
byte[] content; byte[] content;
String contentType; String contentType;
String urlAfterRedirect; String urlAfterRedirect;
} }
@Value @Value
public static class HttpResult { public static class HttpResult {
byte[] content; byte[] content;
String contentType; String contentType;
String lastModifiedSince; String lastModifiedSince;
String eTag; String eTag;
String urlAfterRedirect; String urlAfterRedirect;
Duration validFor; Duration validFor;
} }
} }

View File

@@ -1,71 +1,71 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.QFeedCategory; import com.commafeed.backend.model.QFeedCategory;
import com.commafeed.backend.model.QUser; import com.commafeed.backend.model.QUser;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.querydsl.core.types.Predicate; import com.querydsl.core.types.Predicate;
@Singleton @Singleton
public class FeedCategoryDAO extends GenericDAO<FeedCategory> { public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory; private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
public FeedCategoryDAO(EntityManager entityManager) { public FeedCategoryDAO(EntityManager entityManager) {
super(entityManager, FeedCategory.class); super(entityManager, FeedCategory.class);
} }
public List<FeedCategory> findAll(User user) { public List<FeedCategory> findAll(User user) {
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user)).join(CATEGORY.user, QUser.user).fetchJoin().fetch(); return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user)).join(CATEGORY.user, QUser.user).fetchJoin().fetch();
} }
public FeedCategory findById(User user, Long id) { public FeedCategory findById(User user, Long id) {
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne(); return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne();
} }
public FeedCategory findByName(User user, String name, FeedCategory parent) { public FeedCategory findByName(User user, String name, FeedCategory parent) {
Predicate parentPredicate; Predicate parentPredicate;
if (parent == null) { if (parent == null) {
parentPredicate = CATEGORY.parent.isNull(); parentPredicate = CATEGORY.parent.isNull();
} else { } else {
parentPredicate = CATEGORY.parent.eq(parent); parentPredicate = CATEGORY.parent.eq(parent);
} }
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.name.eq(name), parentPredicate).fetchOne(); return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.name.eq(name), parentPredicate).fetchOne();
} }
public List<FeedCategory> findByParent(User user, FeedCategory parent) { public List<FeedCategory> findByParent(User user, FeedCategory parent) {
Predicate parentPredicate; Predicate parentPredicate;
if (parent == null) { if (parent == null) {
parentPredicate = CATEGORY.parent.isNull(); parentPredicate = CATEGORY.parent.isNull();
} else { } else {
parentPredicate = CATEGORY.parent.eq(parent); parentPredicate = CATEGORY.parent.eq(parent);
} }
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), parentPredicate).fetch(); return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), parentPredicate).fetch();
} }
public List<FeedCategory> findAllChildrenCategories(User user, FeedCategory parent) { public List<FeedCategory> findAllChildrenCategories(User user, FeedCategory parent) {
return findAll(user).stream().filter(c -> isChild(c, parent)).toList(); return findAll(user).stream().filter(c -> isChild(c, parent)).toList();
} }
private boolean isChild(FeedCategory child, FeedCategory parent) { private boolean isChild(FeedCategory child, FeedCategory parent) {
if (parent == null) { if (parent == null) {
return true; return true;
} }
boolean isChild = false; boolean isChild = false;
while (child != null) { while (child != null) {
if (Objects.equals(child.getId(), parent.getId())) { if (Objects.equals(child.getId(), parent.getId())) {
isChild = true; isChild = true;
break; break;
} }
child = child.getParent(); child = child.getParent();
} }
return isChild; return isChild;
} }
} }

View File

@@ -1,64 +1,64 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.QFeed; import com.commafeed.backend.model.QFeed;
import com.commafeed.backend.model.QFeedSubscription; import com.commafeed.backend.model.QFeedSubscription;
import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQuery;
@Singleton @Singleton
public class FeedDAO extends GenericDAO<Feed> { public class FeedDAO extends GenericDAO<Feed> {
private static final QFeed FEED = QFeed.feed; private static final QFeed FEED = QFeed.feed;
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
public FeedDAO(EntityManager entityManager) { public FeedDAO(EntityManager entityManager) {
super(entityManager, Feed.class); super(entityManager, Feed.class);
} }
public List<Feed> findByIds(List<Long> id) { public List<Feed> findByIds(List<Long> id) {
return query().selectFrom(FEED).where(FEED.id.in(id)).fetch(); return query().selectFrom(FEED).where(FEED.id.in(id)).fetch();
} }
public List<Feed> findNextUpdatable(int count, Instant lastLoginThreshold) { public List<Feed> findNextUpdatable(int count, Instant lastLoginThreshold) {
JPAQuery<Feed> query = query().selectFrom(FEED) JPAQuery<Feed> query = query().selectFrom(FEED)
.distinct() .distinct()
// join on subscriptions to only refresh feeds that have subscribers // join on subscriptions to only refresh feeds that have subscribers
.join(SUBSCRIPTION) .join(SUBSCRIPTION)
.on(SUBSCRIPTION.feed.eq(FEED)) .on(SUBSCRIPTION.feed.eq(FEED))
.where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now()))); .where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
if (lastLoginThreshold != null) { if (lastLoginThreshold != null) {
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold)); query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
} }
return query.orderBy(FEED.disabledUntil.asc()).limit(count).fetch(); return query.orderBy(FEED.disabledUntil.asc()).limit(count).fetch();
} }
public void setDisabledUntil(List<Long> feedIds, Instant date) { public void setDisabledUntil(List<Long> feedIds, Instant date) {
updateQuery(FEED).set(FEED.disabledUntil, date).where(FEED.id.in(feedIds)).execute(); updateQuery(FEED).set(FEED.disabledUntil, date).where(FEED.id.in(feedIds)).execute();
} }
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) { public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
return query().selectFrom(FEED) return query().selectFrom(FEED)
.where(FEED.normalizedUrlHash.eq(normalizedUrlHash)) .where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
.fetch() .fetch()
.stream() .stream()
.filter(f -> StringUtils.equals(normalizedUrl, f.getNormalizedUrl())) .filter(f -> StringUtils.equals(normalizedUrl, f.getNormalizedUrl()))
.findFirst() .findFirst()
.orElse(null); .orElse(null);
} }
public List<Feed> findWithoutSubscriptions(int max) { public List<Feed> findWithoutSubscriptions(int max) {
QFeedSubscription sub = QFeedSubscription.feedSubscription; QFeedSubscription sub = QFeedSubscription.feedSubscription;
return query().selectFrom(FEED).where(JPAExpressions.selectOne().from(sub).where(sub.feed.eq(FEED)).notExists()).limit(max).fetch(); return query().selectFrom(FEED).where(JPAExpressions.selectOne().from(sub).where(sub.feed.eq(FEED)).notExists()).limit(max).fetch();
} }
} }

View File

@@ -1,36 +1,36 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import com.commafeed.backend.model.FeedEntryContent; import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.QFeedEntry; import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent; import com.commafeed.backend.model.QFeedEntryContent;
@Singleton @Singleton
public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> { public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
public FeedEntryContentDAO(EntityManager entityManager) { public FeedEntryContentDAO(EntityManager entityManager) {
super(entityManager, FeedEntryContent.class); super(entityManager, FeedEntryContent.class);
} }
public List<FeedEntryContent> findExisting(String contentHash, String titleHash) { public List<FeedEntryContent> findExisting(String contentHash, String titleHash) {
return query().select(CONTENT).from(CONTENT).where(CONTENT.contentHash.eq(contentHash), CONTENT.titleHash.eq(titleHash)).fetch(); return query().select(CONTENT).from(CONTENT).where(CONTENT.contentHash.eq(contentHash), CONTENT.titleHash.eq(titleHash)).fetch();
} }
public long deleteWithoutEntries(int max) { public long deleteWithoutEntries(int max) {
List<Long> ids = query().select(CONTENT.id) List<Long> ids = query().select(CONTENT.id)
.from(CONTENT) .from(CONTENT)
.leftJoin(ENTRY) .leftJoin(ENTRY)
.on(ENTRY.content.id.eq(CONTENT.id)) .on(ENTRY.content.id.eq(CONTENT.id))
.where(ENTRY.id.isNull()) .where(ENTRY.id.isNull())
.limit(max) .limit(max)
.fetch(); .fetch();
return deleteQuery(CONTENT).where(CONTENT.id.in(ids)).execute(); return deleteQuery(CONTENT).where(CONTENT.id.in(ids)).execute();
} }
} }

View File

@@ -1,73 +1,73 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.QFeedEntry; import com.commafeed.backend.model.QFeedEntry;
import com.querydsl.core.Tuple; import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.core.types.dsl.NumberExpression;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
@Singleton @Singleton
public class FeedEntryDAO extends GenericDAO<FeedEntry> { public class FeedEntryDAO extends GenericDAO<FeedEntry> {
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
public FeedEntryDAO(EntityManager entityManager) { public FeedEntryDAO(EntityManager entityManager) {
super(entityManager, FeedEntry.class); super(entityManager, FeedEntry.class);
} }
public FeedEntry findExisting(String guidHash, Feed feed) { public FeedEntry findExisting(String guidHash, Feed feed) {
return query().select(ENTRY).from(ENTRY).where(ENTRY.guidHash.eq(guidHash), ENTRY.feed.eq(feed)).limit(1).fetchOne(); return query().select(ENTRY).from(ENTRY).where(ENTRY.guidHash.eq(guidHash), ENTRY.feed.eq(feed)).limit(1).fetchOne();
} }
public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max) { public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max) {
NumberExpression<Long> count = ENTRY.id.count(); NumberExpression<Long> count = ENTRY.id.count();
List<Tuple> tuples = query().select(ENTRY.feed.id, count) List<Tuple> tuples = query().select(ENTRY.feed.id, count)
.from(ENTRY) .from(ENTRY)
.groupBy(ENTRY.feed) .groupBy(ENTRY.feed)
.having(count.gt(maxCapacity)) .having(count.gt(maxCapacity))
.limit(max) .limit(max)
.fetch(); .fetch();
return tuples.stream().map(t -> new FeedCapacity(t.get(ENTRY.feed.id), t.get(count))).toList(); return tuples.stream().map(t -> new FeedCapacity(t.get(ENTRY.feed.id), t.get(count))).toList();
} }
public int delete(Long feedId, long max) { public int delete(Long feedId, long max) {
List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch(); List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
return delete(list); return delete(list);
} }
/** /**
* Delete entries older than a certain date * Delete entries older than a certain date
*/ */
public int deleteEntriesOlderThan(Instant olderThan, long max) { public int deleteEntriesOlderThan(Instant olderThan, long max) {
List<FeedEntry> list = query().selectFrom(ENTRY) List<FeedEntry> list = query().selectFrom(ENTRY)
.where(ENTRY.published.lt(olderThan)) .where(ENTRY.published.lt(olderThan))
.orderBy(ENTRY.published.asc()) .orderBy(ENTRY.published.asc())
.limit(max) .limit(max)
.fetch(); .fetch();
return delete(list); return delete(list);
} }
/** /**
* Delete the oldest entries of a feed * Delete the oldest entries of a feed
*/ */
public int deleteOldEntries(Long feedId, long max) { public int deleteOldEntries(Long feedId, long max) {
List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max).fetch(); List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max).fetch();
return delete(list); return delete(list);
} }
@AllArgsConstructor @AllArgsConstructor
@Getter @Getter
public static class FeedCapacity { public static class FeedCapacity {
private Long id; private Long id;
private Long capacity; private Long capacity;
} }
} }

View File

@@ -1,236 +1,236 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.CollectionUtils;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.feed.FeedEntryKeyword; import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedEntryKeyword.Mode; import com.commafeed.backend.feed.FeedEntryKeyword.Mode;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryStatus; import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedEntryTag; import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.QFeedEntry; import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent; import com.commafeed.backend.model.QFeedEntryContent;
import com.commafeed.backend.model.QFeedEntryStatus; import com.commafeed.backend.model.QFeedEntryStatus;
import com.commafeed.backend.model.QFeedEntryTag; import com.commafeed.backend.model.QFeedEntryTag;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder; import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.frontend.model.UnreadCount; import com.commafeed.frontend.model.UnreadCount;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.querydsl.core.BooleanBuilder; import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple; import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQuery;
@Singleton @Singleton
public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> { public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private static final QFeedEntryStatus STATUS = QFeedEntryStatus.feedEntryStatus; private static final QFeedEntryStatus STATUS = QFeedEntryStatus.feedEntryStatus;
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
private final FeedEntryTagDAO feedEntryTagDAO; private final FeedEntryTagDAO feedEntryTagDAO;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
public FeedEntryStatusDAO(EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) { public FeedEntryStatusDAO(EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class); super(entityManager, FeedEntryStatus.class);
this.feedEntryTagDAO = feedEntryTagDAO; this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config; this.config = config;
} }
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) { public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
List<FeedEntryStatus> statuses = query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch(); List<FeedEntryStatus> statuses = query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch();
FeedEntryStatus status = Iterables.getFirst(statuses, null); FeedEntryStatus status = Iterables.getFirst(statuses, null);
return handleStatus(user, status, sub, entry); return handleStatus(user, status, sub, entry);
} }
/** /**
* creates an artificial "unread" status if status is null * creates an artificial "unread" status if status is null
*/ */
private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) { private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
if (status == null) { if (status == null) {
Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold(); Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold();
boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold); boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold);
status = new FeedEntryStatus(user, sub, entry); status = new FeedEntryStatus(user, sub, entry);
status.setRead(read); status.setRead(read);
status.setMarkable(!read); status.setMarkable(!read);
} else { } else {
status.setMarkable(true); status.setMarkable(true);
} }
return status; return status;
} }
private void fetchTags(User user, List<FeedEntryStatus> statuses) { private void fetchTags(User user, List<FeedEntryStatus> statuses) {
Map<Long, List<FeedEntryTag>> tagsByEntryIds = feedEntryTagDAO.findByEntries(user, Map<Long, List<FeedEntryTag>> tagsByEntryIds = feedEntryTagDAO.findByEntries(user,
statuses.stream().map(FeedEntryStatus::getEntry).toList()); statuses.stream().map(FeedEntryStatus::getEntry).toList());
for (FeedEntryStatus status : statuses) { for (FeedEntryStatus status : statuses) {
List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId()); List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId());
status.setTags(tags == null ? List.of() : tags); status.setTags(tags == null ? List.of() : tags);
} }
} }
public List<FeedEntryStatus> findStarred(User user, Instant newerThan, int offset, int limit, ReadingOrder order, public List<FeedEntryStatus> findStarred(User user, Instant newerThan, int offset, int limit, ReadingOrder order,
boolean includeContent) { boolean includeContent) {
JPAQuery<FeedEntryStatus> query = query().selectFrom(STATUS).where(STATUS.user.eq(user), STATUS.starred.isTrue()); JPAQuery<FeedEntryStatus> query = query().selectFrom(STATUS).where(STATUS.user.eq(user), STATUS.starred.isTrue());
if (includeContent) { if (includeContent) {
query.join(STATUS.entry).fetchJoin(); query.join(STATUS.entry).fetchJoin();
query.join(STATUS.entry.content).fetchJoin(); query.join(STATUS.entry.content).fetchJoin();
} }
if (newerThan != null) { if (newerThan != null) {
query.where(STATUS.entryInserted.gt(newerThan)); query.where(STATUS.entryInserted.gt(newerThan));
} }
if (order == ReadingOrder.asc) { if (order == ReadingOrder.asc) {
query.orderBy(STATUS.entryPublished.asc(), STATUS.id.asc()); query.orderBy(STATUS.entryPublished.asc(), STATUS.id.asc());
} else { } else {
query.orderBy(STATUS.entryPublished.desc(), STATUS.id.desc()); query.orderBy(STATUS.entryPublished.desc(), STATUS.id.desc());
} }
if (offset > -1) { if (offset > -1) {
query.offset(offset); query.offset(offset);
} }
if (limit > -1) { if (limit > -1) {
query.limit(limit); query.limit(limit);
} }
setTimeout(query, config.database().queryTimeout()); setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = query.fetch(); List<FeedEntryStatus> statuses = query.fetch();
statuses.forEach(s -> s.setMarkable(true)); statuses.forEach(s -> s.setMarkable(true));
if (includeContent) { if (includeContent) {
fetchTags(user, statuses); fetchTags(user, statuses);
} }
return statuses; return statuses;
} }
public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly, public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly,
List<FeedEntryKeyword> keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent, List<FeedEntryKeyword> keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent,
String tag, Long minEntryId, Long maxEntryId) { String tag, Long minEntryId, Long maxEntryId) {
Map<Long, List<FeedSubscription>> subsByFeedId = subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId())); Map<Long, List<FeedSubscription>> subsByFeedId = subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId()));
JPAQuery<Tuple> query = query().select(ENTRY, STATUS).from(ENTRY); JPAQuery<Tuple> query = query().select(ENTRY, STATUS).from(ENTRY);
query.leftJoin(ENTRY.statuses, STATUS).on(STATUS.subscription.in(subs)); query.leftJoin(ENTRY.statuses, STATUS).on(STATUS.subscription.in(subs));
query.where(ENTRY.feed.id.in(subsByFeedId.keySet())); query.where(ENTRY.feed.id.in(subsByFeedId.keySet()));
if (includeContent || CollectionUtils.isNotEmpty(keywords)) { if (includeContent || CollectionUtils.isNotEmpty(keywords)) {
query.join(ENTRY.content, CONTENT).fetchJoin(); query.join(ENTRY.content, CONTENT).fetchJoin();
} }
if (CollectionUtils.isNotEmpty(keywords)) { if (CollectionUtils.isNotEmpty(keywords)) {
for (FeedEntryKeyword keyword : keywords) { for (FeedEntryKeyword keyword : keywords) {
BooleanBuilder or = new BooleanBuilder(); BooleanBuilder or = new BooleanBuilder();
or.or(CONTENT.content.containsIgnoreCase(keyword.getKeyword())); or.or(CONTENT.content.containsIgnoreCase(keyword.getKeyword()));
or.or(CONTENT.title.containsIgnoreCase(keyword.getKeyword())); or.or(CONTENT.title.containsIgnoreCase(keyword.getKeyword()));
if (keyword.getMode() == Mode.EXCLUDE) { if (keyword.getMode() == Mode.EXCLUDE) {
or.not(); or.not();
} }
query.where(or); query.where(or);
} }
} }
if (unreadOnly && tag == null) { if (unreadOnly && tag == null) {
query.where(buildUnreadPredicate()); query.where(buildUnreadPredicate());
} }
if (tag != null) { if (tag != null) {
BooleanBuilder and = new BooleanBuilder(); BooleanBuilder and = new BooleanBuilder();
and.and(TAG.user.id.eq(user.getId())); and.and(TAG.user.id.eq(user.getId()));
and.and(TAG.name.eq(tag)); and.and(TAG.name.eq(tag));
query.join(ENTRY.tags, TAG).on(and); query.join(ENTRY.tags, TAG).on(and);
} }
if (newerThan != null) { if (newerThan != null) {
query.where(ENTRY.inserted.goe(newerThan)); query.where(ENTRY.inserted.goe(newerThan));
} }
if (minEntryId != null) { if (minEntryId != null) {
query.where(ENTRY.id.gt(minEntryId)); query.where(ENTRY.id.gt(minEntryId));
} }
if (maxEntryId != null) { if (maxEntryId != null) {
query.where(ENTRY.id.lt(maxEntryId)); query.where(ENTRY.id.lt(maxEntryId));
} }
if (order != null) { if (order != null) {
if (order == ReadingOrder.asc) { if (order == ReadingOrder.asc) {
query.orderBy(ENTRY.published.asc(), ENTRY.id.asc()); query.orderBy(ENTRY.published.asc(), ENTRY.id.asc());
} else { } else {
query.orderBy(ENTRY.published.desc(), ENTRY.id.desc()); query.orderBy(ENTRY.published.desc(), ENTRY.id.desc());
} }
} }
if (offset > -1) { if (offset > -1) {
query.offset(offset); query.offset(offset);
} }
if (limit > -1) { if (limit > -1) {
query.limit(limit); query.limit(limit);
} }
setTimeout(query, config.database().queryTimeout()); setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = new ArrayList<>(); List<FeedEntryStatus> statuses = new ArrayList<>();
List<Tuple> tuples = query.fetch(); List<Tuple> tuples = query.fetch();
for (Tuple tuple : tuples) { for (Tuple tuple : tuples) {
FeedEntry e = tuple.get(ENTRY); FeedEntry e = tuple.get(ENTRY);
FeedEntryStatus s = tuple.get(STATUS); FeedEntryStatus s = tuple.get(STATUS);
for (FeedSubscription sub : subsByFeedId.get(e.getFeed().getId())) { for (FeedSubscription sub : subsByFeedId.get(e.getFeed().getId())) {
statuses.add(handleStatus(user, s, sub, e)); statuses.add(handleStatus(user, s, sub, e));
} }
} }
if (includeContent) { if (includeContent) {
fetchTags(user, statuses); fetchTags(user, statuses);
} }
return statuses; return statuses;
} }
public UnreadCount getUnreadCount(FeedSubscription sub) { public UnreadCount getUnreadCount(FeedSubscription sub) {
JPAQuery<Tuple> query = query().select(ENTRY.count(), ENTRY.published.max()) JPAQuery<Tuple> query = query().select(ENTRY.count(), ENTRY.published.max())
.from(ENTRY) .from(ENTRY)
.leftJoin(ENTRY.statuses, STATUS) .leftJoin(ENTRY.statuses, STATUS)
.on(STATUS.subscription.eq(sub)) .on(STATUS.subscription.eq(sub))
.where(ENTRY.feed.eq(sub.getFeed())) .where(ENTRY.feed.eq(sub.getFeed()))
.where(buildUnreadPredicate()); .where(buildUnreadPredicate());
Tuple tuple = query.fetchOne(); Tuple tuple = query.fetchOne();
Long count = tuple.get(ENTRY.count()); Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max()); Instant published = tuple.get(ENTRY.published.max());
return new UnreadCount(sub.getId(), count == null ? 0 : count, published); return new UnreadCount(sub.getId(), count == null ? 0 : count, published);
} }
private BooleanBuilder buildUnreadPredicate() { private BooleanBuilder buildUnreadPredicate() {
BooleanBuilder or = new BooleanBuilder(); BooleanBuilder or = new BooleanBuilder();
or.or(STATUS.read.isNull()); or.or(STATUS.read.isNull());
or.or(STATUS.read.isFalse()); or.or(STATUS.read.isFalse());
Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold(); Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold();
if (statusesInstantThreshold != null) { if (statusesInstantThreshold != null) {
return or.and(ENTRY.published.goe(statusesInstantThreshold)); return or.and(ENTRY.published.goe(statusesInstantThreshold));
} else { } else {
return or; return or;
} }
} }
public long deleteOldStatuses(Instant olderThan, int limit) { public long deleteOldStatuses(Instant olderThan, int limit) {
List<Long> ids = query().select(STATUS.id) List<Long> ids = query().select(STATUS.id)
.from(STATUS) .from(STATUS)
.where(STATUS.entryInserted.lt(olderThan), STATUS.starred.isFalse()) .where(STATUS.entryInserted.lt(olderThan), STATUS.starred.isFalse())
.limit(limit) .limit(limit)
.fetch(); .fetch();
return deleteQuery(STATUS).where(STATUS.id.in(ids)).execute(); return deleteQuery(STATUS).where(STATUS.id.in(ids)).execute();
} }
} }

View File

@@ -1,39 +1,39 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag; import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.QFeedEntryTag; import com.commafeed.backend.model.QFeedEntryTag;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
@Singleton @Singleton
public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> { public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
public FeedEntryTagDAO(EntityManager entityManager) { public FeedEntryTagDAO(EntityManager entityManager) {
super(entityManager, FeedEntryTag.class); super(entityManager, FeedEntryTag.class);
} }
public List<String> findByUser(User user) { public List<String> findByUser(User user) {
return query().selectDistinct(TAG.name).from(TAG).where(TAG.user.eq(user)).fetch(); return query().selectDistinct(TAG.name).from(TAG).where(TAG.user.eq(user)).fetch();
} }
public List<FeedEntryTag> findByEntry(User user, FeedEntry entry) { public List<FeedEntryTag> findByEntry(User user, FeedEntry entry) {
return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.eq(entry)).fetch(); return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.eq(entry)).fetch();
} }
public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) { public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) {
return query().selectFrom(TAG) return query().selectFrom(TAG)
.where(TAG.user.eq(user), TAG.entry.in(entries)) .where(TAG.user.eq(user), TAG.entry.in(entries))
.fetch() .fetch()
.stream() .stream()
.collect(Collectors.groupingBy(t -> t.getEntry().getId())); .collect(Collectors.groupingBy(t -> t.getEntry().getId()));
} }
} }

View File

@@ -1,130 +1,130 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import org.hibernate.engine.spi.SharedSessionContractImplementor; import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.event.service.spi.EventListenerRegistry; import org.hibernate.event.service.spi.EventListenerRegistry;
import org.hibernate.event.spi.EventType; import org.hibernate.event.spi.EventType;
import org.hibernate.event.spi.PostCommitInsertEventListener; import org.hibernate.event.spi.PostCommitInsertEventListener;
import org.hibernate.event.spi.PostInsertEvent; import org.hibernate.event.spi.PostInsertEvent;
import org.hibernate.persister.entity.EntityPersister; import org.hibernate.persister.entity.EntityPersister;
import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.Models; import com.commafeed.backend.model.Models;
import com.commafeed.backend.model.QFeedSubscription; import com.commafeed.backend.model.QFeedSubscription;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.querydsl.jpa.JPQLQuery; import com.querydsl.jpa.JPQLQuery;
@Singleton @Singleton
public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> { public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
private final EntityManager entityManager; private final EntityManager entityManager;
public FeedSubscriptionDAO(EntityManager entityManager) { public FeedSubscriptionDAO(EntityManager entityManager) {
super(entityManager, FeedSubscription.class); super(entityManager, FeedSubscription.class);
this.entityManager = entityManager; this.entityManager = entityManager;
} }
public void onPostCommitInsert(Consumer<FeedSubscription> consumer) { public void onPostCommitInsert(Consumer<FeedSubscription> consumer) {
entityManager.unwrap(SharedSessionContractImplementor.class) entityManager.unwrap(SharedSessionContractImplementor.class)
.getFactory() .getFactory()
.getServiceRegistry() .getServiceRegistry()
.getService(EventListenerRegistry.class) .getService(EventListenerRegistry.class)
.getEventListenerGroup(EventType.POST_COMMIT_INSERT) .getEventListenerGroup(EventType.POST_COMMIT_INSERT)
.appendListener(new PostCommitInsertEventListener() { .appendListener(new PostCommitInsertEventListener() {
@Override @Override
public void onPostInsert(PostInsertEvent event) { public void onPostInsert(PostInsertEvent event) {
if (event.getEntity() instanceof FeedSubscription s) { if (event.getEntity() instanceof FeedSubscription s) {
consumer.accept(s); consumer.accept(s);
} }
} }
@Override @Override
public boolean requiresPostCommitHandling(EntityPersister persister) { public boolean requiresPostCommitHandling(EntityPersister persister) {
return true; return true;
} }
@Override @Override
public void onPostInsertCommitFailed(PostInsertEvent event) { public void onPostInsertCommitFailed(PostInsertEvent event) {
// do nothing // do nothing
} }
}); });
} }
public FeedSubscription findById(User user, Long id) { public FeedSubscription findById(User user, Long id) {
List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION) List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.id.eq(id)) .where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.id.eq(id))
.leftJoin(SUBSCRIPTION.feed) .leftJoin(SUBSCRIPTION.feed)
.fetchJoin() .fetchJoin()
.leftJoin(SUBSCRIPTION.category) .leftJoin(SUBSCRIPTION.category)
.fetchJoin() .fetchJoin()
.fetch(); .fetch();
return initRelations(Iterables.getFirst(subs, null)); return initRelations(Iterables.getFirst(subs, null));
} }
public List<FeedSubscription> findByFeed(Feed feed) { public List<FeedSubscription> findByFeed(Feed feed) {
return query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.feed.eq(feed)).fetch(); return query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.feed.eq(feed)).fetch();
} }
public FeedSubscription findByFeed(User user, Feed feed) { public FeedSubscription findByFeed(User user, Feed feed) {
List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION) List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed)) .where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed))
.fetch(); .fetch();
return initRelations(Iterables.getFirst(subs, null)); return initRelations(Iterables.getFirst(subs, null));
} }
public List<FeedSubscription> findAll(User user) { public List<FeedSubscription> findAll(User user) {
List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION) List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user)) .where(SUBSCRIPTION.user.eq(user))
.leftJoin(SUBSCRIPTION.feed) .leftJoin(SUBSCRIPTION.feed)
.fetchJoin() .fetchJoin()
.leftJoin(SUBSCRIPTION.category) .leftJoin(SUBSCRIPTION.category)
.fetchJoin() .fetchJoin()
.fetch(); .fetch();
return initRelations(subs); return initRelations(subs);
} }
public Long count(User user) { public Long count(User user) {
return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne(); return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne();
} }
public List<FeedSubscription> findByCategory(User user, FeedCategory category) { public List<FeedSubscription> findByCategory(User user, FeedCategory category) {
JPQLQuery<FeedSubscription> query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)); JPQLQuery<FeedSubscription> query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
if (category == null) { if (category == null) {
query.where(SUBSCRIPTION.category.isNull()); query.where(SUBSCRIPTION.category.isNull());
} else { } else {
query.where(SUBSCRIPTION.category.eq(category)); query.where(SUBSCRIPTION.category.eq(category));
} }
return initRelations(query.fetch()); return initRelations(query.fetch());
} }
public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) { public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) {
Set<Long> categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet()); Set<Long> categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
return findAll(user).stream().filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId())).toList(); return findAll(user).stream().filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId())).toList();
} }
private List<FeedSubscription> initRelations(List<FeedSubscription> list) { private List<FeedSubscription> initRelations(List<FeedSubscription> list) {
list.forEach(this::initRelations); list.forEach(this::initRelations);
return list; return list;
} }
private FeedSubscription initRelations(FeedSubscription sub) { private FeedSubscription initRelations(FeedSubscription sub) {
if (sub != null) { if (sub != null) {
Models.initialize(sub.getFeed()); Models.initialize(sub.getFeed());
Models.initialize(sub.getCategory()); Models.initialize(sub.getCategory());
} }
return sub; return sub;
} }
} }

View File

@@ -1,76 +1,76 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.time.Duration; import java.time.Duration;
import java.util.Collection; import java.util.Collection;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.jpa.SpecHints; import org.hibernate.jpa.SpecHints;
import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.AbstractModel;
import com.querydsl.core.types.EntityPath; import com.querydsl.core.types.EntityPath;
import com.querydsl.jpa.impl.JPADeleteClause; import com.querydsl.jpa.impl.JPADeleteClause;
import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory; import com.querydsl.jpa.impl.JPAQueryFactory;
import com.querydsl.jpa.impl.JPAUpdateClause; import com.querydsl.jpa.impl.JPAUpdateClause;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
public abstract class GenericDAO<T extends AbstractModel> { public abstract class GenericDAO<T extends AbstractModel> {
private final EntityManager entityManager; private final EntityManager entityManager;
private final Class<T> entityClass; private final Class<T> entityClass;
protected JPAQueryFactory query() { protected JPAQueryFactory query() {
return new JPAQueryFactory(entityManager); return new JPAQueryFactory(entityManager);
} }
protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) { protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) {
return new JPAUpdateClause(entityManager, entityPath); return new JPAUpdateClause(entityManager, entityPath);
} }
protected JPADeleteClause deleteQuery(EntityPath<T> entityPath) { protected JPADeleteClause deleteQuery(EntityPath<T> entityPath) {
return new JPADeleteClause(entityManager, entityPath); return new JPADeleteClause(entityManager, entityPath);
} }
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public void saveOrUpdate(T model) { public void saveOrUpdate(T model) {
entityManager.unwrap(Session.class).saveOrUpdate(model); entityManager.unwrap(Session.class).saveOrUpdate(model);
} }
public void saveOrUpdate(Collection<T> models) { public void saveOrUpdate(Collection<T> models) {
models.forEach(this::saveOrUpdate); models.forEach(this::saveOrUpdate);
} }
public void persist(T model) { public void persist(T model) {
entityManager.persist(model); entityManager.persist(model);
} }
public T merge(T model) { public T merge(T model) {
return entityManager.merge(model); return entityManager.merge(model);
} }
public T findById(Long id) { public T findById(Long id) {
return entityManager.find(entityClass, id); return entityManager.find(entityClass, id);
} }
public void delete(T object) { public void delete(T object) {
if (object != null) { if (object != null) {
entityManager.remove(object); entityManager.remove(object);
} }
} }
public int delete(Collection<T> objects) { public int delete(Collection<T> objects) {
objects.forEach(this::delete); objects.forEach(this::delete);
return objects.size(); return objects.size();
} }
protected void setTimeout(JPAQuery<?> query, Duration timeout) { protected void setTimeout(JPAQuery<?> query, Duration timeout) {
if (!timeout.isZero()) { if (!timeout.isZero()) {
query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis())); query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis()));
} }
} }
} }

View File

@@ -1,19 +1,19 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.narayana.jta.QuarkusTransaction;
@Singleton @Singleton
public class UnitOfWork { public class UnitOfWork {
public void run(Runnable runnable) { public void run(Runnable runnable) {
QuarkusTransaction.joiningExisting().run(runnable); QuarkusTransaction.joiningExisting().run(runnable);
} }
public <T> T call(Callable<T> callable) { public <T> T call(Callable<T> callable) {
return QuarkusTransaction.joiningExisting().call(callable); return QuarkusTransaction.joiningExisting().call(callable);
} }
} }

View File

@@ -1,33 +1,33 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import com.commafeed.backend.model.QUser; import com.commafeed.backend.model.QUser;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
@Singleton @Singleton
public class UserDAO extends GenericDAO<User> { public class UserDAO extends GenericDAO<User> {
private static final QUser USER = QUser.user; private static final QUser USER = QUser.user;
public UserDAO(EntityManager entityManager) { public UserDAO(EntityManager entityManager) {
super(entityManager, User.class); super(entityManager, User.class);
} }
public User findByName(String name) { public User findByName(String name) {
return query().selectFrom(USER).where(USER.name.equalsIgnoreCase(name)).fetchOne(); return query().selectFrom(USER).where(USER.name.equalsIgnoreCase(name)).fetchOne();
} }
public User findByApiKey(String key) { public User findByApiKey(String key) {
return query().selectFrom(USER).where(USER.apiKey.equalsIgnoreCase(key)).fetchOne(); return query().selectFrom(USER).where(USER.apiKey.equalsIgnoreCase(key)).fetchOne();
} }
public User findByEmail(String email) { public User findByEmail(String email) {
return query().selectFrom(USER).where(USER.email.equalsIgnoreCase(email)).fetchOne(); return query().selectFrom(USER).where(USER.email.equalsIgnoreCase(email)).fetchOne();
} }
public long count() { public long count() {
return query().select(USER.count()).from(USER).fetchOne(); return query().select(USER.count()).from(USER).fetchOne();
} }
} }

View File

@@ -1,35 +1,35 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import com.commafeed.backend.model.QUserRole; import com.commafeed.backend.model.QUserRole;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole; import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role; import com.commafeed.backend.model.UserRole.Role;
@Singleton @Singleton
public class UserRoleDAO extends GenericDAO<UserRole> { public class UserRoleDAO extends GenericDAO<UserRole> {
private static final QUserRole ROLE = QUserRole.userRole; private static final QUserRole ROLE = QUserRole.userRole;
public UserRoleDAO(EntityManager entityManager) { public UserRoleDAO(EntityManager entityManager) {
super(entityManager, UserRole.class); super(entityManager, UserRole.class);
} }
public List<UserRole> findAll() { public List<UserRole> findAll() {
return query().selectFrom(ROLE).leftJoin(ROLE.user).fetchJoin().distinct().fetch(); return query().selectFrom(ROLE).leftJoin(ROLE.user).fetchJoin().distinct().fetch();
} }
public List<UserRole> findAll(User user) { public List<UserRole> findAll(User user) {
return query().selectFrom(ROLE).where(ROLE.user.eq(user)).distinct().fetch(); return query().selectFrom(ROLE).where(ROLE.user.eq(user)).distinct().fetch();
} }
public Set<Role> findRoles(User user) { public Set<Role> findRoles(User user) {
return findAll(user).stream().map(UserRole::getRole).collect(Collectors.toSet()); return findAll(user).stream().map(UserRole::getRole).collect(Collectors.toSet());
} }
} }

View File

@@ -1,22 +1,22 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import com.commafeed.backend.model.QUserSettings; import com.commafeed.backend.model.QUserSettings;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings; import com.commafeed.backend.model.UserSettings;
@Singleton @Singleton
public class UserSettingsDAO extends GenericDAO<UserSettings> { public class UserSettingsDAO extends GenericDAO<UserSettings> {
private static final QUserSettings SETTINGS = QUserSettings.userSettings; private static final QUserSettings SETTINGS = QUserSettings.userSettings;
public UserSettingsDAO(EntityManager entityManager) { public UserSettingsDAO(EntityManager entityManager) {
super(entityManager, UserSettings.class); super(entityManager, UserSettings.class);
} }
public UserSettings findByUser(User user) { public UserSettings findByUser(User user) {
return query().selectFrom(SETTINGS).where(SETTINGS.user.eq(user)).fetchFirst(); return query().selectFrom(SETTINGS).where(SETTINGS.user.eq(user)).fetchFirst();
} }
} }

View File

@@ -1,49 +1,49 @@
package com.commafeed.backend.favicon; package com.commafeed.backend.favicon;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
public abstract class AbstractFaviconFetcher { public abstract class AbstractFaviconFetcher {
private static final List<String> ICON_MIMETYPE_BLACKLIST = Arrays.asList("application/xml", "text/html"); private static final List<String> ICON_MIMETYPE_BLACKLIST = Arrays.asList("application/xml", "text/html");
private static final long MIN_ICON_LENGTH = 100; private static final long MIN_ICON_LENGTH = 100;
private static final long MAX_ICON_LENGTH = 100000; private static final long MAX_ICON_LENGTH = 100000;
public abstract Favicon fetch(Feed feed); public abstract Favicon fetch(Feed feed);
protected boolean isValidIconResponse(byte[] content, String contentType) { protected boolean isValidIconResponse(byte[] content, String contentType) {
if (content == null) { if (content == null) {
return false; return false;
} }
long length = content.length; long length = content.length;
if (StringUtils.isNotBlank(contentType)) { if (StringUtils.isNotBlank(contentType)) {
contentType = contentType.split(";")[0]; contentType = contentType.split(";")[0];
} }
if (ICON_MIMETYPE_BLACKLIST.contains(contentType)) { if (ICON_MIMETYPE_BLACKLIST.contains(contentType)) {
log.debug("Content-Type {} is blacklisted", contentType); log.debug("Content-Type {} is blacklisted", contentType);
return false; return false;
} }
if (length < MIN_ICON_LENGTH) { if (length < MIN_ICON_LENGTH) {
log.debug("Length {} below MIN_ICON_LENGTH {}", length, MIN_ICON_LENGTH); log.debug("Length {} below MIN_ICON_LENGTH {}", length, MIN_ICON_LENGTH);
return false; return false;
} }
if (length > MAX_ICON_LENGTH) { if (length > MAX_ICON_LENGTH) {
log.debug("Length {} greater than MAX_ICON_LENGTH {}", length, MAX_ICON_LENGTH); log.debug("Length {} greater than MAX_ICON_LENGTH {}", length, MAX_ICON_LENGTH);
return false; return false;
} }
return true; return true;
} }
} }

View File

@@ -1,133 +1,133 @@
package com.commafeed.backend.favicon; package com.commafeed.backend.favicon;
import jakarta.annotation.Priority; import jakarta.annotation.Priority;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import com.commafeed.backend.HttpGetter; import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Inspired/Ported from https://github.com/potatolondon/getfavicon * Inspired/Ported from https://github.com/potatolondon/getfavicon
* *
*/ */
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
@Priority(Integer.MIN_VALUE) @Priority(Integer.MIN_VALUE)
public class DefaultFaviconFetcher extends AbstractFaviconFetcher { public class DefaultFaviconFetcher extends AbstractFaviconFetcher {
private final HttpGetter getter; private final HttpGetter getter;
@Override @Override
public Favicon fetch(Feed feed) { public Favicon fetch(Feed feed) {
Favicon icon = fetch(feed.getLink()); Favicon icon = fetch(feed.getLink());
if (icon == null) { if (icon == null) {
icon = fetch(feed.getUrl()); icon = fetch(feed.getUrl());
} }
return icon; return icon;
} }
private Favicon fetch(String url) { private Favicon fetch(String url) {
if (url == null) { if (url == null) {
log.debug("url is null"); log.debug("url is null");
return null; return null;
} }
int doubleSlash = url.indexOf("//"); int doubleSlash = url.indexOf("//");
if (doubleSlash == -1) { if (doubleSlash == -1) {
doubleSlash = 0; doubleSlash = 0;
} else { } else {
doubleSlash += 2; doubleSlash += 2;
} }
int firstSlash = url.indexOf('/', doubleSlash); int firstSlash = url.indexOf('/', doubleSlash);
if (firstSlash != -1) { if (firstSlash != -1) {
url = url.substring(0, firstSlash); url = url.substring(0, firstSlash);
} }
Favicon icon = getIconAtRoot(url); Favicon icon = getIconAtRoot(url);
if (icon == null) { if (icon == null) {
icon = getIconInPage(url); icon = getIconInPage(url);
} }
return icon; return icon;
} }
private Favicon getIconAtRoot(String url) { private Favicon getIconAtRoot(String url) {
byte[] bytes = null; byte[] bytes = null;
String contentType = null; String contentType = null;
try { try {
url = FeedUtils.removeTrailingSlash(url) + "/favicon.ico"; url = FeedUtils.removeTrailingSlash(url) + "/favicon.ico";
log.debug("getting root icon at {}", url); log.debug("getting root icon at {}", url);
HttpResult result = getter.get(url); HttpResult result = getter.get(url);
bytes = result.getContent(); bytes = result.getContent();
contentType = result.getContentType(); contentType = result.getContentType();
} catch (Exception e) { } catch (Exception e) {
log.debug("Failed to retrieve iconAtRoot for url {}: ", url); log.debug("Failed to retrieve iconAtRoot for url {}: ", url);
log.trace("Failed to retrieve iconAtRoot for url {}: ", url, e); log.trace("Failed to retrieve iconAtRoot for url {}: ", url, e);
} }
if (!isValidIconResponse(bytes, contentType)) { if (!isValidIconResponse(bytes, contentType)) {
return null; return null;
} }
return new Favicon(bytes, contentType); return new Favicon(bytes, contentType);
} }
private Favicon getIconInPage(String url) { private Favicon getIconInPage(String url) {
Document doc; Document doc;
try { try {
HttpResult result = getter.get(url); HttpResult result = getter.get(url);
doc = Jsoup.parse(new String(result.getContent()), url); doc = Jsoup.parse(new String(result.getContent()), url);
} catch (Exception e) { } catch (Exception e) {
log.debug("Failed to retrieve page to find icon"); log.debug("Failed to retrieve page to find icon");
log.trace("Failed to retrieve page to find icon", e); log.trace("Failed to retrieve page to find icon", e);
return null; return null;
} }
Elements icons = doc.select("link[rel~=(?i)^(shortcut|icon|shortcut icon)$]"); Elements icons = doc.select("link[rel~=(?i)^(shortcut|icon|shortcut icon)$]");
if (icons.isEmpty()) { if (icons.isEmpty()) {
log.debug("No icon found in page {}", url); log.debug("No icon found in page {}", url);
return null; return null;
} }
String href = icons.get(0).attr("abs:href"); String href = icons.get(0).attr("abs:href");
if (StringUtils.isBlank(href)) { if (StringUtils.isBlank(href)) {
log.debug("No icon found in page"); log.debug("No icon found in page");
return null; return null;
} }
log.debug("Found unconfirmed iconInPage at {}", href); log.debug("Found unconfirmed iconInPage at {}", href);
byte[] bytes; byte[] bytes;
String contentType; String contentType;
try { try {
HttpResult result = getter.get(href); HttpResult result = getter.get(href);
bytes = result.getContent(); bytes = result.getContent();
contentType = result.getContentType(); contentType = result.getContentType();
} catch (Exception e) { } catch (Exception e) {
log.debug("Failed to retrieve icon found in page {}", href); log.debug("Failed to retrieve icon found in page {}", href);
log.trace("Failed to retrieve icon found in page {}", href, e); log.trace("Failed to retrieve icon found in page {}", href, e);
return null; return null;
} }
if (!isValidIconResponse(bytes, contentType)) { if (!isValidIconResponse(bytes, contentType)) {
log.debug("Invalid icon found for {}", href); log.debug("Invalid icon found for {}", href);
return null; return null;
} }
return new Favicon(bytes, contentType); return new Favicon(bytes, contentType);
} }
} }

View File

@@ -1,73 +1,73 @@
package com.commafeed.backend.favicon; package com.commafeed.backend.favicon;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.net.URIBuilder; import org.apache.hc.core5.net.URIBuilder;
import com.commafeed.backend.HttpGetter; import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class FacebookFaviconFetcher extends AbstractFaviconFetcher { public class FacebookFaviconFetcher extends AbstractFaviconFetcher {
private final HttpGetter getter; private final HttpGetter getter;
@Override @Override
public Favicon fetch(Feed feed) { public Favicon fetch(Feed feed) {
String url = feed.getUrl(); String url = feed.getUrl();
if (!url.toLowerCase().contains("www.facebook.com")) { if (!url.toLowerCase().contains("www.facebook.com")) {
return null; return null;
} }
String userName = extractUserName(url); String userName = extractUserName(url);
if (userName == null) { if (userName == null) {
return null; return null;
} }
String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName); String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName);
byte[] bytes = null; byte[] bytes = null;
String contentType = null; String contentType = null;
try { try {
log.debug("Getting Facebook user's icon, {}", url); log.debug("Getting Facebook user's icon, {}", url);
HttpResult iconResult = getter.get(iconUrl); HttpResult iconResult = getter.get(iconUrl);
bytes = iconResult.getContent(); bytes = iconResult.getContent();
contentType = iconResult.getContentType(); contentType = iconResult.getContentType();
} catch (Exception e) { } catch (Exception e) {
log.debug("Failed to retrieve Facebook icon", e); log.debug("Failed to retrieve Facebook icon", e);
} }
if (!isValidIconResponse(bytes, contentType)) { if (!isValidIconResponse(bytes, contentType)) {
return null; return null;
} }
return new Favicon(bytes, contentType); return new Favicon(bytes, contentType);
} }
private String extractUserName(String url) { private String extractUserName(String url) {
URI uri; URI uri;
try { try {
uri = new URI(url); uri = new URI(url);
} catch (URISyntaxException e) { } catch (URISyntaxException e) {
log.debug("could not parse url", e); log.debug("could not parse url", e);
return null; return null;
} }
List<NameValuePair> params = new URIBuilder(uri).getQueryParams(); List<NameValuePair> params = new URIBuilder(uri).getQueryParams();
return params.stream().filter(p -> "id".equals(p.getName())).map(NameValuePair::getValue).findFirst().orElse(null); return params.stream().filter(p -> "id".equals(p.getName())).map(NameValuePair::getValue).findFirst().orElse(null);
} }
} }

View File

@@ -1,31 +1,31 @@
package com.commafeed.backend.favicon; package com.commafeed.backend.favicon;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor @RequiredArgsConstructor
@Getter @Getter
@Slf4j @Slf4j
public class Favicon { public class Favicon {
private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.valueOf("image/x-icon"); private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.valueOf("image/x-icon");
private final byte[] icon; private final byte[] icon;
private final MediaType mediaType; private final MediaType mediaType;
public Favicon(byte[] icon, String contentType) { public Favicon(byte[] icon, String contentType) {
this(icon, parseMediaType(contentType)); this(icon, parseMediaType(contentType));
} }
private static MediaType parseMediaType(String contentType) { private static MediaType parseMediaType(String contentType) {
try { try {
return MediaType.valueOf(contentType); return MediaType.valueOf(contentType);
} catch (Exception e) { } catch (Exception e) {
log.debug("invalid content type '{}' received, returning default value", contentType); log.debug("invalid content type '{}' received, returning default value", contentType);
return DEFAULT_MEDIA_TYPE; return DEFAULT_MEDIA_TYPE;
} }
} }
} }

View File

@@ -1,135 +1,135 @@
package com.commafeed.backend.favicon; package com.commafeed.backend.favicon;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ArrayUtils;
import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.net.URIBuilder; import org.apache.hc.core5.net.URIBuilder;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.HttpGetter; import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HostNotAllowedException; import com.commafeed.backend.HttpGetter.HostNotAllowedException;
import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.HttpGetter.NotModifiedException; import com.commafeed.backend.HttpGetter.NotModifiedException;
import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; import com.commafeed.backend.HttpGetter.SchemeNotAllowedException;
import com.commafeed.backend.HttpGetter.TooManyRequestsException; import com.commafeed.backend.HttpGetter.TooManyRequestsException;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.fasterxml.jackson.core.JsonPointer; import com.fasterxml.jackson.core.JsonPointer;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
private static final JsonPointer CHANNEL_THUMBNAIL_URL = JsonPointer.compile("/items/0/snippet/thumbnails/default/url"); private static final JsonPointer CHANNEL_THUMBNAIL_URL = JsonPointer.compile("/items/0/snippet/thumbnails/default/url");
private static final JsonPointer PLAYLIST_CHANNEL_ID = JsonPointer.compile("/items/0/snippet/channelId"); private static final JsonPointer PLAYLIST_CHANNEL_ID = JsonPointer.compile("/items/0/snippet/channelId");
private final HttpGetter getter; private final HttpGetter getter;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Override @Override
public Favicon fetch(Feed feed) { public Favicon fetch(Feed feed) {
String url = feed.getUrl(); String url = feed.getUrl();
if (!url.toLowerCase().contains("youtube.com/feeds/videos.xml")) { if (!url.toLowerCase().contains("youtube.com/feeds/videos.xml")) {
return null; return null;
} }
Optional<String> googleAuthKey = config.googleAuthKey(); Optional<String> googleAuthKey = config.googleAuthKey();
if (googleAuthKey.isEmpty()) { if (googleAuthKey.isEmpty()) {
log.debug("no google auth key configured"); log.debug("no google auth key configured");
return null; return null;
} }
byte[] bytes = null; byte[] bytes = null;
String contentType = null; String contentType = null;
try { try {
List<NameValuePair> params = new URIBuilder(url).getQueryParams(); List<NameValuePair> params = new URIBuilder(url).getQueryParams();
Optional<NameValuePair> userId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst(); Optional<NameValuePair> userId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst();
Optional<NameValuePair> channelId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst(); Optional<NameValuePair> channelId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst();
Optional<NameValuePair> playlistId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst(); Optional<NameValuePair> playlistId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst();
byte[] response = null; byte[] response = null;
if (userId.isPresent()) { if (userId.isPresent()) {
log.debug("contacting youtube api for user {}", userId.get().getValue()); log.debug("contacting youtube api for user {}", userId.get().getValue());
response = fetchForUser(googleAuthKey.get(), userId.get().getValue()); response = fetchForUser(googleAuthKey.get(), userId.get().getValue());
} else if (channelId.isPresent()) { } else if (channelId.isPresent()) {
log.debug("contacting youtube api for channel {}", channelId.get().getValue()); log.debug("contacting youtube api for channel {}", channelId.get().getValue());
response = fetchForChannel(googleAuthKey.get(), channelId.get().getValue()); response = fetchForChannel(googleAuthKey.get(), channelId.get().getValue());
} else if (playlistId.isPresent()) { } else if (playlistId.isPresent()) {
log.debug("contacting youtube api for playlist {}", playlistId.get().getValue()); log.debug("contacting youtube api for playlist {}", playlistId.get().getValue());
response = fetchForPlaylist(googleAuthKey.get(), playlistId.get().getValue()); response = fetchForPlaylist(googleAuthKey.get(), playlistId.get().getValue());
} }
if (ArrayUtils.isEmpty(response)) { if (ArrayUtils.isEmpty(response)) {
log.debug("youtube api returned empty response"); log.debug("youtube api returned empty response");
return null; return null;
} }
JsonNode thumbnailUrl = objectMapper.readTree(response).at(CHANNEL_THUMBNAIL_URL); JsonNode thumbnailUrl = objectMapper.readTree(response).at(CHANNEL_THUMBNAIL_URL);
if (thumbnailUrl.isMissingNode()) { if (thumbnailUrl.isMissingNode()) {
log.debug("youtube api returned invalid response"); log.debug("youtube api returned invalid response");
return null; return null;
} }
HttpResult iconResult = getter.get(thumbnailUrl.asText()); HttpResult iconResult = getter.get(thumbnailUrl.asText());
bytes = iconResult.getContent(); bytes = iconResult.getContent();
contentType = iconResult.getContentType(); contentType = iconResult.getContentType();
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to retrieve YouTube icon", e); log.error("Failed to retrieve YouTube icon", e);
} }
if (!isValidIconResponse(bytes, contentType)) { if (!isValidIconResponse(bytes, contentType)) {
return null; return null;
} }
return new Favicon(bytes, contentType); return new Favicon(bytes, contentType);
} }
private byte[] fetchForUser(String googleAuthKey, String userId) private byte[] fetchForUser(String googleAuthKey, String userId)
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", "snippet") .queryParam("part", "snippet")
.queryParam("key", googleAuthKey) .queryParam("key", googleAuthKey)
.queryParam("forUsername", userId) .queryParam("forUsername", userId)
.build(); .build();
return getter.get(uri.toString()).getContent(); return getter.get(uri.toString()).getContent();
} }
private byte[] fetchForChannel(String googleAuthKey, String channelId) private byte[] fetchForChannel(String googleAuthKey, String channelId)
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
.queryParam("part", "snippet") .queryParam("part", "snippet")
.queryParam("key", googleAuthKey) .queryParam("key", googleAuthKey)
.queryParam("id", channelId) .queryParam("id", channelId)
.build(); .build();
return getter.get(uri.toString()).getContent(); return getter.get(uri.toString()).getContent();
} }
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException {
URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists") URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
.queryParam("part", "snippet") .queryParam("part", "snippet")
.queryParam("key", googleAuthKey) .queryParam("key", googleAuthKey)
.queryParam("id", playlistId) .queryParam("id", playlistId)
.build(); .build();
byte[] playlistBytes = getter.get(uri.toString()).getContent(); byte[] playlistBytes = getter.get(uri.toString()).getContent();
JsonNode channelId = objectMapper.readTree(playlistBytes).at(PLAYLIST_CHANNEL_ID); JsonNode channelId = objectMapper.readTree(playlistBytes).at(PLAYLIST_CHANNEL_ID);
if (channelId.isMissingNode()) { if (channelId.isMissingNode()) {
return null; return null;
} }
return fetchForChannel(googleAuthKey, channelId.asText()); return fetchForChannel(googleAuthKey, channelId.asText());
} }
} }

View File

@@ -1,39 +1,39 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
/** /**
* A keyword used in a search query * A keyword used in a search query
*/ */
@Getter @Getter
@RequiredArgsConstructor @RequiredArgsConstructor
public class FeedEntryKeyword { public class FeedEntryKeyword {
public enum Mode { public enum Mode {
INCLUDE, EXCLUDE INCLUDE, EXCLUDE
} }
private final String keyword; private final String keyword;
private final Mode mode; private final Mode mode;
public static List<FeedEntryKeyword> fromQueryString(String keywords) { public static List<FeedEntryKeyword> fromQueryString(String keywords) {
List<FeedEntryKeyword> list = new ArrayList<>(); List<FeedEntryKeyword> list = new ArrayList<>();
if (keywords != null) { if (keywords != null) {
for (String keyword : StringUtils.split(keywords)) { for (String keyword : StringUtils.split(keywords)) {
boolean not = false; boolean not = false;
if (keyword.startsWith("-") || keyword.startsWith("!")) { if (keyword.startsWith("-") || keyword.startsWith("!")) {
not = true; not = true;
keyword = keyword.substring(1); keyword = keyword.substring(1);
} }
list.add(new FeedEntryKeyword(keyword, not ? Mode.EXCLUDE : Mode.INCLUDE)); list.add(new FeedEntryKeyword(keyword, not ? Mode.EXCLUDE : Mode.INCLUDE));
} }
} }
return list; return list;
} }
} }

View File

@@ -1,123 +1,123 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.commafeed.backend.Digests; import com.commafeed.backend.Digests;
import com.commafeed.backend.HttpGetter; import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HostNotAllowedException; import com.commafeed.backend.HttpGetter.HostNotAllowedException;
import com.commafeed.backend.HttpGetter.HttpRequest; import com.commafeed.backend.HttpGetter.HttpRequest;
import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.HttpGetter.NotModifiedException; import com.commafeed.backend.HttpGetter.NotModifiedException;
import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; import com.commafeed.backend.HttpGetter.SchemeNotAllowedException;
import com.commafeed.backend.HttpGetter.TooManyRequestsException; import com.commafeed.backend.HttpGetter.TooManyRequestsException;
import com.commafeed.backend.feed.parser.FeedParser; import com.commafeed.backend.feed.parser.FeedParser;
import com.commafeed.backend.feed.parser.FeedParser.FeedParsingException; import com.commafeed.backend.feed.parser.FeedParser.FeedParsingException;
import com.commafeed.backend.feed.parser.FeedParserResult; import com.commafeed.backend.feed.parser.FeedParserResult;
import com.commafeed.backend.urlprovider.FeedURLProvider; import com.commafeed.backend.urlprovider.FeedURLProvider;
import io.quarkus.arc.All; import io.quarkus.arc.All;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Fetches a feed then parses it * Fetches a feed then parses it
*/ */
@Slf4j @Slf4j
@Singleton @Singleton
public class FeedFetcher { public class FeedFetcher {
private final FeedParser parser; private final FeedParser parser;
private final HttpGetter getter; private final HttpGetter getter;
private final List<FeedURLProvider> urlProviders; private final List<FeedURLProvider> urlProviders;
public FeedFetcher(FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) { public FeedFetcher(FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
this.parser = parser; this.parser = parser;
this.getter = getter; this.getter = getter;
this.urlProviders = urlProviders; this.urlProviders = urlProviders;
} }
public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag, public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag,
Instant lastPublishedDate, String lastContentHash) throws FeedParsingException, IOException, NotModifiedException, Instant lastPublishedDate, String lastContentHash) throws FeedParsingException, IOException, NotModifiedException,
TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException, NoFeedFoundException { TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException, NoFeedFoundException {
log.debug("Fetching feed {}", feedUrl); log.debug("Fetching feed {}", feedUrl);
HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build()); HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
byte[] content = result.getContent(); byte[] content = result.getContent();
FeedParserResult parserResult; FeedParserResult parserResult;
try { try {
parserResult = parser.parse(result.getUrlAfterRedirect(), content); parserResult = parser.parse(result.getUrlAfterRedirect(), content);
} catch (FeedParsingException e) { } catch (FeedParsingException e) {
if (extractFeedUrlFromHtml) { if (extractFeedUrlFromHtml) {
String extractedUrl = extractFeedUrl(urlProviders, feedUrl, new String(result.getContent(), StandardCharsets.UTF_8)); String extractedUrl = extractFeedUrl(urlProviders, feedUrl, new String(result.getContent(), StandardCharsets.UTF_8));
if (StringUtils.isNotBlank(extractedUrl)) { if (StringUtils.isNotBlank(extractedUrl)) {
feedUrl = extractedUrl; feedUrl = extractedUrl;
result = getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build()); result = getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build());
content = result.getContent(); content = result.getContent();
parserResult = parser.parse(result.getUrlAfterRedirect(), content); parserResult = parser.parse(result.getUrlAfterRedirect(), content);
} else { } else {
throw new NoFeedFoundException(e); throw new NoFeedFoundException(e);
} }
} else { } else {
throw e; throw e;
} }
} }
if (content == null) { if (content == null) {
throw new IOException("Feed content is empty."); throw new IOException("Feed content is empty.");
} }
boolean lastModifiedHeaderValueChanged = !StringUtils.equals(lastModified, result.getLastModifiedSince()); boolean lastModifiedHeaderValueChanged = !StringUtils.equals(lastModified, result.getLastModifiedSince());
boolean etagHeaderValueChanged = !StringUtils.equals(eTag, result.getETag()); boolean etagHeaderValueChanged = !StringUtils.equals(eTag, result.getETag());
String hash = Digests.sha1Hex(content); String hash = Digests.sha1Hex(content);
if (lastContentHash != null && lastContentHash.equals(hash)) { if (lastContentHash != null && lastContentHash.equals(hash)) {
log.debug("content hash not modified: {}", feedUrl); log.debug("content hash not modified: {}", feedUrl);
throw new NotModifiedException("content hash not modified", throw new NotModifiedException("content hash not modified",
lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null, lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null,
etagHeaderValueChanged ? result.getETag() : null); etagHeaderValueChanged ? result.getETag() : null);
} }
if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) { if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) {
log.debug("publishedDate not modified: {}", feedUrl); log.debug("publishedDate not modified: {}", feedUrl);
throw new NotModifiedException("publishedDate not modified", throw new NotModifiedException("publishedDate not modified",
lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null, lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null,
etagHeaderValueChanged ? result.getETag() : null); etagHeaderValueChanged ? result.getETag() : null);
} }
return new FeedFetcherResult(parserResult, result.getUrlAfterRedirect(), result.getLastModifiedSince(), result.getETag(), hash, return new FeedFetcherResult(parserResult, result.getUrlAfterRedirect(), result.getLastModifiedSince(), result.getETag(), hash,
result.getValidFor()); result.getValidFor());
} }
private static String extractFeedUrl(List<FeedURLProvider> urlProviders, String url, String urlContent) { private static String extractFeedUrl(List<FeedURLProvider> urlProviders, String url, String urlContent) {
for (FeedURLProvider urlProvider : urlProviders) { for (FeedURLProvider urlProvider : urlProviders) {
String feedUrl = urlProvider.get(url, urlContent); String feedUrl = urlProvider.get(url, urlContent);
if (feedUrl != null) { if (feedUrl != null) {
return feedUrl; return feedUrl;
} }
} }
return null; return null;
} }
public record FeedFetcherResult(FeedParserResult feed, String urlAfterRedirect, String lastModifiedHeader, String lastETagHeader, public record FeedFetcherResult(FeedParserResult feed, String urlAfterRedirect, String lastModifiedHeader, String lastETagHeader,
String contentHash, Duration validFor) { String contentHash, Duration validFor) {
} }
public static class NoFeedFoundException extends Exception { public static class NoFeedFoundException extends Exception {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
public NoFeedFoundException(Throwable cause) { public NoFeedFoundException(Throwable cause) {
super("This URL does not point to an RSS feed or a website with an RSS feed.", cause); super("This URL does not point to an RSS feed or a website with an RSS feed.", cause);
} }
} }
} }

View File

@@ -1,214 +1,214 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.concurrent.BlockingDeque; import java.util.concurrent.BlockingDeque;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.SynchronousQueue; import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.codahale.metrics.Gauge; import com.codahale.metrics.Gauge;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedDAO; import com.commafeed.backend.dao.FeedDAO;
import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@Singleton @Singleton
public class FeedRefreshEngine { public class FeedRefreshEngine {
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
private final FeedDAO feedDAO; private final FeedDAO feedDAO;
private final FeedRefreshWorker worker; private final FeedRefreshWorker worker;
private final FeedRefreshUpdater updater; private final FeedRefreshUpdater updater;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final Meter refill; private final Meter refill;
private final BlockingDeque<Feed> queue; private final BlockingDeque<Feed> queue;
private final ExecutorService feedProcessingLoopExecutor; private final ExecutorService feedProcessingLoopExecutor;
private final ExecutorService refillLoopExecutor; private final ExecutorService refillLoopExecutor;
private final ExecutorService refillExecutor; private final ExecutorService refillExecutor;
private final ThreadPoolExecutor workerExecutor; private final ThreadPoolExecutor workerExecutor;
private final ThreadPoolExecutor databaseUpdaterExecutor; private final ThreadPoolExecutor databaseUpdaterExecutor;
public FeedRefreshEngine(UnitOfWork unitOfWork, FeedDAO feedDAO, FeedRefreshWorker worker, FeedRefreshUpdater updater, public FeedRefreshEngine(UnitOfWork unitOfWork, FeedDAO feedDAO, FeedRefreshWorker worker, FeedRefreshUpdater updater,
CommaFeedConfiguration config, MetricRegistry metrics) { CommaFeedConfiguration config, MetricRegistry metrics) {
this.unitOfWork = unitOfWork; this.unitOfWork = unitOfWork;
this.feedDAO = feedDAO; this.feedDAO = feedDAO;
this.worker = worker; this.worker = worker;
this.updater = updater; this.updater = updater;
this.config = config; this.config = config;
this.refill = metrics.meter(MetricRegistry.name(getClass(), "refill")); this.refill = metrics.meter(MetricRegistry.name(getClass(), "refill"));
this.queue = new LinkedBlockingDeque<>(); this.queue = new LinkedBlockingDeque<>();
this.feedProcessingLoopExecutor = Executors.newSingleThreadExecutor(); this.feedProcessingLoopExecutor = Executors.newSingleThreadExecutor();
this.refillLoopExecutor = Executors.newSingleThreadExecutor(); this.refillLoopExecutor = Executors.newSingleThreadExecutor();
this.refillExecutor = newDiscardingSingleThreadExecutorService(); this.refillExecutor = newDiscardingSingleThreadExecutorService();
this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads()); this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads());
this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads()); this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads());
metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size); metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(MetricRegistry.name(getClass(), "worker", "active"), (Gauge<Integer>) workerExecutor::getActiveCount); metrics.register(MetricRegistry.name(getClass(), "worker", "active"), (Gauge<Integer>) workerExecutor::getActiveCount);
metrics.register(MetricRegistry.name(getClass(), "updater", "active"), (Gauge<Integer>) databaseUpdaterExecutor::getActiveCount); metrics.register(MetricRegistry.name(getClass(), "updater", "active"), (Gauge<Integer>) databaseUpdaterExecutor::getActiveCount);
} }
public void start() { public void start() {
startFeedProcessingLoop(); startFeedProcessingLoop();
startRefillLoop(); startRefillLoop();
} }
private void startFeedProcessingLoop() { private void startFeedProcessingLoop() {
// take a feed from the queue, process it, rince, repeat // take a feed from the queue, process it, rince, repeat
feedProcessingLoopExecutor.submit(() -> { feedProcessingLoopExecutor.submit(() -> {
while (!feedProcessingLoopExecutor.isShutdown()) { while (!feedProcessingLoopExecutor.isShutdown()) {
try { try {
// take() is blocking until a feed is available from the queue // take() is blocking until a feed is available from the queue
Feed feed = queue.take(); Feed feed = queue.take();
// send the feed to be processed // send the feed to be processed
log.debug("got feed {} from the queue, send it for processing", feed.getId()); log.debug("got feed {} from the queue, send it for processing", feed.getId());
processFeedAsync(feed); processFeedAsync(feed);
// we removed a feed from the queue, try to refill it as it may now be empty // we removed a feed from the queue, try to refill it as it may now be empty
if (queue.isEmpty()) { if (queue.isEmpty()) {
log.debug("took the last feed from the queue, try to refill"); log.debug("took the last feed from the queue, try to refill");
refillQueueAsync(); refillQueueAsync();
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.debug("interrupted while waiting for a feed in the queue"); log.debug("interrupted while waiting for a feed in the queue");
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
} }
}); });
} }
private void startRefillLoop() { private void startRefillLoop() {
// refill the queue at regular intervals if it's empty // refill the queue at regular intervals if it's empty
refillLoopExecutor.submit(() -> { refillLoopExecutor.submit(() -> {
while (!refillLoopExecutor.isShutdown()) { while (!refillLoopExecutor.isShutdown()) {
try { try {
if (queue.isEmpty()) { if (queue.isEmpty()) {
log.debug("refilling queue"); log.debug("refilling queue");
refillQueueAsync(); refillQueueAsync();
} }
log.debug("sleeping for 15s"); log.debug("sleeping for 15s");
TimeUnit.SECONDS.sleep(15); TimeUnit.SECONDS.sleep(15);
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.debug("interrupted while sleeping"); log.debug("interrupted while sleeping");
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
} }
}); });
} }
public void refreshImmediately(Feed feed) { public void refreshImmediately(Feed feed) {
log.debug("add feed {} at the start of the queue", feed.getId()); log.debug("add feed {} at the start of the queue", feed.getId());
// remove the feed from the queue if it was already queued to avoid refreshing it twice // remove the feed from the queue if it was already queued to avoid refreshing it twice
queue.removeIf(f -> f.getId().equals(feed.getId())); queue.removeIf(f -> f.getId().equals(feed.getId()));
queue.addFirst(feed); queue.addFirst(feed);
} }
private void refillQueueAsync() { private void refillQueueAsync() {
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
if (!queue.isEmpty()) { if (!queue.isEmpty()) {
return; return;
} }
refill.mark(); refill.mark();
List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize()); List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize());
log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size()); log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size());
for (Feed feed : nextUpdatableFeeds) { for (Feed feed : nextUpdatableFeeds) {
// add the feed only if it was not already queued // add the feed only if it was not already queued
if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) { if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) {
queue.addLast(feed); queue.addLast(feed);
} }
} }
}, refillExecutor).whenComplete((data, ex) -> { }, refillExecutor).whenComplete((data, ex) -> {
if (ex != null) { if (ex != null) {
log.error("error while refilling the queue", ex); log.error("error while refilling the queue", ex);
} }
}); });
} }
private void processFeedAsync(Feed feed) { private void processFeedAsync(Feed feed) {
CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor) CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor)
.thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor) .thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor)
.whenComplete((data, ex) -> { .whenComplete((data, ex) -> {
if (ex != null) { if (ex != null) {
log.error("error while processing feed {}", feed.getUrl(), ex); log.error("error while processing feed {}", feed.getUrl(), ex);
} }
}); });
} }
private List<Feed> getNextUpdatableFeeds(int max) { private List<Feed> getNextUpdatableFeeds(int max) {
return unitOfWork.call(() -> { return unitOfWork.call(() -> {
Instant lastLoginThreshold = config.feedRefresh().userInactivityPeriod().isZero() ? null Instant lastLoginThreshold = config.feedRefresh().userInactivityPeriod().isZero() ? null
: Instant.now().minus(config.feedRefresh().userInactivityPeriod()); : Instant.now().minus(config.feedRefresh().userInactivityPeriod());
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold); List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
// update disabledUntil to prevent feeds from being returned again by feedDAO.findNextUpdatable() // update disabledUntil to prevent feeds from being returned again by feedDAO.findNextUpdatable()
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval()); Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate); feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
return feeds; return feeds;
}); });
} }
private int getBatchSize() { private int getBatchSize() {
return Math.min(100, 3 * config.feedRefresh().httpThreads()); return Math.min(100, 3 * config.feedRefresh().httpThreads());
} }
public void stop() { public void stop() {
this.feedProcessingLoopExecutor.shutdownNow(); this.feedProcessingLoopExecutor.shutdownNow();
this.refillLoopExecutor.shutdownNow(); this.refillLoopExecutor.shutdownNow();
this.refillExecutor.shutdownNow(); this.refillExecutor.shutdownNow();
this.workerExecutor.shutdownNow(); this.workerExecutor.shutdownNow();
this.databaseUpdaterExecutor.shutdownNow(); this.databaseUpdaterExecutor.shutdownNow();
} }
/** /**
* returns an ExecutorService with a single thread that discards tasks if a task is already running * returns an ExecutorService with a single thread that discards tasks if a task is already running
*/ */
private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() { private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() {
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return pool; return pool;
} }
/** /**
* returns an ExecutorService that blocks submissions until a thread is available * returns an ExecutorService that blocks submissions until a thread is available
*/ */
private ThreadPoolExecutor newBlockingExecutorService(int threads) { private ThreadPoolExecutor newBlockingExecutorService(int threads) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler((r, e) -> { pool.setRejectedExecutionHandler((r, e) -> {
if (e.isShutdown()) { if (e.isShutdown()) {
return; return;
} }
try { try {
e.getQueue().put(r); e.getQueue().put(r);
} catch (InterruptedException ex) { } catch (InterruptedException ex) {
log.debug("interrupted while waiting for a slot in the queue.", ex); log.debug("interrupted while waiting for a slot in the queue.", ex);
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
}); });
return pool; return pool;
} }
} }

View File

@@ -1,84 +1,84 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.InstantSource; import java.time.InstantSource;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.ObjectUtils;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling; import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling;
import com.google.common.primitives.Longs; import com.google.common.primitives.Longs;
@Singleton @Singleton
public class FeedRefreshIntervalCalculator { public class FeedRefreshIntervalCalculator {
private final Duration interval; private final Duration interval;
private final Duration maxInterval; private final Duration maxInterval;
private final boolean empirical; private final boolean empirical;
private final FeedRefreshErrorHandling errorHandling; private final FeedRefreshErrorHandling errorHandling;
private final InstantSource instantSource; private final InstantSource instantSource;
public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) { public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) {
this.interval = config.feedRefresh().interval(); this.interval = config.feedRefresh().interval();
this.maxInterval = config.feedRefresh().maxInterval(); this.maxInterval = config.feedRefresh().maxInterval();
this.empirical = config.feedRefresh().intervalEmpirical(); this.empirical = config.feedRefresh().intervalEmpirical();
this.errorHandling = config.feedRefresh().errors(); this.errorHandling = config.feedRefresh().errors();
this.instantSource = instantSource; this.instantSource = instantSource;
} }
public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) { public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) {
Instant instant = empirical ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval) Instant instant = empirical ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval)
: instantSource.instant().plus(interval); : instantSource.instant().plus(interval);
return constrainToBounds(ObjectUtils.max(instant, instantSource.instant().plus(validFor))); return constrainToBounds(ObjectUtils.max(instant, instantSource.instant().plus(validFor)));
} }
public Instant onFeedNotModified(Instant publishedDate, Long averageEntryInterval) { public Instant onFeedNotModified(Instant publishedDate, Long averageEntryInterval) {
return onFetchSuccess(publishedDate, averageEntryInterval, Duration.ZERO); return onFetchSuccess(publishedDate, averageEntryInterval, Duration.ZERO);
} }
public Instant onTooManyRequests(Instant retryAfter, int errorCount) { public Instant onTooManyRequests(Instant retryAfter, int errorCount) {
return constrainToBounds(ObjectUtils.max(retryAfter, onFetchError(errorCount))); return constrainToBounds(ObjectUtils.max(retryAfter, onFetchError(errorCount)));
} }
public Instant onFetchError(int errorCount) { public Instant onFetchError(int errorCount) {
if (errorCount < errorHandling.retriesBeforeBackoff()) { if (errorCount < errorHandling.retriesBeforeBackoff()) {
return constrainToBounds(instantSource.instant().plus(interval)); return constrainToBounds(instantSource.instant().plus(interval));
} }
Duration retryInterval = errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L); Duration retryInterval = errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
return constrainToBounds(instantSource.instant().plus(retryInterval)); return constrainToBounds(instantSource.instant().plus(retryInterval));
} }
private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) { private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) {
Instant now = instantSource.instant(); Instant now = instantSource.instant();
if (publishedDate == null) { if (publishedDate == null) {
return now.plus(maxInterval); return now.plus(maxInterval);
} }
long daysSinceLastPublication = ChronoUnit.DAYS.between(publishedDate, now); long daysSinceLastPublication = ChronoUnit.DAYS.between(publishedDate, now);
if (daysSinceLastPublication >= 30) { if (daysSinceLastPublication >= 30) {
return now.plus(maxInterval); return now.plus(maxInterval);
} else if (daysSinceLastPublication >= 14) { } else if (daysSinceLastPublication >= 14) {
return now.plus(maxInterval.dividedBy(2)); return now.plus(maxInterval.dividedBy(2));
} else if (daysSinceLastPublication >= 7) { } else if (daysSinceLastPublication >= 7) {
return now.plus(maxInterval.dividedBy(4)); return now.plus(maxInterval.dividedBy(4));
} else if (averageEntryInterval != null) { } else if (averageEntryInterval != null) {
// use average time between entries to decide when to refresh next, divided by factor // use average time between entries to decide when to refresh next, divided by factor
int factor = 2; int factor = 2;
long millis = Longs.constrainToRange(averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis()); long millis = Longs.constrainToRange(averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis());
return now.plusMillis(millis); return now.plusMillis(millis);
} else { } else {
// unknown case // unknown case
return now.plus(maxInterval); return now.plus(maxInterval);
} }
} }
private Instant constrainToBounds(Instant instant) { private Instant constrainToBounds(Instant instant) {
return ObjectUtils.max(ObjectUtils.min(instant, instantSource.instant().plus(maxInterval)), instantSource.instant().plus(interval)); return ObjectUtils.max(ObjectUtils.min(instant, instantSource.instant().plus(maxInterval)), instantSource.instant().plus(interval));
} }
} }

View File

@@ -1,180 +1,180 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Lock;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.commafeed.backend.Digests; import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.FeedSubscriptionDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.feed.parser.FeedParserResult.Content; import com.commafeed.backend.feed.parser.FeedParserResult.Content;
import com.commafeed.backend.feed.parser.FeedParserResult.Entry; import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.Models; import com.commafeed.backend.model.Models;
import com.commafeed.backend.service.FeedEntryService; import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedService; import com.commafeed.backend.service.FeedService;
import com.commafeed.frontend.ws.WebSocketMessageBuilder; import com.commafeed.frontend.ws.WebSocketMessageBuilder;
import com.commafeed.frontend.ws.WebSocketSessions; import com.commafeed.frontend.ws.WebSocketSessions;
import com.google.common.util.concurrent.Striped; import com.google.common.util.concurrent.Striped;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Updates the feed in the database and inserts new entries * Updates the feed in the database and inserts new entries
*/ */
@Slf4j @Slf4j
@Singleton @Singleton
public class FeedRefreshUpdater { public class FeedRefreshUpdater {
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
private final FeedService feedService; private final FeedService feedService;
private final FeedEntryService feedEntryService; private final FeedEntryService feedEntryService;
private final FeedSubscriptionDAO feedSubscriptionDAO; private final FeedSubscriptionDAO feedSubscriptionDAO;
private final WebSocketSessions webSocketSessions; private final WebSocketSessions webSocketSessions;
private final Striped<Lock> locks; private final Striped<Lock> locks;
private final Meter feedUpdated; private final Meter feedUpdated;
private final Meter entryInserted; private final Meter entryInserted;
public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics, public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics,
FeedSubscriptionDAO feedSubscriptionDAO, WebSocketSessions webSocketSessions) { FeedSubscriptionDAO feedSubscriptionDAO, WebSocketSessions webSocketSessions) {
this.unitOfWork = unitOfWork; this.unitOfWork = unitOfWork;
this.feedService = feedService; this.feedService = feedService;
this.feedEntryService = feedEntryService; this.feedEntryService = feedEntryService;
this.feedSubscriptionDAO = feedSubscriptionDAO; this.feedSubscriptionDAO = feedSubscriptionDAO;
this.webSocketSessions = webSocketSessions; this.webSocketSessions = webSocketSessions;
locks = Striped.lazyWeakLock(100000); locks = Striped.lazyWeakLock(100000);
feedUpdated = metrics.meter(MetricRegistry.name(getClass(), "feedUpdated")); feedUpdated = metrics.meter(MetricRegistry.name(getClass(), "feedUpdated"));
entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted")); entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted"));
} }
private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) { private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
boolean processed = false; boolean processed = false;
boolean inserted = false; boolean inserted = false;
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>(); Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
// lock on feed, make sure we are not updating the same feed twice at // lock on feed, make sure we are not updating the same feed twice at
// the same time // the same time
String key1 = StringUtils.trimToEmpty(String.valueOf(feed.getId())); String key1 = StringUtils.trimToEmpty(String.valueOf(feed.getId()));
// lock on content, make sure we are not updating the same entry // lock on content, make sure we are not updating the same entry
// twice at the same time // twice at the same time
Content content = entry.content(); Content content = entry.content();
String key2 = Digests.sha1Hex(StringUtils.trimToEmpty(content.content() + content.title())); String key2 = Digests.sha1Hex(StringUtils.trimToEmpty(content.content() + content.title()));
Iterator<Lock> iterator = locks.bulkGet(Arrays.asList(key1, key2)).iterator(); Iterator<Lock> iterator = locks.bulkGet(Arrays.asList(key1, key2)).iterator();
Lock lock1 = iterator.next(); Lock lock1 = iterator.next();
Lock lock2 = iterator.next(); Lock lock2 = iterator.next();
boolean locked1 = false; boolean locked1 = false;
boolean locked2 = false; boolean locked2 = false;
try { try {
// try to lock, give up after 1 minute // try to lock, give up after 1 minute
locked1 = lock1.tryLock(1, TimeUnit.MINUTES); locked1 = lock1.tryLock(1, TimeUnit.MINUTES);
locked2 = lock2.tryLock(1, TimeUnit.MINUTES); locked2 = lock2.tryLock(1, TimeUnit.MINUTES);
if (locked1 && locked2) { if (locked1 && locked2) {
processed = true; processed = true;
inserted = unitOfWork.call(() -> { inserted = unitOfWork.call(() -> {
boolean newEntry = false; boolean newEntry = false;
FeedEntry feedEntry = feedEntryService.find(feed, entry); FeedEntry feedEntry = feedEntryService.find(feed, entry);
if (feedEntry == null) { if (feedEntry == null) {
feedEntry = feedEntryService.create(feed, entry); feedEntry = feedEntryService.create(feed, entry);
newEntry = true; newEntry = true;
} }
if (newEntry) { if (newEntry) {
entryInserted.mark(); entryInserted.mark();
for (FeedSubscription sub : subscriptions) { for (FeedSubscription sub : subscriptions) {
boolean unread = feedEntryService.applyFilter(sub, feedEntry); boolean unread = feedEntryService.applyFilter(sub, feedEntry);
if (unread) { if (unread) {
subscriptionsForWhichEntryIsUnread.add(sub); subscriptionsForWhichEntryIsUnread.add(sub);
} }
} }
} }
return newEntry; return newEntry;
}); });
} else { } else {
log.error("lock timeout for {} - {}", feed.getUrl(), key1); log.error("lock timeout for {} - {}", feed.getUrl(), key1);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e); log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e);
} finally { } finally {
if (locked1) { if (locked1) {
lock1.unlock(); lock1.unlock();
} }
if (locked2) { if (locked2) {
lock2.unlock(); lock2.unlock();
} }
} }
return new AddEntryResult(processed, inserted, subscriptionsForWhichEntryIsUnread); return new AddEntryResult(processed, inserted, subscriptionsForWhichEntryIsUnread);
} }
public boolean update(Feed feed, List<Entry> entries) { public boolean update(Feed feed, List<Entry> entries) {
boolean processed = true; boolean processed = true;
long inserted = 0; long inserted = 0;
Map<FeedSubscription, Long> unreadCountBySubscription = new HashMap<>(); Map<FeedSubscription, Long> unreadCountBySubscription = new HashMap<>();
if (!entries.isEmpty()) { if (!entries.isEmpty()) {
List<FeedSubscription> subscriptions = null; List<FeedSubscription> subscriptions = null;
for (Entry entry : entries) { for (Entry entry : entries) {
if (subscriptions == null) { if (subscriptions == null) {
subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed)); subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed));
} }
AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions); AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions);
processed &= addEntryResult.processed; processed &= addEntryResult.processed;
inserted += addEntryResult.inserted ? 1 : 0; inserted += addEntryResult.inserted ? 1 : 0;
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> unreadCountBySubscription.merge(sub, 1L, Long::sum)); addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> unreadCountBySubscription.merge(sub, 1L, Long::sum));
} }
if (inserted == 0) { if (inserted == 0) {
feed.setMessage("No new entries found"); feed.setMessage("No new entries found");
} else if (inserted > 0) { } else if (inserted > 0) {
feed.setMessage("Found %s new entries".formatted(inserted)); feed.setMessage("Found %s new entries".formatted(inserted));
} }
} }
if (!processed) { if (!processed) {
// requeue asap // requeue asap
feed.setDisabledUntil(Models.MINIMUM_INSTANT); feed.setDisabledUntil(Models.MINIMUM_INSTANT);
} }
if (inserted > 0) { if (inserted > 0) {
feedUpdated.mark(); feedUpdated.mark();
} }
unitOfWork.run(() -> feedService.update(feed)); unitOfWork.run(() -> feedService.update(feed));
notifyOverWebsocket(unreadCountBySubscription); notifyOverWebsocket(unreadCountBySubscription);
return processed; return processed;
} }
private void notifyOverWebsocket(Map<FeedSubscription, Long> unreadCountBySubscription) { private void notifyOverWebsocket(Map<FeedSubscription, Long> unreadCountBySubscription) {
unreadCountBySubscription.forEach((sub, unreadCount) -> webSocketSessions.sendMessage(sub.getUser(), unreadCountBySubscription.forEach((sub, unreadCount) -> webSocketSessions.sendMessage(sub.getUser(),
WebSocketMessageBuilder.newFeedEntries(sub, unreadCount))); WebSocketMessageBuilder.newFeedEntries(sub, unreadCount)));
} }
@AllArgsConstructor @AllArgsConstructor
private static class AddEntryResult { private static class AddEntryResult {
private final boolean processed; private final boolean processed;
private final boolean inserted; private final boolean inserted;
private final Set<FeedSubscription> subscriptionsForWhichEntryIsUnread; private final Set<FeedSubscription> subscriptionsForWhichEntryIsUnread;
} }
} }

View File

@@ -1,125 +1,125 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.HttpGetter.NotModifiedException; import com.commafeed.backend.HttpGetter.NotModifiedException;
import com.commafeed.backend.HttpGetter.TooManyRequestsException; import com.commafeed.backend.HttpGetter.TooManyRequestsException;
import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult; import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult;
import com.commafeed.backend.feed.parser.FeedParserResult.Entry; import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater} does that) * Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater} does that)
*/ */
@Slf4j @Slf4j
@Singleton @Singleton
public class FeedRefreshWorker { public class FeedRefreshWorker {
private final FeedRefreshIntervalCalculator refreshIntervalCalculator; private final FeedRefreshIntervalCalculator refreshIntervalCalculator;
private final FeedFetcher fetcher; private final FeedFetcher fetcher;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final Meter feedFetched; private final Meter feedFetched;
public FeedRefreshWorker(FeedRefreshIntervalCalculator refreshIntervalCalculator, FeedFetcher fetcher, CommaFeedConfiguration config, public FeedRefreshWorker(FeedRefreshIntervalCalculator refreshIntervalCalculator, FeedFetcher fetcher, CommaFeedConfiguration config,
MetricRegistry metrics) { MetricRegistry metrics) {
this.refreshIntervalCalculator = refreshIntervalCalculator; this.refreshIntervalCalculator = refreshIntervalCalculator;
this.fetcher = fetcher; this.fetcher = fetcher;
this.config = config; this.config = config;
this.feedFetched = metrics.meter(MetricRegistry.name(getClass(), "feedFetched")); this.feedFetched = metrics.meter(MetricRegistry.name(getClass(), "feedFetched"));
} }
public FeedRefreshWorkerResult update(Feed feed) { public FeedRefreshWorkerResult update(Feed feed) {
try { try {
String url = Optional.ofNullable(feed.getUrlAfterRedirect()).orElse(feed.getUrl()); String url = Optional.ofNullable(feed.getUrlAfterRedirect()).orElse(feed.getUrl());
FeedFetcherResult result = fetcher.fetch(url, false, feed.getLastModifiedHeader(), feed.getEtagHeader(), FeedFetcherResult result = fetcher.fetch(url, false, feed.getLastModifiedHeader(), feed.getEtagHeader(),
feed.getLastPublishedDate(), feed.getLastContentHash()); feed.getLastPublishedDate(), feed.getLastContentHash());
// stops here if NotModifiedException or any other exception is thrown // stops here if NotModifiedException or any other exception is thrown
List<Entry> entries = result.feed().entries(); List<Entry> entries = result.feed().entries();
int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); int maxFeedCapacity = config.database().cleanup().maxFeedCapacity();
if (maxFeedCapacity > 0) { if (maxFeedCapacity > 0) {
entries = entries.stream().limit(maxFeedCapacity).toList(); entries = entries.stream().limit(maxFeedCapacity).toList();
} }
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
if (!entriesMaxAge.isZero()) { if (!entriesMaxAge.isZero()) {
Instant threshold = Instant.now().minus(entriesMaxAge); Instant threshold = Instant.now().minus(entriesMaxAge);
entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList(); entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
} }
String urlAfterRedirect = result.urlAfterRedirect(); String urlAfterRedirect = result.urlAfterRedirect();
if (StringUtils.equals(url, urlAfterRedirect)) { if (StringUtils.equals(url, urlAfterRedirect)) {
urlAfterRedirect = null; urlAfterRedirect = null;
} }
feed.setUrlAfterRedirect(urlAfterRedirect); feed.setUrlAfterRedirect(urlAfterRedirect);
feed.setLink(result.feed().link()); feed.setLink(result.feed().link());
feed.setLastModifiedHeader(result.lastModifiedHeader()); feed.setLastModifiedHeader(result.lastModifiedHeader());
feed.setEtagHeader(result.lastETagHeader()); feed.setEtagHeader(result.lastETagHeader());
feed.setLastContentHash(result.contentHash()); feed.setLastContentHash(result.contentHash());
feed.setLastPublishedDate(result.feed().lastPublishedDate()); feed.setLastPublishedDate(result.feed().lastPublishedDate());
feed.setAverageEntryInterval(result.feed().averageEntryInterval()); feed.setAverageEntryInterval(result.feed().averageEntryInterval());
feed.setLastEntryDate(result.feed().lastEntryDate()); feed.setLastEntryDate(result.feed().lastEntryDate());
feed.setErrorCount(0); feed.setErrorCount(0);
feed.setMessage(null); feed.setMessage(null);
feed.setDisabledUntil(refreshIntervalCalculator.onFetchSuccess(result.feed().lastPublishedDate(), feed.setDisabledUntil(refreshIntervalCalculator.onFetchSuccess(result.feed().lastPublishedDate(),
result.feed().averageEntryInterval(), result.validFor())); result.feed().averageEntryInterval(), result.validFor()));
return new FeedRefreshWorkerResult(feed, entries); return new FeedRefreshWorkerResult(feed, entries);
} catch (NotModifiedException e) { } catch (NotModifiedException e) {
log.debug("Feed not modified : {} - {}", feed.getUrl(), e.getMessage()); log.debug("Feed not modified : {} - {}", feed.getUrl(), e.getMessage());
feed.setErrorCount(0); feed.setErrorCount(0);
feed.setMessage(e.getMessage()); feed.setMessage(e.getMessage());
feed.setDisabledUntil(refreshIntervalCalculator.onFeedNotModified(feed.getLastPublishedDate(), feed.getAverageEntryInterval())); feed.setDisabledUntil(refreshIntervalCalculator.onFeedNotModified(feed.getLastPublishedDate(), feed.getAverageEntryInterval()));
if (e.getNewLastModifiedHeader() != null) { if (e.getNewLastModifiedHeader() != null) {
feed.setLastModifiedHeader(e.getNewLastModifiedHeader()); feed.setLastModifiedHeader(e.getNewLastModifiedHeader());
} }
if (e.getNewEtagHeader() != null) { if (e.getNewEtagHeader() != null) {
feed.setEtagHeader(e.getNewEtagHeader()); feed.setEtagHeader(e.getNewEtagHeader());
} }
return new FeedRefreshWorkerResult(feed, Collections.emptyList()); return new FeedRefreshWorkerResult(feed, Collections.emptyList());
} catch (TooManyRequestsException e) { } catch (TooManyRequestsException e) {
log.debug("Too many requests : {}", feed.getUrl()); log.debug("Too many requests : {}", feed.getUrl());
feed.setErrorCount(feed.getErrorCount() + 1); feed.setErrorCount(feed.getErrorCount() + 1);
feed.setMessage("Server indicated that we are sending too many requests"); feed.setMessage("Server indicated that we are sending too many requests");
feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount())); feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount()));
return new FeedRefreshWorkerResult(feed, Collections.emptyList()); return new FeedRefreshWorkerResult(feed, Collections.emptyList());
} catch (Exception e) { } catch (Exception e) {
log.debug("unable to refresh feed {}", feed.getUrl(), e); log.debug("unable to refresh feed {}", feed.getUrl(), e);
feed.setErrorCount(feed.getErrorCount() + 1); feed.setErrorCount(feed.getErrorCount() + 1);
feed.setMessage("Unable to refresh feed : " + e.getMessage()); feed.setMessage("Unable to refresh feed : " + e.getMessage());
feed.setDisabledUntil(refreshIntervalCalculator.onFetchError(feed.getErrorCount())); feed.setDisabledUntil(refreshIntervalCalculator.onFetchError(feed.getErrorCount()));
return new FeedRefreshWorkerResult(feed, Collections.emptyList()); return new FeedRefreshWorkerResult(feed, Collections.emptyList());
} finally { } finally {
feedFetched.mark(); feedFetched.mark();
} }
} }
public record FeedRefreshWorkerResult(Feed feed, List<Entry> entries) { public record FeedRefreshWorkerResult(Feed feed, List<Entry> entries) {
} }
} }

View File

@@ -1,218 +1,218 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URL; import java.net.URL;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.utils.Base64; import org.apache.hc.client5.http.utils.Base64;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import org.netpreserve.urlcanon.Canonicalizer; import org.netpreserve.urlcanon.Canonicalizer;
import org.netpreserve.urlcanon.ParsedUrl; import org.netpreserve.urlcanon.ParsedUrl;
import com.commafeed.backend.feed.FeedEntryKeyword.Mode; import com.commafeed.backend.feed.FeedEntryKeyword.Mode;
import com.commafeed.backend.feed.parser.TextDirectionDetector; import com.commafeed.backend.feed.parser.TextDirectionDetector;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.frontend.model.Entry; import com.commafeed.frontend.model.Entry;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Utility methods related to feed handling * Utility methods related to feed handling
* *
*/ */
@Slf4j @Slf4j
public class FeedUtils { public class FeedUtils {
private static final String ESCAPED_QUESTION_MARK = Pattern.quote("?"); private static final String ESCAPED_QUESTION_MARK = Pattern.quote("?");
public static String truncate(String string, int length) { public static String truncate(String string, int length) {
if (string != null) { if (string != null) {
string = string.substring(0, Math.min(length, string.length())); string = string.substring(0, Math.min(length, string.length()));
} }
return string; return string;
} }
public static boolean isHttp(String url) { public static boolean isHttp(String url) {
return url.startsWith("http://"); return url.startsWith("http://");
} }
public static boolean isHttps(String url) { public static boolean isHttps(String url) {
return url.startsWith("https://"); return url.startsWith("https://");
} }
public static boolean isAbsoluteUrl(String url) { public static boolean isAbsoluteUrl(String url) {
return isHttp(url) || isHttps(url); return isHttp(url) || isHttps(url);
} }
/** /**
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to identify a feed and avoid duplicates * Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to identify a feed and avoid duplicates
*/ */
public static String normalizeURL(String url) { public static String normalizeURL(String url) {
if (url == null) { if (url == null) {
return null; return null;
} }
ParsedUrl parsedUrl = ParsedUrl.parseUrl(url); ParsedUrl parsedUrl = ParsedUrl.parseUrl(url);
Canonicalizer.AGGRESSIVE.canonicalize(parsedUrl); Canonicalizer.AGGRESSIVE.canonicalize(parsedUrl);
String normalized = parsedUrl.toString(); String normalized = parsedUrl.toString();
if (normalized == null) { if (normalized == null) {
normalized = url; normalized = url;
} }
// convert to lower case, the url probably won't work in some cases // convert to lower case, the url probably won't work in some cases
// after that but we don't care we just want to compare urls to avoid // after that but we don't care we just want to compare urls to avoid
// duplicates // duplicates
normalized = normalized.toLowerCase(); normalized = normalized.toLowerCase();
// store all urls as http // store all urls as http
if (normalized.startsWith("https")) { if (normalized.startsWith("https")) {
normalized = "http" + normalized.substring(5); normalized = "http" + normalized.substring(5);
} }
// remove the www. part // remove the www. part
normalized = normalized.replace("//www.", "//"); normalized = normalized.replace("//www.", "//");
// feedproxy redirects to feedburner // feedproxy redirects to feedburner
normalized = normalized.replace("feedproxy.google.com", "feeds.feedburner.com"); normalized = normalized.replace("feedproxy.google.com", "feeds.feedburner.com");
// feedburner feeds have a special treatment // feedburner feeds have a special treatment
if (normalized.split(ESCAPED_QUESTION_MARK)[0].contains("feedburner.com")) { if (normalized.split(ESCAPED_QUESTION_MARK)[0].contains("feedburner.com")) {
normalized = normalized.replace("feeds2.feedburner.com", "feeds.feedburner.com"); normalized = normalized.replace("feeds2.feedburner.com", "feeds.feedburner.com");
normalized = normalized.split(ESCAPED_QUESTION_MARK)[0]; normalized = normalized.split(ESCAPED_QUESTION_MARK)[0];
normalized = StringUtils.removeEnd(normalized, "/"); normalized = StringUtils.removeEnd(normalized, "/");
} }
return normalized; return normalized;
} }
public static boolean isRTL(String title, String content) { public static boolean isRTL(String title, String content) {
String text = StringUtils.isNotBlank(content) ? content : title; String text = StringUtils.isNotBlank(content) ? content : title;
if (StringUtils.isBlank(text)) { if (StringUtils.isBlank(text)) {
return false; return false;
} }
String stripped = Jsoup.parse(text).text(); String stripped = Jsoup.parse(text).text();
if (StringUtils.isBlank(stripped)) { if (StringUtils.isBlank(stripped)) {
return false; return false;
} }
return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT; return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT;
} }
public static String removeTrailingSlash(String url) { public static String removeTrailingSlash(String url) {
if (url.endsWith("/")) { if (url.endsWith("/")) {
url = url.substring(0, url.length() - 1); url = url.substring(0, url.length() - 1);
} }
return url; return url;
} }
/** /**
* *
* @param relativeUrl * @param relativeUrl
* the url of the entry * the url of the entry
* @param feedLink * @param feedLink
* the url of the feed as described in the feed * the url of the feed as described in the feed
* @param feedUrl * @param feedUrl
* the url of the feed that we used to fetch the feed * the url of the feed that we used to fetch the feed
* @return an absolute url pointing to the entry * @return an absolute url pointing to the entry
*/ */
public static String toAbsoluteUrl(String relativeUrl, String feedLink, String feedUrl) { public static String toAbsoluteUrl(String relativeUrl, String feedLink, String feedUrl) {
String baseUrl = (feedLink != null && isAbsoluteUrl(feedLink)) ? feedLink : feedUrl; String baseUrl = (feedLink != null && isAbsoluteUrl(feedLink)) ? feedLink : feedUrl;
if (baseUrl == null) { if (baseUrl == null) {
return null; return null;
} }
try { try {
return new URL(new URL(baseUrl), relativeUrl).toString(); return new URL(new URL(baseUrl), relativeUrl).toString();
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
log.debug("could not parse url : {}", e.getMessage(), e); log.debug("could not parse url : {}", e.getMessage(), e);
return null; return null;
} }
} }
public static String getFaviconUrl(FeedSubscription subscription) { public static String getFaviconUrl(FeedSubscription subscription) {
return "rest/feed/favicon/" + subscription.getId(); return "rest/feed/favicon/" + subscription.getId();
} }
public static String proxyImages(String content) { public static String proxyImages(String content) {
if (StringUtils.isBlank(content)) { if (StringUtils.isBlank(content)) {
return content; return content;
} }
Document doc = Jsoup.parse(content); Document doc = Jsoup.parse(content);
Elements elements = doc.select("img"); Elements elements = doc.select("img");
for (Element element : elements) { for (Element element : elements) {
String href = element.attr("src"); String href = element.attr("src");
if (StringUtils.isNotBlank(href)) { if (StringUtils.isNotBlank(href)) {
String proxy = proxyImage(href); String proxy = proxyImage(href);
element.attr("src", proxy); element.attr("src", proxy);
} }
} }
return doc.body().html(); return doc.body().html();
} }
public static String proxyImage(String url) { public static String proxyImage(String url) {
if (StringUtils.isBlank(url)) { if (StringUtils.isBlank(url)) {
return url; return url;
} }
return "rest/server/proxy?u=" + imageProxyEncoder(url); return "rest/server/proxy?u=" + imageProxyEncoder(url);
} }
public static String rot13(String msg) { public static String rot13(String msg) {
StringBuilder message = new StringBuilder(); StringBuilder message = new StringBuilder();
for (char c : msg.toCharArray()) { for (char c : msg.toCharArray()) {
if (c >= 'a' && c <= 'm') { if (c >= 'a' && c <= 'm') {
c += 13; c += 13;
} else if (c >= 'n' && c <= 'z') { } else if (c >= 'n' && c <= 'z') {
c -= 13; c -= 13;
} else if (c >= 'A' && c <= 'M') { } else if (c >= 'A' && c <= 'M') {
c += 13; c += 13;
} else if (c >= 'N' && c <= 'Z') { } else if (c >= 'N' && c <= 'Z') {
c -= 13; c -= 13;
} }
message.append(c); message.append(c);
} }
return message.toString(); return message.toString();
} }
public static String imageProxyEncoder(String url) { public static String imageProxyEncoder(String url) {
return Base64.encodeBase64String(rot13(url).getBytes()); return Base64.encodeBase64String(rot13(url).getBytes());
} }
public static String imageProxyDecoder(String code) { public static String imageProxyDecoder(String code) {
return rot13(new String(Base64.decodeBase64(code))); return rot13(new String(Base64.decodeBase64(code)));
} }
public static void removeUnwantedFromSearch(List<Entry> entries, List<FeedEntryKeyword> keywords) { public static void removeUnwantedFromSearch(List<Entry> entries, List<FeedEntryKeyword> keywords) {
Iterator<Entry> it = entries.iterator(); Iterator<Entry> it = entries.iterator();
while (it.hasNext()) { while (it.hasNext()) {
Entry entry = it.next(); Entry entry = it.next();
boolean keep = true; boolean keep = true;
for (FeedEntryKeyword keyword : keywords) { for (FeedEntryKeyword keyword : keywords) {
String title = entry.getTitle() == null ? null : Jsoup.parse(entry.getTitle()).text(); String title = entry.getTitle() == null ? null : Jsoup.parse(entry.getTitle()).text();
String content = entry.getContent() == null ? null : Jsoup.parse(entry.getContent()).text(); String content = entry.getContent() == null ? null : Jsoup.parse(entry.getContent()).text();
boolean condition = !StringUtils.containsIgnoreCase(content, keyword.getKeyword()) boolean condition = !StringUtils.containsIgnoreCase(content, keyword.getKeyword())
&& !StringUtils.containsIgnoreCase(title, keyword.getKeyword()); && !StringUtils.containsIgnoreCase(title, keyword.getKeyword());
if (keyword.getMode() == Mode.EXCLUDE) { if (keyword.getMode() == Mode.EXCLUDE) {
condition = !condition; condition = !condition;
} }
if (condition) { if (condition) {
keep = false; keep = false;
break; break;
} }
} }
if (!keep) { if (!keep) {
it.remove(); it.remove();
} }
} }
} }
} }

View File

@@ -1,70 +1,70 @@
package com.commafeed.backend.feed.parser; package com.commafeed.backend.feed.parser;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.ibm.icu.text.CharsetDetector; import com.ibm.icu.text.CharsetDetector;
import com.ibm.icu.text.CharsetMatch; import com.ibm.icu.text.CharsetMatch;
@Singleton @Singleton
class EncodingDetector { class EncodingDetector {
/** /**
* Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the characters used in the * Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the characters used in the
* feed * feed
* *
*/ */
public Charset getEncoding(byte[] bytes) { public Charset getEncoding(byte[] bytes) {
String extracted = extractDeclaredEncoding(bytes); String extracted = extractDeclaredEncoding(bytes);
if (StringUtils.startsWithIgnoreCase(extracted, "iso-8859-")) { if (StringUtils.startsWithIgnoreCase(extracted, "iso-8859-")) {
if (!StringUtils.endsWith(extracted, "1")) { if (!StringUtils.endsWith(extracted, "1")) {
return Charset.forName(extracted); return Charset.forName(extracted);
} }
} else if (StringUtils.startsWithIgnoreCase(extracted, "windows-")) { } else if (StringUtils.startsWithIgnoreCase(extracted, "windows-")) {
return Charset.forName(extracted); return Charset.forName(extracted);
} }
return detectEncoding(bytes); return detectEncoding(bytes);
} }
/** /**
* Extract the declared encoding from the xml * Extract the declared encoding from the xml
*/ */
public String extractDeclaredEncoding(byte[] bytes) { public String extractDeclaredEncoding(byte[] bytes) {
int index = ArrayUtils.indexOf(bytes, (byte) '>'); int index = ArrayUtils.indexOf(bytes, (byte) '>');
if (index == -1) { if (index == -1) {
return null; return null;
} }
String pi = new String(ArrayUtils.subarray(bytes, 0, index + 1)).replace('\'', '"'); String pi = new String(ArrayUtils.subarray(bytes, 0, index + 1)).replace('\'', '"');
index = StringUtils.indexOf(pi, "encoding=\""); index = StringUtils.indexOf(pi, "encoding=\"");
if (index == -1) { if (index == -1) {
return null; return null;
} }
String encoding = pi.substring(index + 10); String encoding = pi.substring(index + 10);
encoding = encoding.substring(0, encoding.indexOf('"')); encoding = encoding.substring(0, encoding.indexOf('"'));
return encoding; return encoding;
} }
/** /**
* Detect encoding by analyzing characters in the array * Detect encoding by analyzing characters in the array
*/ */
private Charset detectEncoding(byte[] bytes) { private Charset detectEncoding(byte[] bytes) {
String encoding = "UTF-8"; String encoding = "UTF-8";
CharsetDetector detector = new CharsetDetector(); CharsetDetector detector = new CharsetDetector();
detector.setText(bytes); detector.setText(bytes);
CharsetMatch match = detector.detect(); CharsetMatch match = detector.detect();
if (match != null) { if (match != null) {
encoding = match.getName(); encoding = match.getName();
} }
if (encoding.equalsIgnoreCase("ISO-8859-1")) { if (encoding.equalsIgnoreCase("ISO-8859-1")) {
encoding = "windows-1252"; encoding = "windows-1252";
} }
return Charset.forName(encoding); return Charset.forName(encoding);
} }
} }

View File

@@ -1,70 +1,70 @@
package com.commafeed.backend.feed.parser; package com.commafeed.backend.feed.parser;
import java.util.Collection; import java.util.Collection;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.ahocorasick.trie.Emit; import org.ahocorasick.trie.Emit;
import org.ahocorasick.trie.Trie; import org.ahocorasick.trie.Trie;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@Singleton @Singleton
class FeedCleaner { class FeedCleaner {
private static final Pattern DOCTYPE_PATTERN = Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE); private static final Pattern DOCTYPE_PATTERN = Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
public String trimInvalidXmlCharacters(String xml) { public String trimInvalidXmlCharacters(String xml) {
if (StringUtils.isBlank(xml)) { if (StringUtils.isBlank(xml)) {
return null; return null;
} }
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
boolean firstTagFound = false; boolean firstTagFound = false;
for (int i = 0; i < xml.length(); i++) { for (int i = 0; i < xml.length(); i++) {
char c = xml.charAt(i); char c = xml.charAt(i);
if (!firstTagFound) { if (!firstTagFound) {
if (c == '<') { if (c == '<') {
firstTagFound = true; firstTagFound = true;
} else { } else {
continue; continue;
} }
} }
if (c >= 32 || c == 9 || c == 10 || c == 13) { if (c >= 32 || c == 9 || c == 10 || c == 13) {
if (!Character.isHighSurrogate(c) && !Character.isLowSurrogate(c)) { if (!Character.isHighSurrogate(c) && !Character.isLowSurrogate(c)) {
sb.append(c); sb.append(c);
} }
} }
} }
return sb.toString(); return sb.toString();
} }
// https://stackoverflow.com/a/40836618 // https://stackoverflow.com/a/40836618
public String replaceHtmlEntitiesWithNumericEntities(String source) { public String replaceHtmlEntitiesWithNumericEntities(String source) {
// Create a buffer sufficiently large that re-allocations are minimized. // Create a buffer sufficiently large that re-allocations are minimized.
StringBuilder sb = new StringBuilder(source.length() << 1); StringBuilder sb = new StringBuilder(source.length() << 1);
Collection<Emit> emits = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build().parseText(source); Collection<Emit> emits = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build().parseText(source);
int prevIndex = 0; int prevIndex = 0;
for (Emit emit : emits) { for (Emit emit : emits) {
int matchIndex = emit.getStart(); int matchIndex = emit.getStart();
sb.append(source, prevIndex, matchIndex); sb.append(source, prevIndex, matchIndex);
sb.append(HtmlEntities.HTML_TO_NUMERIC_MAP.get(emit.getKeyword())); sb.append(HtmlEntities.HTML_TO_NUMERIC_MAP.get(emit.getKeyword()));
prevIndex = emit.getEnd() + 1; prevIndex = emit.getEnd() + 1;
} }
// Add the remainder of the string (contains no more matches). // Add the remainder of the string (contains no more matches).
sb.append(source.substring(prevIndex)); sb.append(source.substring(prevIndex));
return sb.toString(); return sb.toString();
} }
public String removeDoctypeDeclarations(String xml) { public String removeDoctypeDeclarations(String xml) {
return DOCTYPE_PATTERN.matcher(xml).replaceAll(""); return DOCTYPE_PATTERN.matcher(xml).replaceAll("");
} }
} }

View File

@@ -1,285 +1,285 @@
package com.commafeed.backend.feed.parser; package com.commafeed.backend.feed.parser;
import java.io.StringReader; import java.io.StringReader;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.text.DateFormat; import java.text.DateFormat;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.math3.stat.descriptive.SummaryStatistics; import org.apache.commons.math3.stat.descriptive.SummaryStatistics;
import org.jdom2.Element; import org.jdom2.Element;
import org.jdom2.Namespace; import org.jdom2.Namespace;
import org.xml.sax.InputSource; import org.xml.sax.InputSource;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.feed.parser.FeedParserResult.Content; import com.commafeed.backend.feed.parser.FeedParserResult.Content;
import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure; import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure;
import com.commafeed.backend.feed.parser.FeedParserResult.Entry; import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
import com.commafeed.backend.feed.parser.FeedParserResult.Media; import com.commafeed.backend.feed.parser.FeedParserResult.Media;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.rometools.modules.mediarss.MediaEntryModule; import com.rometools.modules.mediarss.MediaEntryModule;
import com.rometools.modules.mediarss.MediaModule; import com.rometools.modules.mediarss.MediaModule;
import com.rometools.modules.mediarss.types.MediaGroup; import com.rometools.modules.mediarss.types.MediaGroup;
import com.rometools.modules.mediarss.types.Metadata; import com.rometools.modules.mediarss.types.Metadata;
import com.rometools.modules.mediarss.types.Thumbnail; import com.rometools.modules.mediarss.types.Thumbnail;
import com.rometools.rome.feed.synd.SyndCategory; import com.rometools.rome.feed.synd.SyndCategory;
import com.rometools.rome.feed.synd.SyndContent; import com.rometools.rome.feed.synd.SyndContent;
import com.rometools.rome.feed.synd.SyndEnclosure; import com.rometools.rome.feed.synd.SyndEnclosure;
import com.rometools.rome.feed.synd.SyndEntry; import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndFeed; import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.feed.synd.SyndLink; import com.rometools.rome.feed.synd.SyndLink;
import com.rometools.rome.feed.synd.SyndLinkImpl; import com.rometools.rome.feed.synd.SyndLinkImpl;
import com.rometools.rome.io.SyndFeedInput; import com.rometools.rome.io.SyndFeedInput;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
/** /**
* Parses raw xml into a FeedParserResult object * Parses raw xml into a FeedParserResult object
*/ */
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class FeedParser { public class FeedParser {
private static final Namespace ATOM_10_NS = Namespace.getNamespace("http://www.w3.org/2005/Atom"); private static final Namespace ATOM_10_NS = Namespace.getNamespace("http://www.w3.org/2005/Atom");
private static final Instant START = Instant.ofEpochMilli(86400000); private static final Instant START = Instant.ofEpochMilli(86400000);
private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000); private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000);
private final EncodingDetector encodingDetector; private final EncodingDetector encodingDetector;
private final FeedCleaner feedCleaner; private final FeedCleaner feedCleaner;
public FeedParserResult parse(String feedUrl, byte[] xml) throws FeedParsingException { public FeedParserResult parse(String feedUrl, byte[] xml) throws FeedParsingException {
try { try {
Charset encoding = encodingDetector.getEncoding(xml); Charset encoding = encodingDetector.getEncoding(xml);
String xmlString = feedCleaner.trimInvalidXmlCharacters(new String(xml, encoding)); String xmlString = feedCleaner.trimInvalidXmlCharacters(new String(xml, encoding));
if (xmlString == null) { if (xmlString == null) {
throw new FeedParsingException("Input string is null for url " + feedUrl); throw new FeedParsingException("Input string is null for url " + feedUrl);
} }
xmlString = feedCleaner.replaceHtmlEntitiesWithNumericEntities(xmlString); xmlString = feedCleaner.replaceHtmlEntitiesWithNumericEntities(xmlString);
xmlString = feedCleaner.removeDoctypeDeclarations(xmlString); xmlString = feedCleaner.removeDoctypeDeclarations(xmlString);
InputSource source = new InputSource(new StringReader(xmlString)); InputSource source = new InputSource(new StringReader(xmlString));
SyndFeed feed = new SyndFeedInput().build(source); SyndFeed feed = new SyndFeedInput().build(source);
handleForeignMarkup(feed); handleForeignMarkup(feed);
String title = feed.getTitle(); String title = feed.getTitle();
String link = feed.getLink(); String link = feed.getLink();
List<Entry> entries = buildEntries(feed, feedUrl); List<Entry> entries = buildEntries(feed, feedUrl);
Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null); Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null);
Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false); Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false);
if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) { if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
lastPublishedDate = lastEntryDate; lastPublishedDate = lastEntryDate;
} }
Long averageEntryInterval = averageTimeBetweenEntries(entries); Long averageEntryInterval = averageTimeBetweenEntries(entries);
return new FeedParserResult(title, link, lastPublishedDate, averageEntryInterval, lastEntryDate, entries); return new FeedParserResult(title, link, lastPublishedDate, averageEntryInterval, lastEntryDate, entries);
} catch (FeedParsingException e) { } catch (FeedParsingException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
throw new FeedParsingException(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);
} }
} }
/** /**
* Adds atom links for rss feeds * Adds atom links for rss feeds
*/ */
private void handleForeignMarkup(SyndFeed feed) { private void handleForeignMarkup(SyndFeed feed) {
List<Element> foreignMarkup = feed.getForeignMarkup(); List<Element> foreignMarkup = feed.getForeignMarkup();
if (foreignMarkup == null) { if (foreignMarkup == null) {
return; return;
} }
for (Element element : foreignMarkup) { for (Element element : foreignMarkup) {
if ("link".equals(element.getName()) && ATOM_10_NS.equals(element.getNamespace())) { if ("link".equals(element.getName()) && ATOM_10_NS.equals(element.getNamespace())) {
SyndLink link = new SyndLinkImpl(); SyndLink link = new SyndLinkImpl();
link.setRel(element.getAttributeValue("rel")); link.setRel(element.getAttributeValue("rel"));
link.setHref(element.getAttributeValue("href")); link.setHref(element.getAttributeValue("href"));
feed.getLinks().add(link); feed.getLinks().add(link);
} }
} }
} }
private List<Entry> buildEntries(SyndFeed feed, String feedUrl) { private List<Entry> buildEntries(SyndFeed feed, String feedUrl) {
List<Entry> entries = new ArrayList<>(); List<Entry> entries = new ArrayList<>();
for (SyndEntry item : feed.getEntries()) { for (SyndEntry item : feed.getEntries()) {
String guid = item.getUri(); String guid = item.getUri();
if (StringUtils.isBlank(guid)) { if (StringUtils.isBlank(guid)) {
guid = item.getLink(); guid = item.getLink();
} }
if (StringUtils.isBlank(guid)) { if (StringUtils.isBlank(guid)) {
// no guid and no link, skip entry // no guid and no link, skip entry
continue; continue;
} }
String url = buildEntryUrl(feed, feedUrl, item); String url = buildEntryUrl(feed, feedUrl, item);
if (StringUtils.isBlank(url) && FeedUtils.isAbsoluteUrl(guid)) { if (StringUtils.isBlank(url) && FeedUtils.isAbsoluteUrl(guid)) {
// if link is empty but guid is used as url, use guid // if link is empty but guid is used as url, use guid
url = guid; url = guid;
} }
Instant publishedDate = buildEntryPublishedDate(item); Instant publishedDate = buildEntryPublishedDate(item);
Content content = buildContent(item); Content content = buildContent(item);
entries.add(new Entry(guid, url, publishedDate, content)); entries.add(new Entry(guid, url, publishedDate, content));
} }
entries.sort(Comparator.comparing(Entry::published).reversed()); entries.sort(Comparator.comparing(Entry::published).reversed());
return entries; return entries;
} }
private Content buildContent(SyndEntry item) { private Content buildContent(SyndEntry item) {
String title = getTitle(item); String title = getTitle(item);
String content = getContent(item); String content = getContent(item);
String author = StringUtils.trimToNull(item.getAuthor()); String author = StringUtils.trimToNull(item.getAuthor());
String categories = StringUtils String categories = StringUtils
.trimToNull(item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", "))); .trimToNull(item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", ")));
Enclosure enclosure = buildEnclosure(item); Enclosure enclosure = buildEnclosure(item);
Media media = buildMedia(item); Media media = buildMedia(item);
return new Content(title, content, author, categories, enclosure, media); return new Content(title, content, author, categories, enclosure, media);
} }
private Enclosure buildEnclosure(SyndEntry item) { private Enclosure buildEnclosure(SyndEntry item) {
SyndEnclosure enclosure = Iterables.getFirst(item.getEnclosures(), null); SyndEnclosure enclosure = Iterables.getFirst(item.getEnclosures(), null);
if (enclosure == null) { if (enclosure == null) {
return null; return null;
} }
return new Enclosure(enclosure.getUrl(), enclosure.getType()); return new Enclosure(enclosure.getUrl(), enclosure.getType());
} }
private Instant buildEntryPublishedDate(SyndEntry item) { private Instant buildEntryPublishedDate(SyndEntry item) {
Date date = item.getPublishedDate(); Date date = item.getPublishedDate();
if (date == null) { if (date == null) {
date = item.getUpdatedDate(); date = item.getUpdatedDate();
} }
return toValidInstant(date, true); return toValidInstant(date, true);
} }
private String buildEntryUrl(SyndFeed feed, String feedUrl, SyndEntry item) { private String buildEntryUrl(SyndFeed feed, String feedUrl, SyndEntry item) {
String url = StringUtils.trimToNull(StringUtils.normalizeSpace(item.getLink())); String url = StringUtils.trimToNull(StringUtils.normalizeSpace(item.getLink()));
if (url == null || FeedUtils.isAbsoluteUrl(url)) { if (url == null || FeedUtils.isAbsoluteUrl(url)) {
// url is absolute, nothing to do // url is absolute, nothing to do
return url; return url;
} }
// url is relative, trying to resolve it // url is relative, trying to resolve it
String feedLink = StringUtils.trimToNull(StringUtils.normalizeSpace(feed.getLink())); String feedLink = StringUtils.trimToNull(StringUtils.normalizeSpace(feed.getLink()));
return FeedUtils.toAbsoluteUrl(url, feedLink, feedUrl); return FeedUtils.toAbsoluteUrl(url, feedLink, feedUrl);
} }
private Instant toValidInstant(Date date, boolean nullToNow) { private Instant toValidInstant(Date date, boolean nullToNow) {
Instant now = Instant.now(); Instant now = Instant.now();
if (date == null) { if (date == null) {
return nullToNow ? now : null; return nullToNow ? now : null;
} }
Instant instant = date.toInstant(); Instant instant = date.toInstant();
if (instant.isBefore(START) || instant.isAfter(END)) { if (instant.isBefore(START) || instant.isAfter(END)) {
return now; return now;
} }
if (instant.isAfter(now)) { if (instant.isAfter(now)) {
return now; return now;
} }
return instant; return instant;
} }
private String getContent(SyndEntry item) { private String getContent(SyndEntry item) {
String content; String content;
if (item.getContents().isEmpty()) { if (item.getContents().isEmpty()) {
content = item.getDescription() == null ? null : item.getDescription().getValue(); content = item.getDescription() == null ? null : item.getDescription().getValue();
} else { } else {
content = item.getContents().stream().map(SyndContent::getValue).collect(Collectors.joining(System.lineSeparator())); content = item.getContents().stream().map(SyndContent::getValue).collect(Collectors.joining(System.lineSeparator()));
} }
return StringUtils.trimToNull(content); return StringUtils.trimToNull(content);
} }
private String getTitle(SyndEntry item) { private String getTitle(SyndEntry item) {
String title = item.getTitle(); String title = item.getTitle();
if (StringUtils.isBlank(title)) { if (StringUtils.isBlank(title)) {
Date date = item.getPublishedDate(); Date date = item.getPublishedDate();
if (date != null) { if (date != null) {
title = DateFormat.getInstance().format(date); title = DateFormat.getInstance().format(date);
} else { } else {
title = "(no title)"; title = "(no title)";
} }
} }
return StringUtils.trimToNull(title); return StringUtils.trimToNull(title);
} }
private Media buildMedia(SyndEntry item) { private Media buildMedia(SyndEntry item) {
MediaEntryModule module = (MediaEntryModule) item.getModule(MediaModule.URI); MediaEntryModule module = (MediaEntryModule) item.getModule(MediaModule.URI);
if (module == null) { if (module == null) {
return null; return null;
} }
Media media = buildMedia(module.getMetadata()); Media media = buildMedia(module.getMetadata());
if (media == null && ArrayUtils.isNotEmpty(module.getMediaGroups())) { if (media == null && ArrayUtils.isNotEmpty(module.getMediaGroups())) {
MediaGroup group = module.getMediaGroups()[0]; MediaGroup group = module.getMediaGroups()[0];
media = buildMedia(group.getMetadata()); media = buildMedia(group.getMetadata());
} }
return media; return media;
} }
private Media buildMedia(Metadata metadata) { private Media buildMedia(Metadata metadata) {
if (metadata == null) { if (metadata == null) {
return null; return null;
} }
String description = metadata.getDescription(); String description = metadata.getDescription();
String thumbnailUrl = null; String thumbnailUrl = null;
Integer thumbnailWidth = null; Integer thumbnailWidth = null;
Integer thumbnailHeight = null; Integer thumbnailHeight = null;
if (ArrayUtils.isNotEmpty(metadata.getThumbnail())) { if (ArrayUtils.isNotEmpty(metadata.getThumbnail())) {
Thumbnail thumbnail = metadata.getThumbnail()[0]; Thumbnail thumbnail = metadata.getThumbnail()[0];
thumbnailWidth = thumbnail.getWidth(); thumbnailWidth = thumbnail.getWidth();
thumbnailHeight = thumbnail.getHeight(); thumbnailHeight = thumbnail.getHeight();
if (thumbnail.getUrl() != null) { if (thumbnail.getUrl() != null) {
thumbnailUrl = thumbnail.getUrl().toString(); thumbnailUrl = thumbnail.getUrl().toString();
} }
} }
if (description == null && thumbnailUrl == null) { if (description == null && thumbnailUrl == null) {
return null; return null;
} }
return new Media(description, thumbnailUrl, thumbnailWidth, thumbnailHeight); return new Media(description, thumbnailUrl, thumbnailWidth, thumbnailHeight);
} }
private Long averageTimeBetweenEntries(List<Entry> entries) { private Long averageTimeBetweenEntries(List<Entry> entries) {
if (entries.isEmpty() || entries.size() == 1) { if (entries.isEmpty() || entries.size() == 1) {
return null; return null;
} }
SummaryStatistics stats = new SummaryStatistics(); SummaryStatistics stats = new SummaryStatistics();
for (int i = 0; i < entries.size() - 1; i++) { for (int i = 0; i < entries.size() - 1; i++) {
long diff = Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli()); long diff = Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli());
stats.addValue(diff); stats.addValue(diff);
} }
return (long) stats.getMean(); return (long) stats.getMean();
} }
public static class FeedParsingException extends Exception { public static class FeedParsingException extends Exception {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
public FeedParsingException(String message) { public FeedParsingException(String message) {
super(message); super(message);
} }
public FeedParsingException(String message, Throwable cause) { public FeedParsingException(String message, Throwable cause) {
super(message, cause); super(message, cause);
} }
} }
} }

View File

@@ -1,20 +1,20 @@
package com.commafeed.backend.feed.parser; package com.commafeed.backend.feed.parser;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
public record FeedParserResult(String title, String link, Instant lastPublishedDate, Long averageEntryInterval, Instant lastEntryDate, public record FeedParserResult(String title, String link, Instant lastPublishedDate, Long averageEntryInterval, Instant lastEntryDate,
List<Entry> entries) { List<Entry> entries) {
public record Entry(String guid, String url, Instant published, Content content) { public record Entry(String guid, String url, Instant published, Content content) {
} }
public record Content(String title, String content, String author, String categories, Enclosure enclosure, Media media) { public record Content(String title, String content, String author, String categories, Enclosure enclosure, Media media) {
} }
public record Enclosure(String url, String type) { public record Enclosure(String url, String type) {
} }
public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) { public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) {
} }
} }

View File

@@ -1,272 +1,272 @@
package com.commafeed.backend.feed.parser; package com.commafeed.backend.feed.parser;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
@UtilityClass @UtilityClass
class HtmlEntities { class HtmlEntities {
public static final Map<String, String> HTML_TO_NUMERIC_MAP; public static final Map<String, String> HTML_TO_NUMERIC_MAP;
public static final String[] HTML_ENTITIES; public static final String[] HTML_ENTITIES;
public static final String[] NUMERIC_ENTITIES; public static final String[] NUMERIC_ENTITIES;
static { static {
Map<String, String> map = new LinkedHashMap<>(); Map<String, String> map = new LinkedHashMap<>();
map.put("&Aacute;", "&#193;"); map.put("&Aacute;", "&#193;");
map.put("&aacute;", "&#225;"); map.put("&aacute;", "&#225;");
map.put("&Acirc;", "&#194;"); map.put("&Acirc;", "&#194;");
map.put("&acirc;", "&#226;"); map.put("&acirc;", "&#226;");
map.put("&acute;", "&#180;"); map.put("&acute;", "&#180;");
map.put("&AElig;", "&#198;"); map.put("&AElig;", "&#198;");
map.put("&aelig;", "&#230;"); map.put("&aelig;", "&#230;");
map.put("&Agrave;", "&#192;"); map.put("&Agrave;", "&#192;");
map.put("&agrave;", "&#224;"); map.put("&agrave;", "&#224;");
map.put("&alefsym;", "&#8501;"); map.put("&alefsym;", "&#8501;");
map.put("&Alpha;", "&#913;"); map.put("&Alpha;", "&#913;");
map.put("&alpha;", "&#945;"); map.put("&alpha;", "&#945;");
map.put("&amp;", "&#38;"); map.put("&amp;", "&#38;");
map.put("&and;", "&#8743;"); map.put("&and;", "&#8743;");
map.put("&ang;", "&#8736;"); map.put("&ang;", "&#8736;");
map.put("&Aring;", "&#197;"); map.put("&Aring;", "&#197;");
map.put("&aring;", "&#229;"); map.put("&aring;", "&#229;");
map.put("&asymp;", "&#8776;"); map.put("&asymp;", "&#8776;");
map.put("&Atilde;", "&#195;"); map.put("&Atilde;", "&#195;");
map.put("&atilde;", "&#227;"); map.put("&atilde;", "&#227;");
map.put("&Auml;", "&#196;"); map.put("&Auml;", "&#196;");
map.put("&auml;", "&#228;"); map.put("&auml;", "&#228;");
map.put("&bdquo;", "&#8222;"); map.put("&bdquo;", "&#8222;");
map.put("&Beta;", "&#914;"); map.put("&Beta;", "&#914;");
map.put("&beta;", "&#946;"); map.put("&beta;", "&#946;");
map.put("&brvbar;", "&#166;"); map.put("&brvbar;", "&#166;");
map.put("&bull;", "&#8226;"); map.put("&bull;", "&#8226;");
map.put("&cap;", "&#8745;"); map.put("&cap;", "&#8745;");
map.put("&Ccedil;", "&#199;"); map.put("&Ccedil;", "&#199;");
map.put("&ccedil;", "&#231;"); map.put("&ccedil;", "&#231;");
map.put("&cedil;", "&#184;"); map.put("&cedil;", "&#184;");
map.put("&cent;", "&#162;"); map.put("&cent;", "&#162;");
map.put("&Chi;", "&#935;"); map.put("&Chi;", "&#935;");
map.put("&chi;", "&#967;"); map.put("&chi;", "&#967;");
map.put("&circ;", "&#710;"); map.put("&circ;", "&#710;");
map.put("&clubs;", "&#9827;"); map.put("&clubs;", "&#9827;");
map.put("&cong;", "&#8773;"); map.put("&cong;", "&#8773;");
map.put("&copy;", "&#169;"); map.put("&copy;", "&#169;");
map.put("&crarr;", "&#8629;"); map.put("&crarr;", "&#8629;");
map.put("&cup;", "&#8746;"); map.put("&cup;", "&#8746;");
map.put("&curren;", "&#164;"); map.put("&curren;", "&#164;");
map.put("&dagger;", "&#8224;"); map.put("&dagger;", "&#8224;");
map.put("&Dagger;", "&#8225;"); map.put("&Dagger;", "&#8225;");
map.put("&darr;", "&#8595;"); map.put("&darr;", "&#8595;");
map.put("&dArr;", "&#8659;"); map.put("&dArr;", "&#8659;");
map.put("&deg;", "&#176;"); map.put("&deg;", "&#176;");
map.put("&Delta;", "&#916;"); map.put("&Delta;", "&#916;");
map.put("&delta;", "&#948;"); map.put("&delta;", "&#948;");
map.put("&diams;", "&#9830;"); map.put("&diams;", "&#9830;");
map.put("&divide;", "&#247;"); map.put("&divide;", "&#247;");
map.put("&Eacute;", "&#201;"); map.put("&Eacute;", "&#201;");
map.put("&eacute;", "&#233;"); map.put("&eacute;", "&#233;");
map.put("&Ecirc;", "&#202;"); map.put("&Ecirc;", "&#202;");
map.put("&ecirc;", "&#234;"); map.put("&ecirc;", "&#234;");
map.put("&Egrave;", "&#200;"); map.put("&Egrave;", "&#200;");
map.put("&egrave;", "&#232;"); map.put("&egrave;", "&#232;");
map.put("&empty;", "&#8709;"); map.put("&empty;", "&#8709;");
map.put("&emsp;", "&#8195;"); map.put("&emsp;", "&#8195;");
map.put("&ensp;", "&#8194;"); map.put("&ensp;", "&#8194;");
map.put("&Epsilon;", "&#917;"); map.put("&Epsilon;", "&#917;");
map.put("&epsilon;", "&#949;"); map.put("&epsilon;", "&#949;");
map.put("&equiv;", "&#8801;"); map.put("&equiv;", "&#8801;");
map.put("&Eta;", "&#919;"); map.put("&Eta;", "&#919;");
map.put("&eta;", "&#951;"); map.put("&eta;", "&#951;");
map.put("&ETH;", "&#208;"); map.put("&ETH;", "&#208;");
map.put("&eth;", "&#240;"); map.put("&eth;", "&#240;");
map.put("&Euml;", "&#203;"); map.put("&Euml;", "&#203;");
map.put("&euml;", "&#235;"); map.put("&euml;", "&#235;");
map.put("&euro;", "&#8364;"); map.put("&euro;", "&#8364;");
map.put("&exist;", "&#8707;"); map.put("&exist;", "&#8707;");
map.put("&fnof;", "&#402;"); map.put("&fnof;", "&#402;");
map.put("&forall;", "&#8704;"); map.put("&forall;", "&#8704;");
map.put("&frac12;", "&#189;"); map.put("&frac12;", "&#189;");
map.put("&frac14;", "&#188;"); map.put("&frac14;", "&#188;");
map.put("&frac34;", "&#190;"); map.put("&frac34;", "&#190;");
map.put("&frasl;", "&#8260;"); map.put("&frasl;", "&#8260;");
map.put("&Gamma;", "&#915;"); map.put("&Gamma;", "&#915;");
map.put("&gamma;", "&#947;"); map.put("&gamma;", "&#947;");
map.put("&ge;", "&#8805;"); map.put("&ge;", "&#8805;");
map.put("&harr;", "&#8596;"); map.put("&harr;", "&#8596;");
map.put("&hArr;", "&#8660;"); map.put("&hArr;", "&#8660;");
map.put("&hearts;", "&#9829;"); map.put("&hearts;", "&#9829;");
map.put("&hellip;", "&#8230;"); map.put("&hellip;", "&#8230;");
map.put("&Iacute;", "&#205;"); map.put("&Iacute;", "&#205;");
map.put("&iacute;", "&#237;"); map.put("&iacute;", "&#237;");
map.put("&Icirc;", "&#206;"); map.put("&Icirc;", "&#206;");
map.put("&icirc;", "&#238;"); map.put("&icirc;", "&#238;");
map.put("&iexcl;", "&#161;"); map.put("&iexcl;", "&#161;");
map.put("&Igrave;", "&#204;"); map.put("&Igrave;", "&#204;");
map.put("&igrave;", "&#236;"); map.put("&igrave;", "&#236;");
map.put("&image;", "&#8465;"); map.put("&image;", "&#8465;");
map.put("&infin;", "&#8734;"); map.put("&infin;", "&#8734;");
map.put("&int;", "&#8747;"); map.put("&int;", "&#8747;");
map.put("&Iota;", "&#921;"); map.put("&Iota;", "&#921;");
map.put("&iota;", "&#953;"); map.put("&iota;", "&#953;");
map.put("&iquest;", "&#191;"); map.put("&iquest;", "&#191;");
map.put("&isin;", "&#8712;"); map.put("&isin;", "&#8712;");
map.put("&Iuml;", "&#207;"); map.put("&Iuml;", "&#207;");
map.put("&iuml;", "&#239;"); map.put("&iuml;", "&#239;");
map.put("&Kappa;", "&#922;"); map.put("&Kappa;", "&#922;");
map.put("&kappa;", "&#954;"); map.put("&kappa;", "&#954;");
map.put("&Lambda;", "&#923;"); map.put("&Lambda;", "&#923;");
map.put("&lambda;", "&#955;"); map.put("&lambda;", "&#955;");
map.put("&lang;", "&#9001;"); map.put("&lang;", "&#9001;");
map.put("&laquo;", "&#171;"); map.put("&laquo;", "&#171;");
map.put("&larr;", "&#8592;"); map.put("&larr;", "&#8592;");
map.put("&lArr;", "&#8656;"); map.put("&lArr;", "&#8656;");
map.put("&lceil;", "&#8968;"); map.put("&lceil;", "&#8968;");
map.put("&ldquo;", "&#8220;"); map.put("&ldquo;", "&#8220;");
map.put("&le;", "&#8804;"); map.put("&le;", "&#8804;");
map.put("&lfloor;", "&#8970;"); map.put("&lfloor;", "&#8970;");
map.put("&lowast;", "&#8727;"); map.put("&lowast;", "&#8727;");
map.put("&loz;", "&#9674;"); map.put("&loz;", "&#9674;");
map.put("&lrm;", "&#8206;"); map.put("&lrm;", "&#8206;");
map.put("&lsaquo;", "&#8249;"); map.put("&lsaquo;", "&#8249;");
map.put("&lsquo;", "&#8216;"); map.put("&lsquo;", "&#8216;");
map.put("&macr;", "&#175;"); map.put("&macr;", "&#175;");
map.put("&mdash;", "&#8212;"); map.put("&mdash;", "&#8212;");
map.put("&micro;", "&#181;"); map.put("&micro;", "&#181;");
map.put("&middot;", "&#183;"); map.put("&middot;", "&#183;");
map.put("&minus;", "&#8722;"); map.put("&minus;", "&#8722;");
map.put("&Mu;", "&#924;"); map.put("&Mu;", "&#924;");
map.put("&mu;", "&#956;"); map.put("&mu;", "&#956;");
map.put("&nabla;", "&#8711;"); map.put("&nabla;", "&#8711;");
map.put("&nbsp;", "&#160;"); map.put("&nbsp;", "&#160;");
map.put("&ndash;", "&#8211;"); map.put("&ndash;", "&#8211;");
map.put("&ne;", "&#8800;"); map.put("&ne;", "&#8800;");
map.put("&ni;", "&#8715;"); map.put("&ni;", "&#8715;");
map.put("&not;", "&#172;"); map.put("&not;", "&#172;");
map.put("&notin;", "&#8713;"); map.put("&notin;", "&#8713;");
map.put("&nsub;", "&#8836;"); map.put("&nsub;", "&#8836;");
map.put("&Ntilde;", "&#209;"); map.put("&Ntilde;", "&#209;");
map.put("&ntilde;", "&#241;"); map.put("&ntilde;", "&#241;");
map.put("&Nu;", "&#925;"); map.put("&Nu;", "&#925;");
map.put("&nu;", "&#957;"); map.put("&nu;", "&#957;");
map.put("&Oacute;", "&#211;"); map.put("&Oacute;", "&#211;");
map.put("&oacute;", "&#243;"); map.put("&oacute;", "&#243;");
map.put("&Ocirc;", "&#212;"); map.put("&Ocirc;", "&#212;");
map.put("&ocirc;", "&#244;"); map.put("&ocirc;", "&#244;");
map.put("&OElig;", "&#338;"); map.put("&OElig;", "&#338;");
map.put("&oelig;", "&#339;"); map.put("&oelig;", "&#339;");
map.put("&Ograve;", "&#210;"); map.put("&Ograve;", "&#210;");
map.put("&ograve;", "&#242;"); map.put("&ograve;", "&#242;");
map.put("&oline;", "&#8254;"); map.put("&oline;", "&#8254;");
map.put("&Omega;", "&#937;"); map.put("&Omega;", "&#937;");
map.put("&omega;", "&#969;"); map.put("&omega;", "&#969;");
map.put("&Omicron;", "&#927;"); map.put("&Omicron;", "&#927;");
map.put("&omicron;", "&#959;"); map.put("&omicron;", "&#959;");
map.put("&oplus;", "&#8853;"); map.put("&oplus;", "&#8853;");
map.put("&or;", "&#8744;"); map.put("&or;", "&#8744;");
map.put("&ordf;", "&#170;"); map.put("&ordf;", "&#170;");
map.put("&ordm;", "&#186;"); map.put("&ordm;", "&#186;");
map.put("&Oslash;", "&#216;"); map.put("&Oslash;", "&#216;");
map.put("&oslash;", "&#248;"); map.put("&oslash;", "&#248;");
map.put("&Otilde;", "&#213;"); map.put("&Otilde;", "&#213;");
map.put("&otilde;", "&#245;"); map.put("&otilde;", "&#245;");
map.put("&otimes;", "&#8855;"); map.put("&otimes;", "&#8855;");
map.put("&Ouml;", "&#214;"); map.put("&Ouml;", "&#214;");
map.put("&ouml;", "&#246;"); map.put("&ouml;", "&#246;");
map.put("&para;", "&#182;"); map.put("&para;", "&#182;");
map.put("&part;", "&#8706;"); map.put("&part;", "&#8706;");
map.put("&permil;", "&#8240;"); map.put("&permil;", "&#8240;");
map.put("&perp;", "&#8869;"); map.put("&perp;", "&#8869;");
map.put("&Phi;", "&#934;"); map.put("&Phi;", "&#934;");
map.put("&phi;", "&#966;"); map.put("&phi;", "&#966;");
map.put("&Pi;", "&#928;"); map.put("&Pi;", "&#928;");
map.put("&pi;", "&#960;"); map.put("&pi;", "&#960;");
map.put("&piv;", "&#982;"); map.put("&piv;", "&#982;");
map.put("&plusmn;", "&#177;"); map.put("&plusmn;", "&#177;");
map.put("&pound;", "&#163;"); map.put("&pound;", "&#163;");
map.put("&prime;", "&#8242;"); map.put("&prime;", "&#8242;");
map.put("&Prime;", "&#8243;"); map.put("&Prime;", "&#8243;");
map.put("&prod;", "&#8719;"); map.put("&prod;", "&#8719;");
map.put("&prop;", "&#8733;"); map.put("&prop;", "&#8733;");
map.put("&Psi;", "&#936;"); map.put("&Psi;", "&#936;");
map.put("&psi;", "&#968;"); map.put("&psi;", "&#968;");
map.put("&quot;", "&#34;"); map.put("&quot;", "&#34;");
map.put("&radic;", "&#8730;"); map.put("&radic;", "&#8730;");
map.put("&rang;", "&#9002;"); map.put("&rang;", "&#9002;");
map.put("&raquo;", "&#187;"); map.put("&raquo;", "&#187;");
map.put("&rarr;", "&#8594;"); map.put("&rarr;", "&#8594;");
map.put("&rArr;", "&#8658;"); map.put("&rArr;", "&#8658;");
map.put("&rceil;", "&#8969;"); map.put("&rceil;", "&#8969;");
map.put("&rdquo;", "&#8221;"); map.put("&rdquo;", "&#8221;");
map.put("&real;", "&#8476;"); map.put("&real;", "&#8476;");
map.put("&reg;", "&#174;"); map.put("&reg;", "&#174;");
map.put("&rfloor;", "&#8971;"); map.put("&rfloor;", "&#8971;");
map.put("&Rho;", "&#929;"); map.put("&Rho;", "&#929;");
map.put("&rho;", "&#961;"); map.put("&rho;", "&#961;");
map.put("&rlm;", "&#8207;"); map.put("&rlm;", "&#8207;");
map.put("&rsaquo;", "&#8250;"); map.put("&rsaquo;", "&#8250;");
map.put("&rsquo;", "&#8217;"); map.put("&rsquo;", "&#8217;");
map.put("&sbquo;", "&#8218;"); map.put("&sbquo;", "&#8218;");
map.put("&Scaron;", "&#352;"); map.put("&Scaron;", "&#352;");
map.put("&scaron;", "&#353;"); map.put("&scaron;", "&#353;");
map.put("&sdot;", "&#8901;"); map.put("&sdot;", "&#8901;");
map.put("&sect;", "&#167;"); map.put("&sect;", "&#167;");
map.put("&shy;", "&#173;"); map.put("&shy;", "&#173;");
map.put("&Sigma;", "&#931;"); map.put("&Sigma;", "&#931;");
map.put("&sigma;", "&#963;"); map.put("&sigma;", "&#963;");
map.put("&sigmaf;", "&#962;"); map.put("&sigmaf;", "&#962;");
map.put("&sim;", "&#8764;"); map.put("&sim;", "&#8764;");
map.put("&spades;", "&#9824;"); map.put("&spades;", "&#9824;");
map.put("&sub;", "&#8834;"); map.put("&sub;", "&#8834;");
map.put("&sube;", "&#8838;"); map.put("&sube;", "&#8838;");
map.put("&sum;", "&#8721;"); map.put("&sum;", "&#8721;");
map.put("&sup1;", "&#185;"); map.put("&sup1;", "&#185;");
map.put("&sup2;", "&#178;"); map.put("&sup2;", "&#178;");
map.put("&sup3;", "&#179;"); map.put("&sup3;", "&#179;");
map.put("&sup;", "&#8835;"); map.put("&sup;", "&#8835;");
map.put("&supe;", "&#8839;"); map.put("&supe;", "&#8839;");
map.put("&szlig;", "&#223;"); map.put("&szlig;", "&#223;");
map.put("&Tau;", "&#932;"); map.put("&Tau;", "&#932;");
map.put("&tau;", "&#964;"); map.put("&tau;", "&#964;");
map.put("&there4;", "&#8756;"); map.put("&there4;", "&#8756;");
map.put("&Theta;", "&#920;"); map.put("&Theta;", "&#920;");
map.put("&theta;", "&#952;"); map.put("&theta;", "&#952;");
map.put("&thetasym;", "&#977;"); map.put("&thetasym;", "&#977;");
map.put("&thinsp;", "&#8201;"); map.put("&thinsp;", "&#8201;");
map.put("&THORN;", "&#222;"); map.put("&THORN;", "&#222;");
map.put("&thorn;", "&#254;"); map.put("&thorn;", "&#254;");
map.put("&tilde;", "&#732;"); map.put("&tilde;", "&#732;");
map.put("&times;", "&#215;"); map.put("&times;", "&#215;");
map.put("&trade;", "&#8482;"); map.put("&trade;", "&#8482;");
map.put("&Uacute;", "&#218;"); map.put("&Uacute;", "&#218;");
map.put("&uacute;", "&#250;"); map.put("&uacute;", "&#250;");
map.put("&uarr;", "&#8593;"); map.put("&uarr;", "&#8593;");
map.put("&uArr;", "&#8657;"); map.put("&uArr;", "&#8657;");
map.put("&Ucirc;", "&#219;"); map.put("&Ucirc;", "&#219;");
map.put("&ucirc;", "&#251;"); map.put("&ucirc;", "&#251;");
map.put("&Ugrave;", "&#217;"); map.put("&Ugrave;", "&#217;");
map.put("&ugrave;", "&#249;"); map.put("&ugrave;", "&#249;");
map.put("&uml;", "&#168;"); map.put("&uml;", "&#168;");
map.put("&upsih;", "&#978;"); map.put("&upsih;", "&#978;");
map.put("&Upsilon;", "&#933;"); map.put("&Upsilon;", "&#933;");
map.put("&upsilon;", "&#965;"); map.put("&upsilon;", "&#965;");
map.put("&Uuml;", "&#220;"); map.put("&Uuml;", "&#220;");
map.put("&uuml;", "&#252;"); map.put("&uuml;", "&#252;");
map.put("&weierp;", "&#8472;"); map.put("&weierp;", "&#8472;");
map.put("&Xi;", "&#926;"); map.put("&Xi;", "&#926;");
map.put("&xi;", "&#958;"); map.put("&xi;", "&#958;");
map.put("&Yacute;", "&#221;"); map.put("&Yacute;", "&#221;");
map.put("&yacute;", "&#253;"); map.put("&yacute;", "&#253;");
map.put("&yen;", "&#165;"); map.put("&yen;", "&#165;");
map.put("&yuml;", "&#255;"); map.put("&yuml;", "&#255;");
map.put("&Yuml;", "&#376;"); map.put("&Yuml;", "&#376;");
map.put("&Zeta;", "&#918;"); map.put("&Zeta;", "&#918;");
map.put("&zeta;", "&#950;"); map.put("&zeta;", "&#950;");
map.put("&zwj;", "&#8205;"); map.put("&zwj;", "&#8205;");
map.put("&zwnj;", "&#8204;"); map.put("&zwnj;", "&#8204;");
HTML_TO_NUMERIC_MAP = Collections.unmodifiableMap(map); HTML_TO_NUMERIC_MAP = Collections.unmodifiableMap(map);
HTML_ENTITIES = map.keySet().toArray(new String[0]); HTML_ENTITIES = map.keySet().toArray(new String[0]);
NUMERIC_ENTITIES = map.values().toArray(new String[0]); NUMERIC_ENTITIES = map.values().toArray(new String[0]);
} }
} }

View File

@@ -1,56 +1,56 @@
package com.commafeed.backend.feed.parser; package com.commafeed.backend.feed.parser;
import java.text.Bidi; import java.text.Bidi;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.lang3.math.NumberUtils;
public class TextDirectionDetector { public class TextDirectionDetector {
private static final Pattern WORDS_PATTERN = Pattern.compile("\\s+"); private static final Pattern WORDS_PATTERN = Pattern.compile("\\s+");
private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*"); private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*");
private static final double RTL_THRESHOLD = 0.4D; private static final double RTL_THRESHOLD = 0.4D;
public enum Direction { public enum Direction {
LEFT_TO_RIGHT, RIGHT_TO_LEFT LEFT_TO_RIGHT, RIGHT_TO_LEFT
} }
public static Direction detect(String input) { public static Direction detect(String input) {
if (input == null || input.isBlank()) { if (input == null || input.isBlank()) {
return Direction.LEFT_TO_RIGHT; return Direction.LEFT_TO_RIGHT;
} }
long rtl = 0; long rtl = 0;
long total = 0; long total = 0;
for (String token : WORDS_PATTERN.split(input)) { for (String token : WORDS_PATTERN.split(input)) {
// skip urls // skip urls
if (URL_PATTERN.matcher(token).matches()) { if (URL_PATTERN.matcher(token).matches()) {
continue; continue;
} }
// skip numbers // skip numbers
if (NumberUtils.isCreatable(token)) { if (NumberUtils.isCreatable(token)) {
continue; continue;
} }
boolean requiresBidi = Bidi.requiresBidi(token.toCharArray(), 0, token.length()); boolean requiresBidi = Bidi.requiresBidi(token.toCharArray(), 0, token.length());
if (requiresBidi) { if (requiresBidi) {
Bidi bidi = new Bidi(token, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT); Bidi bidi = new Bidi(token, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT);
if (bidi.getBaseLevel() == 1) { if (bidi.getBaseLevel() == 1) {
rtl++; rtl++;
} }
} }
total++; total++;
} }
if (total == 0) { if (total == 0) {
return Direction.LEFT_TO_RIGHT; return Direction.LEFT_TO_RIGHT;
} }
double ratio = (double) rtl / total; double ratio = (double) rtl / total;
return ratio > RTL_THRESHOLD ? Direction.RIGHT_TO_LEFT : Direction.LEFT_TO_RIGHT; return ratio > RTL_THRESHOLD ? Direction.RIGHT_TO_LEFT : Direction.LEFT_TO_RIGHT;
} }
} }

View File

@@ -1,33 +1,33 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.io.Serializable; import java.io.Serializable;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass; import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.TableGenerator; import jakarta.persistence.TableGenerator;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
/** /**
* Abstract model for all entities, defining id and table generator * Abstract model for all entities, defining id and table generator
* *
*/ */
@SuppressWarnings("serial") @SuppressWarnings("serial")
@MappedSuperclass @MappedSuperclass
@Getter @Getter
@Setter @Setter
public abstract class AbstractModel implements Serializable { public abstract class AbstractModel implements Serializable {
@Id @Id
@GeneratedValue(strategy = GenerationType.TABLE, generator = "gen") @GeneratedValue(strategy = GenerationType.TABLE, generator = "gen")
@TableGenerator( @TableGenerator(
name = "gen", name = "gen",
table = "hibernate_sequences", table = "hibernate_sequences",
pkColumnName = "sequence_name", pkColumnName = "sequence_name",
valueColumnName = "sequence_next_hi_value", valueColumnName = "sequence_next_hi_value",
allocationSize = 1000) allocationSize = 1000)
private Long id; private Long id;
} }

View File

@@ -1,111 +1,111 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.sql.Types; import java.sql.Types;
import java.time.Instant; import java.time.Instant;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Lob; import jakarta.persistence.Lob;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.JdbcTypeCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "FEEDS") @Table(name = "FEEDS")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class Feed extends AbstractModel { public class Feed extends AbstractModel {
/** /**
* The url of the feed * The url of the feed
*/ */
@Lob @Lob
@Column(length = Integer.MAX_VALUE, nullable = false) @Column(length = Integer.MAX_VALUE, nullable = false)
@JdbcTypeCode(Types.LONGVARCHAR) @JdbcTypeCode(Types.LONGVARCHAR)
private String url; private String url;
/** /**
* cache the url after potential http 30x redirects * cache the url after potential http 30x redirects
*/ */
@Column(name = "url_after_redirect", length = 2048, nullable = false) @Column(name = "url_after_redirect", length = 2048, nullable = false)
private String urlAfterRedirect; private String urlAfterRedirect;
@Column(length = 2048, nullable = false) @Column(length = 2048, nullable = false)
private String normalizedUrl; private String normalizedUrl;
@Column(length = 40, nullable = false) @Column(length = 40, nullable = false)
private String normalizedUrlHash; private String normalizedUrlHash;
/** /**
* The url of the website, extracted from the feed * The url of the website, extracted from the feed
*/ */
@Lob @Lob
@Column(length = Integer.MAX_VALUE) @Column(length = Integer.MAX_VALUE)
@JdbcTypeCode(Types.LONGVARCHAR) @JdbcTypeCode(Types.LONGVARCHAR)
private String link; private String link;
/** /**
* Last time we tried to fetch the feed * Last time we tried to fetch the feed
*/ */
@Column @Column
private Instant lastUpdated; private Instant lastUpdated;
/** /**
* Last publishedDate value in the feed * Last publishedDate value in the feed
*/ */
@Column @Column
private Instant lastPublishedDate; private Instant lastPublishedDate;
/** /**
* date of the last entry of the feed * date of the last entry of the feed
*/ */
@Column @Column
private Instant lastEntryDate; private Instant lastEntryDate;
/** /**
* error message while retrieving the feed * error message while retrieving the feed
*/ */
@Lob @Lob
@Column(length = Integer.MAX_VALUE) @Column(length = Integer.MAX_VALUE)
@JdbcTypeCode(Types.LONGVARCHAR) @JdbcTypeCode(Types.LONGVARCHAR)
private String message; private String message;
/** /**
* times we failed to retrieve the feed * times we failed to retrieve the feed
*/ */
private int errorCount; private int errorCount;
/** /**
* feed refresh is disabled until this date * feed refresh is disabled until this date
*/ */
@Column @Column
private Instant disabledUntil; private Instant disabledUntil;
/** /**
* http header returned by the feed * http header returned by the feed
*/ */
@Column(length = 64) @Column(length = 64)
private String lastModifiedHeader; private String lastModifiedHeader;
/** /**
* http header returned by the feed * http header returned by the feed
*/ */
@Column(length = 255) @Column(length = 255)
private String etagHeader; private String etagHeader;
/** /**
* average time between entries in the feed * average time between entries in the feed
*/ */
private Long averageEntryInterval; private Long averageEntryInterval;
/** /**
* last hash of the content of the feed xml * last hash of the content of the feed xml
*/ */
@Column(length = 40) @Column(length = 40)
private String lastContentHash; private String lastContentHash;
} }

View File

@@ -1,43 +1,43 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.util.Set; import java.util.Set;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "FEEDCATEGORIES") @Table(name = "FEEDCATEGORIES")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class FeedCategory extends AbstractModel { public class FeedCategory extends AbstractModel {
@Column(length = 128, nullable = false) @Column(length = 128, nullable = false)
private String name; private String name;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private User user; private User user;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
private FeedCategory parent; private FeedCategory parent;
@OneToMany(mappedBy = "parent") @OneToMany(mappedBy = "parent")
private Set<FeedCategory> children; private Set<FeedCategory> children;
@OneToMany(mappedBy = "category") @OneToMany(mappedBy = "category")
private Set<FeedSubscription> subscriptions; private Set<FeedSubscription> subscriptions;
private boolean collapsed; private boolean collapsed;
private int position; private int position;
} }

View File

@@ -1,60 +1,60 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.time.Instant; import java.time.Instant;
import java.util.Set; import java.util.Set;
import jakarta.persistence.CascadeType; import jakarta.persistence.CascadeType;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "FEEDENTRIES") @Table(name = "FEEDENTRIES")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class FeedEntry extends AbstractModel { public class FeedEntry extends AbstractModel {
@Column(length = 2048, nullable = false) @Column(length = 2048, nullable = false)
private String guid; private String guid;
@Column(length = 40, nullable = false) @Column(length = 40, nullable = false)
private String guidHash; private String guidHash;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
private Feed feed; private Feed feed;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(nullable = false, updatable = false) @JoinColumn(nullable = false, updatable = false)
private FeedEntryContent content; private FeedEntryContent content;
@Column(length = 2048) @Column(length = 2048)
private String url; private String url;
/** /**
* the moment the entry was inserted in the database * the moment the entry was inserted in the database
*/ */
@Column @Column
private Instant inserted; private Instant inserted;
/** /**
* the moment the entry was published in the feed * the moment the entry was published in the feed
* *
*/ */
@Column(name = "updated") @Column(name = "updated")
private Instant published; private Instant published;
@OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE) @OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE)
private Set<FeedEntryStatus> statuses; private Set<FeedEntryStatus> statuses;
@OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE) @OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE)
private Set<FeedEntryTag> tags; private Set<FeedEntryTag> tags;
} }

View File

@@ -1,105 +1,105 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.sql.Types; import java.sql.Types;
import java.util.Set; import java.util.Set;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import jakarta.persistence.Lob; import jakarta.persistence.Lob;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.EqualsBuilder;
import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.JdbcTypeCode;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "FEEDENTRYCONTENTS") @Table(name = "FEEDENTRYCONTENTS")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class FeedEntryContent extends AbstractModel { public class FeedEntryContent extends AbstractModel {
public enum Direction { public enum Direction {
ltr, rtl, unknown ltr, rtl, unknown
} }
@Column(length = 2048) @Column(length = 2048)
private String title; private String title;
@Column(length = 40) @Column(length = 40)
private String titleHash; private String titleHash;
@Lob @Lob
@Column(length = Integer.MAX_VALUE) @Column(length = Integer.MAX_VALUE)
@JdbcTypeCode(Types.LONGVARCHAR) @JdbcTypeCode(Types.LONGVARCHAR)
private String content; private String content;
@Column(length = 40) @Column(length = 40)
private String contentHash; private String contentHash;
@Column(name = "author", length = 128) @Column(name = "author", length = 128)
private String author; private String author;
@Column(length = 2048) @Column(length = 2048)
private String enclosureUrl; private String enclosureUrl;
@Column(length = 255) @Column(length = 255)
private String enclosureType; private String enclosureType;
@Lob @Lob
@Column(length = Integer.MAX_VALUE) @Column(length = Integer.MAX_VALUE)
@JdbcTypeCode(Types.LONGVARCHAR) @JdbcTypeCode(Types.LONGVARCHAR)
private String mediaDescription; private String mediaDescription;
@Column(length = 2048) @Column(length = 2048)
private String mediaThumbnailUrl; private String mediaThumbnailUrl;
private Integer mediaThumbnailWidth; private Integer mediaThumbnailWidth;
private Integer mediaThumbnailHeight; private Integer mediaThumbnailHeight;
@Column(length = 4096) @Column(length = 4096)
private String categories; private String categories;
@Column @Column
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Direction direction = Direction.unknown; private Direction direction = Direction.unknown;
@OneToMany(mappedBy = "content") @OneToMany(mappedBy = "content")
private Set<FeedEntry> entries; private Set<FeedEntry> entries;
public boolean equivalentTo(FeedEntryContent c) { public boolean equivalentTo(FeedEntryContent c) {
if (c == null) { if (c == null) {
return false; return false;
} }
return new EqualsBuilder().append(title, c.title) return new EqualsBuilder().append(title, c.title)
.append(content, c.content) .append(content, c.content)
.append(author, c.author) .append(author, c.author)
.append(categories, c.categories) .append(categories, c.categories)
.append(enclosureUrl, c.enclosureUrl) .append(enclosureUrl, c.enclosureUrl)
.append(enclosureType, c.enclosureType) .append(enclosureType, c.enclosureType)
.append(mediaDescription, c.mediaDescription) .append(mediaDescription, c.mediaDescription)
.append(mediaThumbnailUrl, c.mediaThumbnailUrl) .append(mediaThumbnailUrl, c.mediaThumbnailUrl)
.append(mediaThumbnailWidth, c.mediaThumbnailWidth) .append(mediaThumbnailWidth, c.mediaThumbnailWidth)
.append(mediaThumbnailHeight, c.mediaThumbnailHeight) .append(mediaThumbnailHeight, c.mediaThumbnailHeight)
.build(); .build();
} }
public boolean isRTL() { public boolean isRTL() {
if (direction == Direction.rtl) { if (direction == Direction.rtl) {
return true; return true;
} else if (direction == Direction.ltr) { } else if (direction == Direction.ltr) {
return false; return false;
} else { } else {
// detect on the fly for content that was inserted before the direction field was added // detect on the fly for content that was inserted before the direction field was added
return FeedUtils.isRTL(title, content); return FeedUtils.isRTL(title, content);
} }
} }
} }

View File

@@ -1,69 +1,69 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Transient; import jakarta.persistence.Transient;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "FEEDENTRYSTATUSES") @Table(name = "FEEDENTRYSTATUSES")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class FeedEntryStatus extends AbstractModel { public class FeedEntryStatus extends AbstractModel {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private FeedSubscription subscription; private FeedSubscription subscription;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private FeedEntry entry; private FeedEntry entry;
@Column(name = "read_status") @Column(name = "read_status")
private boolean read; private boolean read;
private boolean starred; private boolean starred;
@Transient @Transient
private boolean markable; private boolean markable;
@Transient @Transient
private List<FeedEntryTag> tags = new ArrayList<>(); private List<FeedEntryTag> tags = new ArrayList<>();
/** /**
* Denormalization starts here * Denormalization starts here
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private User user; private User user;
@Column @Column
private Instant entryInserted; private Instant entryInserted;
@Column(name = "entryUpdated") @Column(name = "entryUpdated")
private Instant entryPublished; private Instant entryPublished;
public FeedEntryStatus() { public FeedEntryStatus() {
} }
public FeedEntryStatus(User user, FeedSubscription subscription, FeedEntry entry) { public FeedEntryStatus(User user, FeedSubscription subscription, FeedEntry entry) {
this.user = user; this.user = user;
this.subscription = subscription; this.subscription = subscription;
this.entry = entry; this.entry = entry;
this.entryInserted = entry.getInserted(); this.entryInserted = entry.getInserted();
this.entryPublished = entry.getPublished(); this.entryPublished = entry.getPublished();
} }
} }

View File

@@ -1,40 +1,40 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "FEEDENTRYTAGS") @Table(name = "FEEDENTRYTAGS")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class FeedEntryTag extends AbstractModel { public class FeedEntryTag extends AbstractModel {
@JoinColumn(name = "user_id") @JoinColumn(name = "user_id")
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
private User user; private User user;
@JoinColumn(name = "entry_id") @JoinColumn(name = "entry_id")
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
private FeedEntry entry; private FeedEntry entry;
@Column(name = "name", length = 40) @Column(name = "name", length = 40)
private String name; private String name;
public FeedEntryTag() { public FeedEntryTag() {
} }
public FeedEntryTag(User user, FeedEntry entry, String name) { public FeedEntryTag(User user, FeedEntry entry, String name) {
this.name = name; this.name = name;
this.entry = entry; this.entry = entry;
this.user = user; this.user = user;
} }
} }

View File

@@ -1,46 +1,46 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.util.Set; import java.util.Set;
import jakarta.persistence.CascadeType; import jakarta.persistence.CascadeType;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "FEEDSUBSCRIPTIONS") @Table(name = "FEEDSUBSCRIPTIONS")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class FeedSubscription extends AbstractModel { public class FeedSubscription extends AbstractModel {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private User user; private User user;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private Feed feed; private Feed feed;
@Column(length = 128, nullable = false) @Column(length = 128, nullable = false)
private String title; private String title;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
private FeedCategory category; private FeedCategory category;
@OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE) @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE)
private Set<FeedEntryStatus> statuses; private Set<FeedEntryStatus> statuses;
private int position; private int position;
@Column(name = "filtering_expression", length = 4096) @Column(name = "filtering_expression", length = 4096)
private String filter; private String filter;
} }

View File

@@ -1,41 +1,41 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import org.hibernate.Hibernate; import org.hibernate.Hibernate;
import org.hibernate.HibernateException; import org.hibernate.HibernateException;
import org.hibernate.proxy.HibernateProxy; import org.hibernate.proxy.HibernateProxy;
import org.hibernate.proxy.LazyInitializer; import org.hibernate.proxy.LazyInitializer;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
@UtilityClass @UtilityClass
public class Models { public class Models {
public static final Instant MINIMUM_INSTANT = Instant.EPOCH public static final Instant MINIMUM_INSTANT = Instant.EPOCH
// mariadb timestamp range starts at 1970-01-01 00:00:01 // mariadb timestamp range starts at 1970-01-01 00:00:01
.plusSeconds(1) .plusSeconds(1)
// make sure the timestamp fits for all timezones // make sure the timestamp fits for all timezones
.plus(Duration.ofHours(24)); .plus(Duration.ofHours(24));
/** /**
* initialize a proxy * initialize a proxy
*/ */
public static void initialize(Object proxy) throws HibernateException { public static void initialize(Object proxy) throws HibernateException {
Hibernate.initialize(proxy); Hibernate.initialize(proxy);
} }
/** /**
* extract the id from the proxy without initializing it * extract the id from the proxy without initializing it
*/ */
public static Long getId(AbstractModel model) { public static Long getId(AbstractModel model) {
if (model instanceof HibernateProxy proxy) { if (model instanceof HibernateProxy proxy) {
LazyInitializer lazyInitializer = proxy.getHibernateLazyInitializer(); LazyInitializer lazyInitializer = proxy.getHibernateLazyInitializer();
if (lazyInitializer.isUninitialized()) { if (lazyInitializer.isUninitialized()) {
return (Long) lazyInitializer.getIdentifier(); return (Long) lazyInitializer.getIdentifier();
} }
} }
return model.getId(); return model.getId();
} }
} }

View File

@@ -1,59 +1,59 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.sql.Types; import java.sql.Types;
import java.time.Instant; import java.time.Instant;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Lob; import jakarta.persistence.Lob;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.JdbcTypeCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "USERS") @Table(name = "USERS")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class User extends AbstractModel { public class User extends AbstractModel {
@Column(length = 32, nullable = false, unique = true) @Column(length = 32, nullable = false, unique = true)
private String name; private String name;
@Column(length = 255, unique = true) @Column(length = 255, unique = true)
private String email; private String email;
@Lob @Lob
@Column(length = Integer.MAX_VALUE, nullable = false) @Column(length = Integer.MAX_VALUE, nullable = false)
@JdbcTypeCode(Types.LONGVARBINARY) @JdbcTypeCode(Types.LONGVARBINARY)
private byte[] password; private byte[] password;
@Column(length = 40, unique = true) @Column(length = 40, unique = true)
private String apiKey; private String apiKey;
@Lob @Lob
@Column(length = Integer.MAX_VALUE, nullable = false) @Column(length = Integer.MAX_VALUE, nullable = false)
@JdbcTypeCode(Types.LONGVARBINARY) @JdbcTypeCode(Types.LONGVARBINARY)
private byte[] salt; private byte[] salt;
@Column(nullable = false) @Column(nullable = false)
private boolean disabled; private boolean disabled;
@Column @Column
private Instant lastLogin; private Instant lastLogin;
@Column @Column
private Instant created; private Instant created;
@Column(length = 40) @Column(length = 40)
private String recoverPasswordToken; private String recoverPasswordToken;
@Column @Column
private Instant recoverPasswordTokenDate; private Instant recoverPasswordTokenDate;
@Column @Column
private Instant lastForceRefresh; private Instant lastForceRefresh;
} }

View File

@@ -1,43 +1,43 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne; import jakarta.persistence.OneToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "USERROLES") @Table(name = "USERROLES")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class UserRole extends AbstractModel { public class UserRole extends AbstractModel {
public enum Role { public enum Role {
USER, ADMIN USER, ADMIN
} }
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false) @JoinColumn(name = "user_id", nullable = false)
private User user; private User user;
@Column(name = "roleName", nullable = false) @Column(name = "roleName", nullable = false)
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Role role; private Role role;
public UserRole() { public UserRole() {
} }
public UserRole(User user, Role role) { public UserRole(User user, Role role) {
this.user = user; this.user = user;
this.role = role; this.role = role;
} }
} }

View File

@@ -1,107 +1,107 @@
package com.commafeed.backend.model; package com.commafeed.backend.model;
import java.sql.Types; import java.sql.Types;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob; import jakarta.persistence.Lob;
import jakarta.persistence.OneToOne; import jakarta.persistence.OneToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.JdbcTypeCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Table(name = "USERSETTINGS") @Table(name = "USERSETTINGS")
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Getter @Getter
@Setter @Setter
public class UserSettings extends AbstractModel { public class UserSettings extends AbstractModel {
public enum ReadingMode { public enum ReadingMode {
all, unread all, unread
} }
public enum ReadingOrder { public enum ReadingOrder {
asc, desc asc, desc
} }
public enum ViewMode { public enum ViewMode {
title, cozy, detailed, expanded title, cozy, detailed, expanded
} }
public enum ScrollMode { public enum ScrollMode {
always, never, if_needed always, never, if_needed
} }
public enum IconDisplayMode { public enum IconDisplayMode {
always, never, on_desktop, on_mobile always, never, on_desktop, on_mobile
} }
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true) @JoinColumn(name = "user_id", nullable = false, unique = true)
private User user; private User user;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
private ReadingMode readingMode; private ReadingMode readingMode;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
private ReadingOrder readingOrder; private ReadingOrder readingOrder;
@Column(name = "user_lang", length = 4) @Column(name = "user_lang", length = 4)
private String language; private String language;
private boolean showRead; private boolean showRead;
private boolean scrollMarks; private boolean scrollMarks;
@Lob @Lob
@Column(length = Integer.MAX_VALUE) @Column(length = Integer.MAX_VALUE)
@JdbcTypeCode(Types.LONGVARCHAR) @JdbcTypeCode(Types.LONGVARCHAR)
private String customCss; private String customCss;
@Lob @Lob
@Column(length = Integer.MAX_VALUE) @Column(length = Integer.MAX_VALUE)
@JdbcTypeCode(Types.LONGVARCHAR) @JdbcTypeCode(Types.LONGVARCHAR)
private String customJs; private String customJs;
@Column(name = "scroll_speed") @Column(name = "scroll_speed")
private int scrollSpeed; private int scrollSpeed;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
private ScrollMode scrollMode; private ScrollMode scrollMode;
private int entriesToKeepOnTopWhenScrolling; private int entriesToKeepOnTopWhenScrolling;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
private IconDisplayMode starIconDisplayMode; private IconDisplayMode starIconDisplayMode;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
private IconDisplayMode externalLinkIconDisplayMode; private IconDisplayMode externalLinkIconDisplayMode;
private boolean markAllAsReadConfirmation; private boolean markAllAsReadConfirmation;
private boolean customContextMenu; private boolean customContextMenu;
private boolean mobileFooter; private boolean mobileFooter;
private boolean unreadCountTitle; private boolean unreadCountTitle;
private boolean unreadCountFavicon; private boolean unreadCountFavicon;
private boolean email; private boolean email;
private boolean gmail; private boolean gmail;
private boolean facebook; private boolean facebook;
private boolean twitter; private boolean twitter;
private boolean tumblr; private boolean tumblr;
private boolean pocket; private boolean pocket;
private boolean instapaper; private boolean instapaper;
private boolean buffer; private boolean buffer;
} }

View File

@@ -1,85 +1,85 @@
package com.commafeed.backend.opml; package com.commafeed.backend.opml;
import java.util.Comparator; import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.ObjectUtils;
import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.rometools.opml.feed.opml.Attribute; import com.rometools.opml.feed.opml.Attribute;
import com.rometools.opml.feed.opml.Opml; import com.rometools.opml.feed.opml.Opml;
import com.rometools.opml.feed.opml.Outline; import com.rometools.opml.feed.opml.Outline;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class OPMLExporter { public class OPMLExporter {
private final FeedCategoryDAO feedCategoryDAO; private final FeedCategoryDAO feedCategoryDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO; private final FeedSubscriptionDAO feedSubscriptionDAO;
public Opml export(User user) { public Opml export(User user) {
Opml opml = new Opml(); Opml opml = new Opml();
opml.setFeedType("opml_1.0"); opml.setFeedType("opml_1.0");
opml.setTitle(String.format("%s subscriptions in CommaFeed", user.getName())); opml.setTitle(String.format("%s subscriptions in CommaFeed", user.getName()));
opml.setCreated(new Date()); opml.setCreated(new Date());
List<FeedCategory> categories = feedCategoryDAO.findAll(user); List<FeedCategory> categories = feedCategoryDAO.findAll(user);
categories.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0))); categories.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0)));
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user); List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
subscriptions.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0))); subscriptions.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0)));
// export root categories // export root categories
for (FeedCategory cat : categories.stream().filter(c -> c.getParent() == null).toList()) { for (FeedCategory cat : categories.stream().filter(c -> c.getParent() == null).toList()) {
opml.getOutlines().add(buildCategoryOutline(cat, categories, subscriptions)); opml.getOutlines().add(buildCategoryOutline(cat, categories, subscriptions));
} }
// export root subscriptions // export root subscriptions
for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) { for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
opml.getOutlines().add(buildSubscriptionOutline(sub)); opml.getOutlines().add(buildSubscriptionOutline(sub));
} }
return opml; return opml;
} }
private Outline buildCategoryOutline(FeedCategory cat, List<FeedCategory> categories, List<FeedSubscription> subscriptions) { private Outline buildCategoryOutline(FeedCategory cat, List<FeedCategory> categories, List<FeedSubscription> subscriptions) {
Outline outline = new Outline(); Outline outline = new Outline();
outline.setText(cat.getName()); outline.setText(cat.getName());
outline.setTitle(cat.getName()); outline.setTitle(cat.getName());
for (FeedCategory child : categories.stream() for (FeedCategory child : categories.stream()
.filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId())) .filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId()))
.toList()) { .toList()) {
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions)); outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
} }
for (FeedSubscription sub : subscriptions.stream() for (FeedSubscription sub : subscriptions.stream()
.filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId())) .filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId()))
.toList()) { .toList()) {
outline.getChildren().add(buildSubscriptionOutline(sub)); outline.getChildren().add(buildSubscriptionOutline(sub));
} }
return outline; return outline;
} }
private Outline buildSubscriptionOutline(FeedSubscription sub) { private Outline buildSubscriptionOutline(FeedSubscription sub) {
Outline outline = new Outline(); Outline outline = new Outline();
outline.setText(sub.getTitle()); outline.setText(sub.getTitle());
outline.setTitle(sub.getTitle()); outline.setTitle(sub.getTitle());
outline.setType("rss"); outline.setType("rss");
outline.getAttributes().add(new Attribute("xmlUrl", sub.getFeed().getUrl())); outline.getAttributes().add(new Attribute("xmlUrl", sub.getFeed().getUrl()));
if (sub.getFeed().getLink() != null) { if (sub.getFeed().getLink() != null) {
outline.getAttributes().add(new Attribute("htmlUrl", sub.getFeed().getLink())); outline.getAttributes().add(new Attribute("htmlUrl", sub.getFeed().getLink()));
} }
return outline; return outline;
} }
} }

View File

@@ -1,82 +1,82 @@
package com.commafeed.backend.opml; package com.commafeed.backend.opml;
import java.io.StringReader; import java.io.StringReader;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedSubscriptionService; import com.commafeed.backend.service.FeedSubscriptionService;
import com.rometools.opml.feed.opml.Opml; import com.rometools.opml.feed.opml.Opml;
import com.rometools.opml.feed.opml.Outline; import com.rometools.opml.feed.opml.Outline;
import com.rometools.rome.io.FeedException; import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.WireFeedInput; import com.rometools.rome.io.WireFeedInput;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class OPMLImporter { public class OPMLImporter {
private final FeedCategoryDAO feedCategoryDAO; private final FeedCategoryDAO feedCategoryDAO;
private final FeedSubscriptionService feedSubscriptionService; private final FeedSubscriptionService feedSubscriptionService;
public void importOpml(User user, String xml) throws IllegalArgumentException, FeedException { public void importOpml(User user, String xml) throws IllegalArgumentException, FeedException {
xml = xml.substring(xml.indexOf('<')); xml = xml.substring(xml.indexOf('<'));
WireFeedInput input = new WireFeedInput(); WireFeedInput input = new WireFeedInput();
Opml feed = (Opml) input.build(new StringReader(xml)); Opml feed = (Opml) input.build(new StringReader(xml));
List<Outline> outlines = feed.getOutlines(); List<Outline> outlines = feed.getOutlines();
for (int i = 0; i < outlines.size(); i++) { for (int i = 0; i < outlines.size(); i++) {
handleOutline(user, outlines.get(i), null, i); handleOutline(user, outlines.get(i), null, i);
} }
} }
private void handleOutline(User user, Outline outline, FeedCategory parent, int position) { private void handleOutline(User user, Outline outline, FeedCategory parent, int position) {
List<Outline> children = outline.getChildren(); List<Outline> children = outline.getChildren();
if (CollectionUtils.isNotEmpty(children)) { if (CollectionUtils.isNotEmpty(children)) {
String name = FeedUtils.truncate(outline.getText(), 128); String name = FeedUtils.truncate(outline.getText(), 128);
if (name == null) { if (name == null) {
name = FeedUtils.truncate(outline.getTitle(), 128); name = FeedUtils.truncate(outline.getTitle(), 128);
} }
FeedCategory category = feedCategoryDAO.findByName(user, name, parent); FeedCategory category = feedCategoryDAO.findByName(user, name, parent);
if (category == null) { if (category == null) {
if (StringUtils.isBlank(name)) { if (StringUtils.isBlank(name)) {
name = "Unnamed category"; name = "Unnamed category";
} }
category = new FeedCategory(); category = new FeedCategory();
category.setName(name); category.setName(name);
category.setParent(parent); category.setParent(parent);
category.setUser(user); category.setUser(user);
category.setPosition(position); category.setPosition(position);
feedCategoryDAO.saveOrUpdate(category); feedCategoryDAO.saveOrUpdate(category);
} }
for (int i = 0; i < children.size(); i++) { for (int i = 0; i < children.size(); i++) {
handleOutline(user, children.get(i), category, i); handleOutline(user, children.get(i), category, i);
} }
} else { } else {
String name = FeedUtils.truncate(outline.getText(), 128); String name = FeedUtils.truncate(outline.getText(), 128);
if (name == null) { if (name == null) {
name = FeedUtils.truncate(outline.getTitle(), 128); name = FeedUtils.truncate(outline.getTitle(), 128);
} }
if (StringUtils.isBlank(name)) { if (StringUtils.isBlank(name)) {
name = "Unnamed subscription"; name = "Unnamed subscription";
} }
// make sure we continue with the import process even if a feed failed // make sure we continue with the import process even if a feed failed
try { try {
feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position); feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position);
} catch (Exception e) { } catch (Exception e) {
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage()); log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
} }
} }
} }
} }

View File

@@ -1,26 +1,26 @@
package com.commafeed.backend.rome; package com.commafeed.backend.rome;
import org.jdom2.Element; import org.jdom2.Element;
import com.rometools.opml.feed.opml.Opml; import com.rometools.opml.feed.opml.Opml;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
/** /**
* Add missing title to the generated OPML * Add missing title to the generated OPML
* *
*/ */
@RegisterForReflection @RegisterForReflection
public class OPML11Generator extends com.rometools.opml.io.impl.OPML10Generator { public class OPML11Generator extends com.rometools.opml.io.impl.OPML10Generator {
public OPML11Generator() { public OPML11Generator() {
super("opml_1.1"); super("opml_1.1");
} }
@Override @Override
protected Element generateHead(Opml opml) { protected Element generateHead(Opml opml) {
Element head = new Element("head"); Element head = new Element("head");
addNotNullSimpleElement(head, "title", opml.getTitle()); addNotNullSimpleElement(head, "title", opml.getTitle());
return head; return head;
} }
} }

View File

@@ -1,38 +1,38 @@
package com.commafeed.backend.rome; package com.commafeed.backend.rome;
import java.util.Locale; import java.util.Locale;
import org.jdom2.Document; import org.jdom2.Document;
import org.jdom2.Element; import org.jdom2.Element;
import com.rometools.opml.io.impl.OPML10Parser; import com.rometools.opml.io.impl.OPML10Parser;
import com.rometools.rome.feed.WireFeed; import com.rometools.rome.feed.WireFeed;
import com.rometools.rome.io.FeedException; import com.rometools.rome.io.FeedException;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
/** /**
* Support for OPML 1.1 parsing * Support for OPML 1.1 parsing
* *
*/ */
@RegisterForReflection @RegisterForReflection
public class OPML11Parser extends OPML10Parser { public class OPML11Parser extends OPML10Parser {
public OPML11Parser() { public OPML11Parser() {
super("opml_1.1"); super("opml_1.1");
} }
@Override @Override
public boolean isMyType(Document document) { public boolean isMyType(Document document) {
Element e = document.getRootElement(); Element e = document.getRootElement();
return e.getName().equals("opml"); return e.getName().equals("opml");
} }
@Override @Override
public WireFeed parse(Document document, boolean validate, Locale locale) throws IllegalArgumentException, FeedException { public WireFeed parse(Document document, boolean validate, Locale locale) throws IllegalArgumentException, FeedException {
document.getRootElement().getChildren().add(new Element("head")); document.getRootElement().getChildren().add(new Element("head"));
return super.parse(document, validate, locale); return super.parse(document, validate, locale);
} }
} }

View File

@@ -1,30 +1,30 @@
package com.commafeed.backend.rome; package com.commafeed.backend.rome;
import com.rometools.rome.feed.rss.Description; import com.rometools.rome.feed.rss.Description;
import com.rometools.rome.feed.rss.Item; import com.rometools.rome.feed.rss.Item;
import com.rometools.rome.feed.synd.SyndContentImpl; import com.rometools.rome.feed.synd.SyndContentImpl;
import com.rometools.rome.feed.synd.SyndEntry; import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.impl.ConverterForRSS090; import com.rometools.rome.feed.synd.impl.ConverterForRSS090;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
/** /**
* Support description tag for RSS09 * Support description tag for RSS09
* *
*/ */
@RegisterForReflection @RegisterForReflection
public class RSS090DescriptionConverter extends ConverterForRSS090 { public class RSS090DescriptionConverter extends ConverterForRSS090 {
@Override @Override
protected SyndEntry createSyndEntry(Item item, boolean preserveWireItem) { protected SyndEntry createSyndEntry(Item item, boolean preserveWireItem) {
SyndEntry entry = super.createSyndEntry(item, preserveWireItem); SyndEntry entry = super.createSyndEntry(item, preserveWireItem);
Description desc = item.getDescription(); Description desc = item.getDescription();
if (desc != null) { if (desc != null) {
SyndContentImpl syndDesc = new SyndContentImpl(); SyndContentImpl syndDesc = new SyndContentImpl();
syndDesc.setValue(desc.getValue()); syndDesc.setValue(desc.getValue());
entry.setDescription(syndDesc); entry.setDescription(syndDesc);
} }
return entry; return entry;
} }
} }

View File

@@ -1,32 +1,32 @@
package com.commafeed.backend.rome; package com.commafeed.backend.rome;
import java.util.Locale; import java.util.Locale;
import org.jdom2.Element; import org.jdom2.Element;
import com.rometools.rome.feed.rss.Description; import com.rometools.rome.feed.rss.Description;
import com.rometools.rome.feed.rss.Item; import com.rometools.rome.feed.rss.Item;
import com.rometools.rome.io.impl.RSS090Parser; import com.rometools.rome.io.impl.RSS090Parser;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
/** /**
* Support description tag for RSS09 * Support description tag for RSS09
* *
*/ */
@RegisterForReflection @RegisterForReflection
public class RSS090DescriptionParser extends RSS090Parser { public class RSS090DescriptionParser extends RSS090Parser {
@Override @Override
protected Item parseItem(Element rssRoot, Element eItem, Locale locale) { protected Item parseItem(Element rssRoot, Element eItem, Locale locale) {
Item item = super.parseItem(rssRoot, eItem, locale); Item item = super.parseItem(rssRoot, eItem, locale);
Element e = eItem.getChild("description", getRSSNamespace()); Element e = eItem.getChild("description", getRSSNamespace());
if (e != null) { if (e != null) {
Description desc = new Description(); Description desc = new Description();
desc.setValue(e.getText()); desc.setValue(e.getText());
item.setDescription(desc); item.setDescription(desc);
} }
return item; return item;
} }
} }

View File

@@ -1,48 +1,48 @@
package com.commafeed.backend.rome; package com.commafeed.backend.rome;
import java.util.List; import java.util.List;
import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.CollectionUtils;
import org.jdom2.Document; import org.jdom2.Document;
import org.jdom2.Element; import org.jdom2.Element;
import org.jdom2.Namespace; import org.jdom2.Namespace;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.rometools.rome.io.impl.RSS10Parser; import com.rometools.rome.io.impl.RSS10Parser;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection @RegisterForReflection
public class RSSRDF10Parser extends RSS10Parser { public class RSSRDF10Parser extends RSS10Parser {
private static final String RSS_URI = "http://purl.org/rss/1.0/"; private static final String RSS_URI = "http://purl.org/rss/1.0/";
private static final Namespace RSS_NS = Namespace.getNamespace(RSS_URI); private static final Namespace RSS_NS = Namespace.getNamespace(RSS_URI);
public RSSRDF10Parser() { public RSSRDF10Parser() {
super("rss_1.0", RSS_NS); super("rss_1.0", RSS_NS);
} }
@Override @Override
public boolean isMyType(Document document) { public boolean isMyType(Document document) {
boolean ok; boolean ok;
Element rssRoot = document.getRootElement(); Element rssRoot = document.getRootElement();
Namespace defaultNS = rssRoot.getNamespace(); Namespace defaultNS = rssRoot.getNamespace();
List<Namespace> additionalNSs = Lists.newArrayList(rssRoot.getAdditionalNamespaces()); List<Namespace> additionalNSs = Lists.newArrayList(rssRoot.getAdditionalNamespaces());
List<Element> children = rssRoot.getChildren(); List<Element> children = rssRoot.getChildren();
if (CollectionUtils.isNotEmpty(children)) { if (CollectionUtils.isNotEmpty(children)) {
Element child = children.get(0); Element child = children.get(0);
additionalNSs.add(child.getNamespace()); additionalNSs.add(child.getNamespace());
additionalNSs.addAll(child.getAdditionalNamespaces()); additionalNSs.addAll(child.getAdditionalNamespaces());
} }
ok = defaultNS != null && defaultNS.equals(getRDFNamespace()); ok = defaultNS != null && defaultNS.equals(getRDFNamespace());
if (ok) { if (ok) {
ok = false; ok = false;
for (int i = 0; !ok && i < additionalNSs.size(); i++) { for (int i = 0; !ok && i < additionalNSs.size(); i++) {
ok = getRSSNamespace().equals(additionalNSs.get(i)); ok = getRSSNamespace().equals(additionalNSs.get(i));
} }
} }
return ok; return ok;
} }
} }

View File

@@ -1,175 +1,175 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.io.StringReader; import java.io.StringReader;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.nodes.Document.OutputSettings; import org.jsoup.nodes.Document.OutputSettings;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.jsoup.nodes.Entities.EscapeMode; import org.jsoup.nodes.Entities.EscapeMode;
import org.jsoup.safety.Cleaner; import org.jsoup.safety.Cleaner;
import org.jsoup.safety.Safelist; import org.jsoup.safety.Safelist;
import org.w3c.css.sac.CSSException; import org.w3c.css.sac.CSSException;
import org.w3c.css.sac.CSSParseException; import org.w3c.css.sac.CSSParseException;
import org.w3c.css.sac.ErrorHandler; import org.w3c.css.sac.ErrorHandler;
import org.w3c.css.sac.InputSource; import org.w3c.css.sac.InputSource;
import org.w3c.dom.css.CSSStyleDeclaration; import org.w3c.dom.css.CSSStyleDeclaration;
import com.steadystate.css.parser.CSSOMParser; import com.steadystate.css.parser.CSSOMParser;
import com.steadystate.css.parser.SACParserCSS21; import com.steadystate.css.parser.SACParserCSS21;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@Singleton @Singleton
public class FeedEntryContentCleaningService { public class FeedEntryContentCleaningService {
private static final Safelist HTML_WHITELIST = buildWhiteList(); private static final Safelist HTML_WHITELIST = buildWhiteList();
private static final List<String> ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border"); private static final List<String> ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border");
private static final List<String> ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height"); private static final List<String> ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height");
private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] { '(', ')' }; private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] { '(', ')' };
public String clean(String content, String baseUri, boolean keepTextOnly) { public String clean(String content, String baseUri, boolean keepTextOnly) {
if (StringUtils.isNotBlank(content)) { if (StringUtils.isNotBlank(content)) {
baseUri = StringUtils.trimToEmpty(baseUri); baseUri = StringUtils.trimToEmpty(baseUri);
Document dirty = Jsoup.parseBodyFragment(content, baseUri); Document dirty = Jsoup.parseBodyFragment(content, baseUri);
Cleaner cleaner = new Cleaner(HTML_WHITELIST); Cleaner cleaner = new Cleaner(HTML_WHITELIST);
Document clean = cleaner.clean(dirty); Document clean = cleaner.clean(dirty);
for (Element e : clean.select("iframe[style]")) { for (Element e : clean.select("iframe[style]")) {
String style = e.attr("style"); String style = e.attr("style");
String escaped = escapeIFrameCss(style); String escaped = escapeIFrameCss(style);
e.attr("style", escaped); e.attr("style", escaped);
} }
for (Element e : clean.select("img[style]")) { for (Element e : clean.select("img[style]")) {
String style = e.attr("style"); String style = e.attr("style");
String escaped = escapeImgCss(style); String escaped = escapeImgCss(style);
e.attr("style", escaped); e.attr("style", escaped);
} }
clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false)); clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
Element body = clean.body(); Element body = clean.body();
if (keepTextOnly) { if (keepTextOnly) {
content = body.text(); content = body.text();
} else { } else {
content = body.html(); content = body.html();
} }
} }
return content; return content;
} }
private static Safelist buildWhiteList() { private static Safelist buildWhiteList() {
Safelist whitelist = new Safelist(); Safelist whitelist = new Safelist();
whitelist.addTags("a", "b", "blockquote", "br", "caption", "cite", "code", "col", "colgroup", "dd", "div", "dl", "dt", "em", "h1", whitelist.addTags("a", "b", "blockquote", "br", "caption", "cite", "code", "col", "colgroup", "dd", "div", "dl", "dt", "em", "h1",
"h2", "h3", "h4", "h5", "h6", "i", "iframe", "img", "li", "ol", "p", "pre", "q", "small", "strike", "strong", "sub", "sup", "h2", "h3", "h4", "h5", "h6", "i", "iframe", "img", "li", "ol", "p", "pre", "q", "small", "strike", "strong", "sub", "sup",
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul"); "table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul");
whitelist.addAttributes("div", "dir"); whitelist.addAttributes("div", "dir");
whitelist.addAttributes("pre", "dir"); whitelist.addAttributes("pre", "dir");
whitelist.addAttributes("code", "dir"); whitelist.addAttributes("code", "dir");
whitelist.addAttributes("table", "dir"); whitelist.addAttributes("table", "dir");
whitelist.addAttributes("p", "dir"); whitelist.addAttributes("p", "dir");
whitelist.addAttributes("a", "href", "title"); whitelist.addAttributes("a", "href", "title");
whitelist.addAttributes("blockquote", "cite"); whitelist.addAttributes("blockquote", "cite");
whitelist.addAttributes("col", "span", "width"); whitelist.addAttributes("col", "span", "width");
whitelist.addAttributes("colgroup", "span", "width"); whitelist.addAttributes("colgroup", "span", "width");
whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style"); whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style"); whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style");
whitelist.addAttributes("ol", "start", "type"); whitelist.addAttributes("ol", "start", "type");
whitelist.addAttributes("q", "cite"); whitelist.addAttributes("q", "cite");
whitelist.addAttributes("table", "border", "bordercolor", "summary", "width"); whitelist.addAttributes("table", "border", "bordercolor", "summary", "width");
whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width"); whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width"); whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width");
whitelist.addAttributes("ul", "type"); whitelist.addAttributes("ul", "type");
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto"); whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
whitelist.addProtocols("blockquote", "cite", "http", "https"); whitelist.addProtocols("blockquote", "cite", "http", "https");
whitelist.addProtocols("img", "src", "http", "https"); whitelist.addProtocols("img", "src", "http", "https");
whitelist.addProtocols("q", "cite", "http", "https"); whitelist.addProtocols("q", "cite", "http", "https");
whitelist.addEnforcedAttribute("a", "target", "_blank"); whitelist.addEnforcedAttribute("a", "target", "_blank");
whitelist.addEnforcedAttribute("a", "rel", "noreferrer"); whitelist.addEnforcedAttribute("a", "rel", "noreferrer");
return whitelist; return whitelist;
} }
private String escapeIFrameCss(String orig) { private String escapeIFrameCss(String orig) {
String rule = ""; String rule = "";
try { try {
List<String> rules = new ArrayList<>(); List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig))); CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) { for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i); String property = decl.item(i);
String value = decl.getPropertyValue(property); String value = decl.getPropertyValue(property);
if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) { if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) {
continue; continue;
} }
if (ALLOWED_IFRAME_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) { if (ALLOWED_IFRAME_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) {
rules.add(property + ":" + decl.getPropertyValue(property) + ";"); rules.add(property + ":" + decl.getPropertyValue(property) + ";");
} }
} }
rule = StringUtils.join(rules, ""); rule = StringUtils.join(rules, "");
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
return rule; return rule;
} }
private String escapeImgCss(String orig) { private String escapeImgCss(String orig) {
String rule = ""; String rule = "";
try { try {
List<String> rules = new ArrayList<>(); List<String> rules = new ArrayList<>();
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig))); CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
for (int i = 0; i < decl.getLength(); i++) { for (int i = 0; i < decl.getLength(); i++) {
String property = decl.item(i); String property = decl.item(i);
String value = decl.getPropertyValue(property); String value = decl.getPropertyValue(property);
if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) { if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) {
continue; continue;
} }
if (ALLOWED_IMG_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) { if (ALLOWED_IMG_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) {
rules.add(property + ":" + decl.getPropertyValue(property) + ";"); rules.add(property + ":" + decl.getPropertyValue(property) + ";");
} }
} }
rule = StringUtils.join(rules, ""); rule = StringUtils.join(rules, "");
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
return rule; return rule;
} }
private CSSOMParser buildCssParser() { private CSSOMParser buildCssParser() {
CSSOMParser parser = new CSSOMParser(new SACParserCSS21()); CSSOMParser parser = new CSSOMParser(new SACParserCSS21());
parser.setErrorHandler(new ErrorHandler() { parser.setErrorHandler(new ErrorHandler() {
@Override @Override
public void warning(CSSParseException exception) throws CSSException { public void warning(CSSParseException exception) throws CSSException {
log.debug("warning while parsing css: {}", exception.getMessage(), exception); log.debug("warning while parsing css: {}", exception.getMessage(), exception);
} }
@Override @Override
public void error(CSSParseException exception) throws CSSException { public void error(CSSParseException exception) throws CSSException {
log.debug("error while parsing css: {}", exception.getMessage(), exception); log.debug("error while parsing css: {}", exception.getMessage(), exception);
} }
@Override @Override
public void fatalError(CSSParseException exception) throws CSSException { public void fatalError(CSSParseException exception) throws CSSException {
log.debug("fatal error while parsing css: {}", exception.getMessage(), exception); log.debug("fatal error while parsing css: {}", exception.getMessage(), exception);
} }
}); });
return parser; return parser;
} }
} }

View File

@@ -1,71 +1,71 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.util.Optional; import java.util.Optional;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.commafeed.backend.Digests; import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.FeedEntryContentDAO; import com.commafeed.backend.dao.FeedEntryContentDAO;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.feed.parser.FeedParserResult.Content; import com.commafeed.backend.feed.parser.FeedParserResult.Content;
import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure; import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure;
import com.commafeed.backend.feed.parser.FeedParserResult.Media; import com.commafeed.backend.feed.parser.FeedParserResult.Media;
import com.commafeed.backend.model.FeedEntryContent; import com.commafeed.backend.model.FeedEntryContent;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class FeedEntryContentService { public class FeedEntryContentService {
private final FeedEntryContentDAO feedEntryContentDAO; private final FeedEntryContentDAO feedEntryContentDAO;
private final FeedEntryContentCleaningService cleaningService; private final FeedEntryContentCleaningService cleaningService;
/** /**
* this is NOT thread-safe * this is NOT thread-safe
*/ */
public FeedEntryContent findOrCreate(Content content, String baseUrl) { public FeedEntryContent findOrCreate(Content content, String baseUrl) {
FeedEntryContent entryContent = buildContent(content, baseUrl); FeedEntryContent entryContent = buildContent(content, baseUrl);
Optional<FeedEntryContent> existing = feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash()) Optional<FeedEntryContent> existing = feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash())
.stream() .stream()
.filter(entryContent::equivalentTo) .filter(entryContent::equivalentTo)
.findFirst(); .findFirst();
if (existing.isPresent()) { if (existing.isPresent()) {
return existing.get(); return existing.get();
} else { } else {
feedEntryContentDAO.saveOrUpdate(entryContent); feedEntryContentDAO.saveOrUpdate(entryContent);
return entryContent; return entryContent;
} }
} }
private FeedEntryContent buildContent(Content content, String baseUrl) { private FeedEntryContent buildContent(Content content, String baseUrl) {
FeedEntryContent entryContent = new FeedEntryContent(); FeedEntryContent entryContent = new FeedEntryContent();
entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title()))); entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title())));
entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content()))); entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content())));
entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048)); entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false)); entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false));
entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128)); entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096)); entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096));
entryContent.setDirection( entryContent.setDirection(
FeedUtils.isRTL(content.title(), content.content()) ? FeedEntryContent.Direction.rtl : FeedEntryContent.Direction.ltr); FeedUtils.isRTL(content.title(), content.content()) ? FeedEntryContent.Direction.rtl : FeedEntryContent.Direction.ltr);
Enclosure enclosure = content.enclosure(); Enclosure enclosure = content.enclosure();
if (enclosure != null) { if (enclosure != null) {
entryContent.setEnclosureUrl(FeedUtils.truncate(enclosure.url(), 2048)); entryContent.setEnclosureUrl(FeedUtils.truncate(enclosure.url(), 2048));
entryContent.setEnclosureType(enclosure.type()); entryContent.setEnclosureType(enclosure.type());
} }
Media media = content.media(); Media media = content.media();
if (media != null) { if (media != null) {
entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false)); entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false));
entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048)); entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048));
entryContent.setMediaThumbnailWidth(media.thumbnailWidth()); entryContent.setMediaThumbnailWidth(media.thumbnailWidth());
entryContent.setMediaThumbnailHeight(media.thumbnailHeight()); entryContent.setMediaThumbnailHeight(media.thumbnailHeight());
} }
return entryContent; return entryContent;
} }
} }

View File

@@ -1,124 +1,124 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.time.Year; import java.time.Year;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.jexl2.JexlContext; import org.apache.commons.jexl2.JexlContext;
import org.apache.commons.jexl2.JexlEngine; import org.apache.commons.jexl2.JexlEngine;
import org.apache.commons.jexl2.JexlException; import org.apache.commons.jexl2.JexlException;
import org.apache.commons.jexl2.JexlInfo; import org.apache.commons.jexl2.JexlInfo;
import org.apache.commons.jexl2.MapContext; import org.apache.commons.jexl2.MapContext;
import org.apache.commons.jexl2.Script; import org.apache.commons.jexl2.Script;
import org.apache.commons.jexl2.introspection.JexlMethod; import org.apache.commons.jexl2.introspection.JexlMethod;
import org.apache.commons.jexl2.introspection.JexlPropertyGet; import org.apache.commons.jexl2.introspection.JexlPropertyGet;
import org.apache.commons.jexl2.introspection.Uberspect; import org.apache.commons.jexl2.introspection.Uberspect;
import org.apache.commons.jexl2.introspection.UberspectImpl; import org.apache.commons.jexl2.introspection.UberspectImpl;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.LogFactory; import org.apache.commons.logging.LogFactory;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class FeedEntryFilteringService { public class FeedEntryFilteringService {
private static final JexlEngine ENGINE = initEngine(); private static final JexlEngine ENGINE = initEngine();
private final ExecutorService executor = Executors.newCachedThreadPool(); private final ExecutorService executor = Executors.newCachedThreadPool();
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private static JexlEngine initEngine() { private static JexlEngine initEngine() {
// classloader that prevents object creation // classloader that prevents object creation
ClassLoader cl = new ClassLoader() { ClassLoader cl = new ClassLoader() {
@Override @Override
protected Class<?> loadClass(String name, boolean resolve) { protected Class<?> loadClass(String name, boolean resolve) {
return null; return null;
} }
}; };
// uberspect that prevents access to .class and .getClass() // uberspect that prevents access to .class and .getClass()
Uberspect uberspect = new UberspectImpl(LogFactory.getLog(JexlEngine.class)) { Uberspect uberspect = new UberspectImpl(LogFactory.getLog(JexlEngine.class)) {
@Override @Override
public JexlPropertyGet getPropertyGet(Object obj, Object identifier, JexlInfo info) { public JexlPropertyGet getPropertyGet(Object obj, Object identifier, JexlInfo info) {
if ("class".equals(identifier)) { if ("class".equals(identifier)) {
return null; return null;
} }
return super.getPropertyGet(obj, identifier, info); return super.getPropertyGet(obj, identifier, info);
} }
@Override @Override
public JexlMethod getMethod(Object obj, String method, Object[] args, JexlInfo info) { public JexlMethod getMethod(Object obj, String method, Object[] args, JexlInfo info) {
if ("getClass".equals(method)) { if ("getClass".equals(method)) {
return null; return null;
} }
return super.getMethod(obj, method, args, info); return super.getMethod(obj, method, args, info);
} }
}; };
JexlEngine engine = new JexlEngine(uberspect, null, null, null); JexlEngine engine = new JexlEngine(uberspect, null, null, null);
engine.setStrict(true); engine.setStrict(true);
engine.setClassLoader(cl); engine.setClassLoader(cl);
return engine; return engine;
} }
public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException { public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException {
if (StringUtils.isBlank(filter)) { if (StringUtils.isBlank(filter)) {
return true; return true;
} }
Script script; Script script;
try { try {
script = ENGINE.createScript(filter); script = ENGINE.createScript(filter);
} catch (JexlException e) { } catch (JexlException e) {
throw new FeedEntryFilterException("Exception while parsing expression " + filter, e); throw new FeedEntryFilterException("Exception while parsing expression " + filter, e);
} }
JexlContext context = new MapContext(); JexlContext context = new MapContext();
context.set("title", entry.getContent().getTitle() == null ? "" : Jsoup.parse(entry.getContent().getTitle()).text().toLowerCase()); context.set("title", entry.getContent().getTitle() == null ? "" : Jsoup.parse(entry.getContent().getTitle()).text().toLowerCase());
context.set("author", entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor().toLowerCase()); context.set("author", entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor().toLowerCase());
context.set("content", context.set("content",
entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text().toLowerCase()); entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text().toLowerCase());
context.set("url", entry.getUrl() == null ? "" : entry.getUrl().toLowerCase()); context.set("url", entry.getUrl() == null ? "" : entry.getUrl().toLowerCase());
context.set("categories", entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories().toLowerCase()); context.set("categories", entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories().toLowerCase());
context.set("year", Year.now().getValue()); context.set("year", Year.now().getValue());
Callable<Object> callable = script.callable(context); Callable<Object> callable = script.callable(context);
Future<Object> future = executor.submit(callable); Future<Object> future = executor.submit(callable);
Object result; Object result;
try { try {
result = future.get(config.feedRefresh().filteringExpressionEvaluationTimeout().toMillis(), TimeUnit.MILLISECONDS); result = future.get(config.feedRefresh().filteringExpressionEvaluationTimeout().toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e); throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e);
} catch (ExecutionException e) { } catch (ExecutionException e) {
throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e); throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e);
} catch (TimeoutException e) { } catch (TimeoutException e) {
throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e); throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e);
} }
try { try {
return (boolean) result; return (boolean) result;
} catch (ClassCastException e) { } catch (ClassCastException e) {
throw new FeedEntryFilterException(e.getMessage(), e); throw new FeedEntryFilterException(e.getMessage(), e);
} }
} }
@SuppressWarnings("serial") @SuppressWarnings("serial")
public static class FeedEntryFilterException extends Exception { public static class FeedEntryFilterException extends Exception {
public FeedEntryFilterException(String message, Throwable t) { public FeedEntryFilterException(String message, Throwable t) {
super(message, t); super(message, t);
} }
} }
} }

View File

@@ -1,131 +1,131 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.Digests; import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.FeedEntryDAO; import com.commafeed.backend.dao.FeedEntryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.feed.FeedEntryKeyword; import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.feed.parser.FeedParserResult.Entry; import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryStatus; import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class FeedEntryService { public class FeedEntryService {
private final FeedSubscriptionDAO feedSubscriptionDAO; private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryDAO feedEntryDAO; private final FeedEntryDAO feedEntryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO; private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedEntryContentService feedEntryContentService; private final FeedEntryContentService feedEntryContentService;
private final FeedEntryFilteringService feedEntryFilteringService; private final FeedEntryFilteringService feedEntryFilteringService;
public FeedEntry find(Feed feed, Entry entry) { public FeedEntry find(Feed feed, Entry entry) {
String guidHash = Digests.sha1Hex(entry.guid()); String guidHash = Digests.sha1Hex(entry.guid());
return feedEntryDAO.findExisting(guidHash, feed); return feedEntryDAO.findExisting(guidHash, feed);
} }
public FeedEntry create(Feed feed, Entry entry) { public FeedEntry create(Feed feed, Entry entry) {
FeedEntry feedEntry = new FeedEntry(); FeedEntry feedEntry = new FeedEntry();
feedEntry.setGuid(FeedUtils.truncate(entry.guid(), 2048)); feedEntry.setGuid(FeedUtils.truncate(entry.guid(), 2048));
feedEntry.setGuidHash(Digests.sha1Hex(entry.guid())); feedEntry.setGuidHash(Digests.sha1Hex(entry.guid()));
feedEntry.setUrl(FeedUtils.truncate(entry.url(), 2048)); feedEntry.setUrl(FeedUtils.truncate(entry.url(), 2048));
feedEntry.setPublished(entry.published()); feedEntry.setPublished(entry.published());
feedEntry.setInserted(Instant.now()); feedEntry.setInserted(Instant.now());
feedEntry.setFeed(feed); feedEntry.setFeed(feed);
feedEntry.setContent(feedEntryContentService.findOrCreate(entry.content(), feed.getLink())); feedEntry.setContent(feedEntryContentService.findOrCreate(entry.content(), feed.getLink()));
feedEntryDAO.saveOrUpdate(feedEntry); feedEntryDAO.saveOrUpdate(feedEntry);
return feedEntry; return feedEntry;
} }
public boolean applyFilter(FeedSubscription sub, FeedEntry entry) { public boolean applyFilter(FeedSubscription sub, FeedEntry entry) {
boolean matches = true; boolean matches = true;
try { try {
matches = feedEntryFilteringService.filterMatchesEntry(sub.getFilter(), entry); matches = feedEntryFilteringService.filterMatchesEntry(sub.getFilter(), entry);
} catch (FeedEntryFilterException e) { } catch (FeedEntryFilterException e) {
log.error("could not evaluate filter {}", sub.getFilter(), e); log.error("could not evaluate filter {}", sub.getFilter(), e);
} }
if (!matches) { if (!matches) {
FeedEntryStatus status = new FeedEntryStatus(sub.getUser(), sub, entry); FeedEntryStatus status = new FeedEntryStatus(sub.getUser(), sub, entry);
status.setRead(true); status.setRead(true);
feedEntryStatusDAO.saveOrUpdate(status); feedEntryStatusDAO.saveOrUpdate(status);
} }
return matches; return matches;
} }
public void markEntry(User user, Long entryId, boolean read) { public void markEntry(User user, Long entryId, boolean read) {
FeedEntry entry = feedEntryDAO.findById(entryId); FeedEntry entry = feedEntryDAO.findById(entryId);
if (entry == null) { if (entry == null) {
return; return;
} }
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed()); FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
if (sub == null) { if (sub == null) {
return; return;
} }
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
if (status.isMarkable()) { if (status.isMarkable()) {
status.setRead(read); status.setRead(read);
feedEntryStatusDAO.saveOrUpdate(status); feedEntryStatusDAO.saveOrUpdate(status);
} }
} }
public void starEntry(User user, Long entryId, Long subscriptionId, boolean starred) { public void starEntry(User user, Long entryId, Long subscriptionId, boolean starred) {
FeedSubscription sub = feedSubscriptionDAO.findById(user, subscriptionId); FeedSubscription sub = feedSubscriptionDAO.findById(user, subscriptionId);
if (sub == null) { if (sub == null) {
return; return;
} }
FeedEntry entry = feedEntryDAO.findById(entryId); FeedEntry entry = feedEntryDAO.findById(entryId);
if (entry == null) { if (entry == null) {
return; return;
} }
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
status.setStarred(starred); status.setStarred(starred);
feedEntryStatusDAO.saveOrUpdate(status); feedEntryStatusDAO.saveOrUpdate(status);
} }
public void markSubscriptionEntries(User user, List<FeedSubscription> subscriptions, Instant olderThan, Instant insertedBefore, public void markSubscriptionEntries(User user, List<FeedSubscription> subscriptions, Instant olderThan, Instant insertedBefore,
List<FeedEntryKeyword> keywords) { List<FeedEntryKeyword> keywords) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null, List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null,
false, null, null, null); false, null, null, null);
markList(statuses, olderThan, insertedBefore); markList(statuses, olderThan, insertedBefore);
} }
public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) { public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, -1, -1, null, false); List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, -1, -1, null, false);
markList(statuses, olderThan, insertedBefore); markList(statuses, olderThan, insertedBefore);
} }
private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) { private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statusesToMark = statuses.stream().filter(FeedEntryStatus::isMarkable).filter(s -> { List<FeedEntryStatus> statusesToMark = statuses.stream().filter(FeedEntryStatus::isMarkable).filter(s -> {
Instant entryDate = s.getEntry().getPublished(); Instant entryDate = s.getEntry().getPublished();
return olderThan == null || entryDate == null || entryDate.isBefore(olderThan); return olderThan == null || entryDate == null || entryDate.isBefore(olderThan);
}).filter(s -> { }).filter(s -> {
Instant insertedDate = s.getEntry().getInserted(); Instant insertedDate = s.getEntry().getInserted();
return insertedBefore == null || insertedDate == null || insertedDate.isBefore(insertedBefore); return insertedBefore == null || insertedDate == null || insertedDate.isBefore(insertedBefore);
}).toList(); }).toList();
statusesToMark.forEach(s -> s.setRead(true)); statusesToMark.forEach(s -> s.setRead(true));
feedEntryStatusDAO.saveOrUpdate(statusesToMark); feedEntryStatusDAO.saveOrUpdate(statusesToMark);
} }
} }

View File

@@ -1,43 +1,43 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.dao.FeedEntryDAO; import com.commafeed.backend.dao.FeedEntryDAO;
import com.commafeed.backend.dao.FeedEntryTagDAO; import com.commafeed.backend.dao.FeedEntryTagDAO;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag; import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class FeedEntryTagService { public class FeedEntryTagService {
private final FeedEntryDAO feedEntryDAO; private final FeedEntryDAO feedEntryDAO;
private final FeedEntryTagDAO feedEntryTagDAO; private final FeedEntryTagDAO feedEntryTagDAO;
public void updateTags(User user, Long entryId, List<String> tagNames) { public void updateTags(User user, Long entryId, List<String> tagNames) {
FeedEntry entry = feedEntryDAO.findById(entryId); FeedEntry entry = feedEntryDAO.findById(entryId);
if (entry == null) { if (entry == null) {
return; return;
} }
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry); List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
Set<String> existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet()); Set<String> existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
List<FeedEntryTag> addList = tagNames.stream() List<FeedEntryTag> addList = tagNames.stream()
.filter(name -> !existingTagNames.contains(name)) .filter(name -> !existingTagNames.contains(name))
.map(name -> new FeedEntryTag(user, entry, name)) .map(name -> new FeedEntryTag(user, entry, name))
.toList(); .toList();
List<FeedEntryTag> removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList(); List<FeedEntryTag> removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
feedEntryTagDAO.saveOrUpdate(addList); feedEntryTagDAO.saveOrUpdate(addList);
feedEntryTagDAO.delete(removeList); feedEntryTagDAO.delete(removeList);
} }
} }

View File

@@ -1,80 +1,80 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.io.IOException; import java.io.IOException;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.Digests; import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.FeedDAO; import com.commafeed.backend.dao.FeedDAO;
import com.commafeed.backend.favicon.AbstractFaviconFetcher; import com.commafeed.backend.favicon.AbstractFaviconFetcher;
import com.commafeed.backend.favicon.Favicon; import com.commafeed.backend.favicon.Favicon;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.Models; import com.commafeed.backend.model.Models;
import com.google.common.io.Resources; import com.google.common.io.Resources;
import io.quarkus.arc.All; import io.quarkus.arc.All;
@Singleton @Singleton
public class FeedService { public class FeedService {
private final FeedDAO feedDAO; private final FeedDAO feedDAO;
private final List<AbstractFaviconFetcher> faviconFetchers; private final List<AbstractFaviconFetcher> faviconFetchers;
private final Favicon defaultFavicon; private final Favicon defaultFavicon;
public FeedService(FeedDAO feedDAO, @All List<AbstractFaviconFetcher> faviconFetchers) { public FeedService(FeedDAO feedDAO, @All List<AbstractFaviconFetcher> faviconFetchers) {
this.feedDAO = feedDAO; this.feedDAO = feedDAO;
this.faviconFetchers = faviconFetchers; this.faviconFetchers = faviconFetchers;
try { try {
defaultFavicon = new Favicon( defaultFavicon = new Favicon(
Resources.toByteArray(Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))), "image/gif"); Resources.toByteArray(Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))), "image/gif");
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("could not load default favicon", e); throw new RuntimeException("could not load default favicon", e);
} }
} }
public synchronized Feed findOrCreate(String url) { public synchronized Feed findOrCreate(String url) {
String normalizedUrl = FeedUtils.normalizeURL(url); String normalizedUrl = FeedUtils.normalizeURL(url);
String normalizedUrlHash = Digests.sha1Hex(normalizedUrl); String normalizedUrlHash = Digests.sha1Hex(normalizedUrl);
Feed feed = feedDAO.findByUrl(normalizedUrl, normalizedUrlHash); Feed feed = feedDAO.findByUrl(normalizedUrl, normalizedUrlHash);
if (feed == null) { if (feed == null) {
feed = new Feed(); feed = new Feed();
feed.setUrl(url); feed.setUrl(url);
feed.setNormalizedUrl(normalizedUrl); feed.setNormalizedUrl(normalizedUrl);
feed.setNormalizedUrlHash(normalizedUrlHash); feed.setNormalizedUrlHash(normalizedUrlHash);
feed.setDisabledUntil(Models.MINIMUM_INSTANT); feed.setDisabledUntil(Models.MINIMUM_INSTANT);
feedDAO.persist(feed); feedDAO.persist(feed);
} }
return feed; return feed;
} }
public void update(Feed feed) { public void update(Feed feed) {
String normalized = FeedUtils.normalizeURL(feed.getUrl()); String normalized = FeedUtils.normalizeURL(feed.getUrl());
feed.setNormalizedUrl(normalized); feed.setNormalizedUrl(normalized);
feed.setNormalizedUrlHash(Digests.sha1Hex(normalized)); feed.setNormalizedUrlHash(Digests.sha1Hex(normalized));
feed.setLastUpdated(Instant.now()); feed.setLastUpdated(Instant.now());
feed.setEtagHeader(FeedUtils.truncate(feed.getEtagHeader(), 255)); feed.setEtagHeader(FeedUtils.truncate(feed.getEtagHeader(), 255));
feedDAO.merge(feed); feedDAO.merge(feed);
} }
public Favicon fetchFavicon(Feed feed) { public Favicon fetchFavicon(Feed feed) {
Favicon icon = null; Favicon icon = null;
for (AbstractFaviconFetcher faviconFetcher : faviconFetchers) { for (AbstractFaviconFetcher faviconFetcher : faviconFetchers) {
icon = faviconFetcher.fetch(feed); icon = faviconFetcher.fetch(feed);
if (icon != null) { if (icon != null) {
break; break;
} }
} }
if (icon == null) { if (icon == null) {
icon = defaultFavicon; icon = defaultFavicon;
} }
return icon; return icon;
} }
} }

View File

@@ -1,148 +1,148 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedDAO; import com.commafeed.backend.dao.FeedDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.feed.FeedRefreshEngine; import com.commafeed.backend.feed.FeedRefreshEngine;
import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.frontend.model.UnreadCount; import com.commafeed.frontend.model.UnreadCount;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@Singleton @Singleton
public class FeedSubscriptionService { public class FeedSubscriptionService {
private final FeedDAO feedDAO; private final FeedDAO feedDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO; private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO; private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedService feedService; private final FeedService feedService;
private final FeedRefreshEngine feedRefreshEngine; private final FeedRefreshEngine feedRefreshEngine;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
public FeedSubscriptionService(FeedDAO feedDAO, FeedEntryStatusDAO feedEntryStatusDAO, FeedSubscriptionDAO feedSubscriptionDAO, public FeedSubscriptionService(FeedDAO feedDAO, FeedEntryStatusDAO feedEntryStatusDAO, FeedSubscriptionDAO feedSubscriptionDAO,
FeedService feedService, FeedRefreshEngine feedRefreshEngine, CommaFeedConfiguration config) { FeedService feedService, FeedRefreshEngine feedRefreshEngine, CommaFeedConfiguration config) {
this.feedDAO = feedDAO; this.feedDAO = feedDAO;
this.feedEntryStatusDAO = feedEntryStatusDAO; this.feedEntryStatusDAO = feedEntryStatusDAO;
this.feedSubscriptionDAO = feedSubscriptionDAO; this.feedSubscriptionDAO = feedSubscriptionDAO;
this.feedService = feedService; this.feedService = feedService;
this.feedRefreshEngine = feedRefreshEngine; this.feedRefreshEngine = feedRefreshEngine;
this.config = config; this.config = config;
// automatically refresh new feeds after they are subscribed to // automatically refresh new feeds after they are subscribed to
// we need to use this hook because the feed needs to have been persisted before being processed by the feed engine // we need to use this hook because the feed needs to have been persisted before being processed by the feed engine
feedSubscriptionDAO.onPostCommitInsert(sub -> { feedSubscriptionDAO.onPostCommitInsert(sub -> {
Feed feed = sub.getFeed(); Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) { if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) {
feedRefreshEngine.refreshImmediately(feed); feedRefreshEngine.refreshImmediately(feed);
} }
}); });
} }
public long subscribe(User user, String url, String title) { public long subscribe(User user, String url, String title) {
return subscribe(user, url, title, null, 0); return subscribe(user, url, title, null, 0);
} }
public long subscribe(User user, String url, String title, FeedCategory parent) { public long subscribe(User user, String url, String title, FeedCategory parent) {
return subscribe(user, url, title, parent, 0); return subscribe(user, url, title, parent, 0);
} }
public long subscribe(User user, String url, String title, FeedCategory category, int position) { public long subscribe(User user, String url, String title, FeedCategory category, int position) {
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser(); Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) { if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
String message = String.format("You cannot subscribe to more feeds on this CommaFeed instance (max %s feeds per user)", String message = String.format("You cannot subscribe to more feeds on this CommaFeed instance (max %s feeds per user)",
maxFeedsPerUser); maxFeedsPerUser);
throw new FeedSubscriptionException(message); throw new FeedSubscriptionException(message);
} }
Feed feed = feedService.findOrCreate(url); Feed feed = feedService.findOrCreate(url);
// upgrade feed to https if it was using http // upgrade feed to https if it was using http
if (FeedUtils.isHttp(feed.getUrl()) && FeedUtils.isHttps(url)) { if (FeedUtils.isHttp(feed.getUrl()) && FeedUtils.isHttps(url)) {
feed.setUrl(url); feed.setUrl(url);
feedDAO.saveOrUpdate(feed); feedDAO.saveOrUpdate(feed);
} }
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, feed); FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, feed);
if (sub == null) { if (sub == null) {
sub = new FeedSubscription(); sub = new FeedSubscription();
sub.setFeed(feed); sub.setFeed(feed);
sub.setUser(user); sub.setUser(user);
} }
sub.setCategory(category); sub.setCategory(category);
sub.setPosition(position); sub.setPosition(position);
sub.setTitle(FeedUtils.truncate(title, 128)); sub.setTitle(FeedUtils.truncate(title, 128));
feedSubscriptionDAO.saveOrUpdate(sub); feedSubscriptionDAO.saveOrUpdate(sub);
return sub.getId(); return sub.getId();
} }
public boolean unsubscribe(User user, Long subId) { public boolean unsubscribe(User user, Long subId) {
FeedSubscription sub = feedSubscriptionDAO.findById(user, subId); FeedSubscription sub = feedSubscriptionDAO.findById(user, subId);
if (sub != null) { if (sub != null) {
feedSubscriptionDAO.delete(sub); feedSubscriptionDAO.delete(sub);
return true; return true;
} else { } else {
return false; return false;
} }
} }
public void refreshAll(User user) throws ForceFeedRefreshTooSoonException { public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
Instant lastForceRefresh = user.getLastForceRefresh(); Instant lastForceRefresh = user.getLastForceRefresh();
if (lastForceRefresh != null && lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) { if (lastForceRefresh != null && lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) {
throw new ForceFeedRefreshTooSoonException(); throw new ForceFeedRefreshTooSoonException();
} }
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user); List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
for (FeedSubscription sub : subs) { for (FeedSubscription sub : subs) {
Feed feed = sub.getFeed(); Feed feed = sub.getFeed();
feedRefreshEngine.refreshImmediately(feed); feedRefreshEngine.refreshImmediately(feed);
} }
user.setLastForceRefresh(Instant.now()); user.setLastForceRefresh(Instant.now());
} }
public void refreshAllUpForRefresh(User user) { public void refreshAllUpForRefresh(User user) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user); List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
for (FeedSubscription sub : subs) { for (FeedSubscription sub : subs) {
Instant disabledUntil = sub.getFeed().getDisabledUntil(); Instant disabledUntil = sub.getFeed().getDisabledUntil();
if (disabledUntil == null || disabledUntil.isBefore(Instant.now())) { if (disabledUntil == null || disabledUntil.isBefore(Instant.now())) {
Feed feed = sub.getFeed(); Feed feed = sub.getFeed();
feedRefreshEngine.refreshImmediately(feed); feedRefreshEngine.refreshImmediately(feed);
} }
} }
} }
public Map<Long, UnreadCount> getUnreadCount(User user) { public Map<Long, UnreadCount> getUnreadCount(User user) {
return feedSubscriptionDAO.findAll(user) return feedSubscriptionDAO.findAll(user)
.stream() .stream()
.collect(Collectors.toMap(FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount)); .collect(Collectors.toMap(FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount));
} }
@SuppressWarnings("serial") @SuppressWarnings("serial")
public static class FeedSubscriptionException extends RuntimeException { public static class FeedSubscriptionException extends RuntimeException {
private FeedSubscriptionException(String msg) { private FeedSubscriptionException(String msg) {
super(msg); super(msg);
} }
} }
@SuppressWarnings("serial") @SuppressWarnings("serial")
public static class ForceFeedRefreshTooSoonException extends Exception { public static class ForceFeedRefreshTooSoonException extends Exception {
private ForceFeedRefreshTooSoonException() { private ForceFeedRefreshTooSoonException() {
super(); super();
} }
} }
} }

View File

@@ -1,21 +1,21 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import io.quarkus.mailer.Mail; import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer; import io.quarkus.mailer.Mailer;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class MailService { public class MailService {
private final Mailer mailer; private final Mailer mailer;
public void sendMail(User user, String subject, String content) { public void sendMail(User user, String subject, String content) {
Mail mail = Mail.withHtml(user.getEmail(), "CommaFeed - " + subject, content); Mail mail = Mail.withHtml(user.getEmail(), "CommaFeed - " + subject, content);
mailer.send(mail); mailer.send(mail);
} }
} }

View File

@@ -1,95 +1,95 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.io.Serializable; import java.io.Serializable;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.security.spec.KeySpec; import java.security.spec.KeySpec;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory; import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.PBEKeySpec;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
// taken from http://www.javacodegeeks.com/2012/05/secure-password-storage-donts-dos-and.html // taken from http://www.javacodegeeks.com/2012/05/secure-password-storage-donts-dos-and.html
@SuppressWarnings("serial") @SuppressWarnings("serial")
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class PasswordEncryptionService implements Serializable { public class PasswordEncryptionService implements Serializable {
public boolean authenticate(String attemptedPassword, byte[] encryptedPassword, byte[] salt) { public boolean authenticate(String attemptedPassword, byte[] encryptedPassword, byte[] salt) {
if (StringUtils.isBlank(attemptedPassword)) { if (StringUtils.isBlank(attemptedPassword)) {
return false; return false;
} }
// Encrypt the clear-text password using the same salt that was used to // Encrypt the clear-text password using the same salt that was used to
// encrypt the original password // encrypt the original password
byte[] encryptedAttemptedPassword = null; byte[] encryptedAttemptedPassword = null;
try { try {
encryptedAttemptedPassword = getEncryptedPassword(attemptedPassword, salt); encryptedAttemptedPassword = getEncryptedPassword(attemptedPassword, salt);
} catch (Exception e) { } catch (Exception e) {
// should never happen // should never happen
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
if (encryptedAttemptedPassword == null) { if (encryptedAttemptedPassword == null) {
return false; return false;
} }
// Authentication succeeds if encrypted password that the user entered // Authentication succeeds if encrypted password that the user entered
// is equal to the stored hash // is equal to the stored hash
return MessageDigest.isEqual(encryptedPassword, encryptedAttemptedPassword); return MessageDigest.isEqual(encryptedPassword, encryptedAttemptedPassword);
} }
public byte[] getEncryptedPassword(String password, byte[] salt) { public byte[] getEncryptedPassword(String password, byte[] salt) {
// PBKDF2 with SHA-1 as the hashing algorithm. Note that the NIST // PBKDF2 with SHA-1 as the hashing algorithm. Note that the NIST
// specifically names SHA-1 as an acceptable hashing algorithm for // specifically names SHA-1 as an acceptable hashing algorithm for
// PBKDF2 // PBKDF2
String algorithm = "PBKDF2WithHmacSHA1"; String algorithm = "PBKDF2WithHmacSHA1";
// SHA-1 generates 160 bit hashes, so that's what makes sense here // SHA-1 generates 160 bit hashes, so that's what makes sense here
int derivedKeyLength = 160; int derivedKeyLength = 160;
// Pick an iteration count that works for you. The NIST recommends at // Pick an iteration count that works for you. The NIST recommends at
// least 1,000 iterations: // least 1,000 iterations:
// http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf // http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf
// iOS 4.x reportedly uses 10,000: // iOS 4.x reportedly uses 10,000:
// http://blog.crackpassword.com/2010/09/smartphone-forensics-cracking-blackberry-backup-passwords/ // http://blog.crackpassword.com/2010/09/smartphone-forensics-cracking-blackberry-backup-passwords/
int iterations = 20000; int iterations = 20000;
KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, derivedKeyLength); KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, derivedKeyLength);
byte[] bytes = null; byte[] bytes = null;
try { try {
SecretKeyFactory f = SecretKeyFactory.getInstance(algorithm); SecretKeyFactory f = SecretKeyFactory.getInstance(algorithm);
SecretKey key = f.generateSecret(spec); SecretKey key = f.generateSecret(spec);
bytes = key.getEncoded(); bytes = key.getEncoded();
} catch (Exception e) { } catch (Exception e) {
// should never happen // should never happen
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
return bytes; return bytes;
} }
public byte[] generateSalt() { public byte[] generateSalt() {
// VERY important to use SecureRandom instead of just Random // VERY important to use SecureRandom instead of just Random
byte[] salt = null; byte[] salt = null;
try { try {
SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
// Generate a 8 byte (64 bit) salt as recommended by RSA PKCS5 // Generate a 8 byte (64 bit) salt as recommended by RSA PKCS5
salt = new byte[8]; salt = new byte[8];
random.nextBytes(salt); random.nextBytes(salt);
} catch (NoSuchAlgorithmException e) { } catch (NoSuchAlgorithmException e) {
// should never happen // should never happen
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
return salt; return salt;
} }
} }

View File

@@ -1,166 +1,166 @@
package com.commafeed.backend.service; package com.commafeed.backend.service;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.commafeed.CommaFeedApplication; import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.Digests; import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO; import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.dao.UserSettingsDAO; import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole; import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role; import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.service.internal.PostLoginActivities; import com.commafeed.backend.service.internal.PostLoginActivities;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class UserService { public class UserService {
private final FeedCategoryDAO feedCategoryDAO; private final FeedCategoryDAO feedCategoryDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO; private final FeedSubscriptionDAO feedSubscriptionDAO;
private final UserDAO userDAO; private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO; private final UserRoleDAO userRoleDAO;
private final UserSettingsDAO userSettingsDAO; private final UserSettingsDAO userSettingsDAO;
private final PasswordEncryptionService encryptionService; private final PasswordEncryptionService encryptionService;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final PostLoginActivities postLoginActivities; private final PostLoginActivities postLoginActivities;
/** /**
* try to log in with given credentials * try to log in with given credentials
*/ */
public Optional<User> login(String nameOrEmail, String password) { public Optional<User> login(String nameOrEmail, String password) {
if (nameOrEmail == null || password == null) { if (nameOrEmail == null || password == null) {
return Optional.empty(); return Optional.empty();
} }
User user = userDAO.findByName(nameOrEmail); User user = userDAO.findByName(nameOrEmail);
if (user == null) { if (user == null) {
user = userDAO.findByEmail(nameOrEmail); user = userDAO.findByEmail(nameOrEmail);
} }
if (user != null && !user.isDisabled()) { if (user != null && !user.isDisabled()) {
boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt()); boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt());
if (authenticated) { if (authenticated) {
performPostLoginActivities(user); performPostLoginActivities(user);
return Optional.of(user); return Optional.of(user);
} }
} }
return Optional.empty(); return Optional.empty();
} }
/** /**
* try to log in with given api key * try to log in with given api key
*/ */
public Optional<User> login(String apiKey) { public Optional<User> login(String apiKey) {
if (apiKey == null) { if (apiKey == null) {
return Optional.empty(); return Optional.empty();
} }
User user = userDAO.findByApiKey(apiKey); User user = userDAO.findByApiKey(apiKey);
if (user != null && !user.isDisabled()) { if (user != null && !user.isDisabled()) {
performPostLoginActivities(user); performPostLoginActivities(user);
return Optional.of(user); return Optional.of(user);
} }
return Optional.empty(); return Optional.empty();
} }
/** /**
* try to log in with given fever api key * try to log in with given fever api key
*/ */
public Optional<User> login(long userId, String feverApiKey) { public Optional<User> login(long userId, String feverApiKey) {
if (feverApiKey == null) { if (feverApiKey == null) {
return Optional.empty(); return Optional.empty();
} }
User user = userDAO.findById(userId); User user = userDAO.findById(userId);
if (user == null || user.isDisabled() || user.getApiKey() == null) { if (user == null || user.isDisabled() || user.getApiKey() == null) {
return Optional.empty(); return Optional.empty();
} }
String computedFeverApiKey = Digests.md5Hex(user.getName() + ":" + user.getApiKey()); String computedFeverApiKey = Digests.md5Hex(user.getName() + ":" + user.getApiKey());
if (!computedFeverApiKey.equals(feverApiKey)) { if (!computedFeverApiKey.equals(feverApiKey)) {
return Optional.empty(); return Optional.empty();
} }
performPostLoginActivities(user); performPostLoginActivities(user);
return Optional.of(user); return Optional.of(user);
} }
/** /**
* should triggers after successful login * should triggers after successful login
*/ */
public void performPostLoginActivities(User user) { public void performPostLoginActivities(User user) {
postLoginActivities.executeFor(user); postLoginActivities.executeFor(user);
} }
public User register(String name, String password, String email, Collection<Role> roles) { public User register(String name, String password, String email, Collection<Role> roles) {
return register(name, password, email, roles, false); return register(name, password, email, roles, false);
} }
public User register(String name, String password, String email, Collection<Role> roles, boolean forceRegistration) { public User register(String name, String password, String email, Collection<Role> roles, boolean forceRegistration) {
if (!forceRegistration) { if (!forceRegistration) {
Preconditions.checkState(config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance"); Preconditions.checkState(config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance");
} }
Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken"); Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken");
if (StringUtils.isNotBlank(email)) { if (StringUtils.isNotBlank(email)) {
Preconditions.checkArgument(userDAO.findByEmail(email) == null, "Email already taken"); Preconditions.checkArgument(userDAO.findByEmail(email) == null, "Email already taken");
} }
User user = new User(); User user = new User();
byte[] salt = encryptionService.generateSalt(); byte[] salt = encryptionService.generateSalt();
user.setName(name); user.setName(name);
user.setEmail(email); user.setEmail(email);
user.setCreated(Instant.now()); user.setCreated(Instant.now());
user.setSalt(salt); user.setSalt(salt);
user.setPassword(encryptionService.getEncryptedPassword(password, salt)); user.setPassword(encryptionService.getEncryptedPassword(password, salt));
userDAO.saveOrUpdate(user); userDAO.saveOrUpdate(user);
for (Role role : roles) { for (Role role : roles) {
userRoleDAO.saveOrUpdate(new UserRole(user, role)); userRoleDAO.saveOrUpdate(new UserRole(user, role));
} }
return user; return user;
} }
public void createAdminUser() { public void createAdminUser() {
register(CommaFeedApplication.USERNAME_ADMIN, "admin", "admin@commafeed.com", Arrays.asList(Role.ADMIN, Role.USER), true); register(CommaFeedApplication.USERNAME_ADMIN, "admin", "admin@commafeed.com", Arrays.asList(Role.ADMIN, Role.USER), true);
} }
public void createDemoUser() { public void createDemoUser() {
register(CommaFeedApplication.USERNAME_DEMO, "demo", "demo@commafeed.com", Collections.singletonList(Role.USER), true); register(CommaFeedApplication.USERNAME_DEMO, "demo", "demo@commafeed.com", Collections.singletonList(Role.USER), true);
} }
public void unregister(User user) { public void unregister(User user) {
userSettingsDAO.delete(userSettingsDAO.findByUser(user)); userSettingsDAO.delete(userSettingsDAO.findByUser(user));
userRoleDAO.delete(userRoleDAO.findAll(user)); userRoleDAO.delete(userRoleDAO.findAll(user));
feedSubscriptionDAO.delete(feedSubscriptionDAO.findAll(user)); feedSubscriptionDAO.delete(feedSubscriptionDAO.findAll(user));
feedCategoryDAO.delete(feedCategoryDAO.findAll(user)); feedCategoryDAO.delete(feedCategoryDAO.findAll(user));
userDAO.delete(user); userDAO.delete(user);
} }
public String generateApiKey(User user) { public String generateApiKey(User user) {
byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt()); byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt());
return Digests.sha1Hex(key); return Digests.sha1Hex(key);
} }
public Set<Role> getRoles(User user) { public Set<Role> getRoles(User user) {
return userRoleDAO.findRoles(user); return userRoleDAO.findRoles(user);
} }
} }

View File

@@ -1,133 +1,133 @@
package com.commafeed.backend.service.db; package com.commafeed.backend.service.db;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedDAO; import com.commafeed.backend.dao.FeedDAO;
import com.commafeed.backend.dao.FeedEntryContentDAO; import com.commafeed.backend.dao.FeedEntryContentDAO;
import com.commafeed.backend.dao.FeedEntryDAO; import com.commafeed.backend.dao.FeedEntryDAO;
import com.commafeed.backend.dao.FeedEntryDAO.FeedCapacity; import com.commafeed.backend.dao.FeedEntryDAO.FeedCapacity;
import com.commafeed.backend.dao.FeedEntryStatusDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Contains utility methods for cleaning the database * Contains utility methods for cleaning the database
* *
*/ */
@Slf4j @Slf4j
@Singleton @Singleton
public class DatabaseCleaningService { public class DatabaseCleaningService {
private final int batchSize; private final int batchSize;
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
private final FeedDAO feedDAO; private final FeedDAO feedDAO;
private final FeedEntryDAO feedEntryDAO; private final FeedEntryDAO feedEntryDAO;
private final FeedEntryContentDAO feedEntryContentDAO; private final FeedEntryContentDAO feedEntryContentDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO; private final FeedEntryStatusDAO feedEntryStatusDAO;
private final Meter entriesDeletedMeter; private final Meter entriesDeletedMeter;
public DatabaseCleaningService(CommaFeedConfiguration config, UnitOfWork unitOfWork, FeedDAO feedDAO, FeedEntryDAO feedEntryDAO, public DatabaseCleaningService(CommaFeedConfiguration config, UnitOfWork unitOfWork, FeedDAO feedDAO, FeedEntryDAO feedEntryDAO,
FeedEntryContentDAO feedEntryContentDAO, FeedEntryStatusDAO feedEntryStatusDAO, MetricRegistry metrics) { FeedEntryContentDAO feedEntryContentDAO, FeedEntryStatusDAO feedEntryStatusDAO, MetricRegistry metrics) {
this.unitOfWork = unitOfWork; this.unitOfWork = unitOfWork;
this.feedDAO = feedDAO; this.feedDAO = feedDAO;
this.feedEntryDAO = feedEntryDAO; this.feedEntryDAO = feedEntryDAO;
this.feedEntryContentDAO = feedEntryContentDAO; this.feedEntryContentDAO = feedEntryContentDAO;
this.feedEntryStatusDAO = feedEntryStatusDAO; this.feedEntryStatusDAO = feedEntryStatusDAO;
this.batchSize = config.database().cleanup().batchSize(); this.batchSize = config.database().cleanup().batchSize();
this.entriesDeletedMeter = metrics.meter(MetricRegistry.name(getClass(), "entriesDeleted")); this.entriesDeletedMeter = metrics.meter(MetricRegistry.name(getClass(), "entriesDeleted"));
} }
public void cleanFeedsWithoutSubscriptions() { public void cleanFeedsWithoutSubscriptions() {
log.info("cleaning feeds without subscriptions"); log.info("cleaning feeds without subscriptions");
long total = 0; long total = 0;
int deleted; int deleted;
long entriesTotal = 0; long entriesTotal = 0;
do { do {
List<Feed> feeds = unitOfWork.call(() -> feedDAO.findWithoutSubscriptions(1)); List<Feed> feeds = unitOfWork.call(() -> feedDAO.findWithoutSubscriptions(1));
for (Feed feed : feeds) { for (Feed feed : feeds) {
long entriesDeleted; long entriesDeleted;
do { do {
entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize)); entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
entriesDeletedMeter.mark(entriesDeleted); entriesDeletedMeter.mark(entriesDeleted);
entriesTotal += entriesDeleted; entriesTotal += entriesDeleted;
log.debug("removed {} entries for feeds without subscriptions", entriesTotal); log.debug("removed {} entries for feeds without subscriptions", entriesTotal);
} while (entriesDeleted > 0); } while (entriesDeleted > 0);
} }
deleted = unitOfWork.call(() -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList()))); deleted = unitOfWork.call(() -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList())));
total += deleted; total += deleted;
log.debug("removed {} feeds without subscriptions", total); log.debug("removed {} feeds without subscriptions", total);
} while (deleted != 0); } while (deleted != 0);
log.info("cleanup done: {} feeds without subscriptions deleted", total); log.info("cleanup done: {} feeds without subscriptions deleted", total);
} }
public void cleanContentsWithoutEntries() { public void cleanContentsWithoutEntries() {
log.info("cleaning contents without entries"); log.info("cleaning contents without entries");
long total = 0; long total = 0;
long deleted; long deleted;
do { do {
deleted = unitOfWork.call(() -> feedEntryContentDAO.deleteWithoutEntries(batchSize)); deleted = unitOfWork.call(() -> feedEntryContentDAO.deleteWithoutEntries(batchSize));
total += deleted; total += deleted;
log.debug("removed {} contents without entries", total); log.debug("removed {} contents without entries", total);
} while (deleted != 0); } while (deleted != 0);
log.info("cleanup done: {} contents without entries deleted", total); log.info("cleanup done: {} contents without entries deleted", total);
} }
public void cleanEntriesForFeedsExceedingCapacity(final int maxFeedCapacity) { public void cleanEntriesForFeedsExceedingCapacity(final int maxFeedCapacity) {
log.info("cleaning entries exceeding feed capacity"); log.info("cleaning entries exceeding feed capacity");
long total = 0; long total = 0;
while (true) { while (true) {
List<FeedCapacity> feeds = unitOfWork.call(() -> feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize)); List<FeedCapacity> feeds = unitOfWork.call(() -> feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize));
if (feeds.isEmpty()) { if (feeds.isEmpty()) {
break; break;
} }
for (final FeedCapacity feed : feeds) { for (final FeedCapacity feed : feeds) {
long remaining = feed.getCapacity() - maxFeedCapacity; long remaining = feed.getCapacity() - maxFeedCapacity;
do { do {
final long rem = remaining; final long rem = remaining;
int deleted = unitOfWork.call(() -> feedEntryDAO.deleteOldEntries(feed.getId(), Math.min(batchSize, rem))); int deleted = unitOfWork.call(() -> feedEntryDAO.deleteOldEntries(feed.getId(), Math.min(batchSize, rem)));
entriesDeletedMeter.mark(deleted); entriesDeletedMeter.mark(deleted);
total += deleted; total += deleted;
remaining -= deleted; remaining -= deleted;
log.debug("removed {} entries for feeds exceeding capacity", total); log.debug("removed {} entries for feeds exceeding capacity", total);
} while (remaining > 0); } while (remaining > 0);
} }
} }
log.info("cleanup done: {} entries for feeds exceeding capacity deleted", total); log.info("cleanup done: {} entries for feeds exceeding capacity deleted", total);
} }
public void cleanEntriesOlderThan(final Instant olderThan) { public void cleanEntriesOlderThan(final Instant olderThan) {
log.info("cleaning old entries"); log.info("cleaning old entries");
long total = 0; long total = 0;
long deleted; long deleted;
do { do {
deleted = unitOfWork.call(() -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize)); deleted = unitOfWork.call(() -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize));
entriesDeletedMeter.mark(deleted); entriesDeletedMeter.mark(deleted);
total += deleted; total += deleted;
log.debug("removed {} old entries", total); log.debug("removed {} old entries", total);
} while (deleted != 0); } while (deleted != 0);
log.info("cleanup done: {} old entries deleted", total); log.info("cleanup done: {} old entries deleted", total);
} }
public void cleanStatusesOlderThan(final Instant olderThan) { public void cleanStatusesOlderThan(final Instant olderThan) {
log.info("cleaning old read statuses"); log.info("cleaning old read statuses");
long total = 0; long total = 0;
long deleted; long deleted;
do { do {
deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize)); deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
total += deleted; total += deleted;
log.debug("removed {} old read statuses", total); log.debug("removed {} old read statuses", total);
} while (deleted != 0); } while (deleted != 0);
log.info("cleanup done: {} old read statuses deleted", total); log.info("cleanup done: {} old read statuses deleted", total);
} }
} }

View File

@@ -1,63 +1,63 @@
package com.commafeed.backend.service.db; package com.commafeed.backend.service.db;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.kohsuke.MetaInfServices; import org.kohsuke.MetaInfServices;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.service.UserService; import com.commafeed.backend.service.UserService;
import liquibase.database.Database; import liquibase.database.Database;
import liquibase.database.core.PostgresDatabase; import liquibase.database.core.PostgresDatabase;
import liquibase.structure.DatabaseObject; import liquibase.structure.DatabaseObject;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class DatabaseStartupService { public class DatabaseStartupService {
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
private final UserDAO userDAO; private final UserDAO userDAO;
private final UserService userService; private final UserService userService;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
public void populateInitialData() { public void populateInitialData() {
long count = unitOfWork.call(userDAO::count); long count = unitOfWork.call(userDAO::count);
if (count == 0) { if (count == 0) {
unitOfWork.run(this::initialData); unitOfWork.run(this::initialData);
} }
} }
private void initialData() { private void initialData() {
log.info("populating database with default values"); log.info("populating database with default values");
try { try {
userService.createAdminUser(); userService.createAdminUser();
if (config.users().createDemoAccount()) { if (config.users().createDemoAccount()) {
userService.createDemoUser(); userService.createDemoUser();
} }
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
} }
/** /**
* Register a postgresql database in liquibase that doesn't escape columns, so that we can use lower case columns * Register a postgresql database in liquibase that doesn't escape columns, so that we can use lower case columns
*/ */
@MetaInfServices(Database.class) @MetaInfServices(Database.class)
public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase { public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase {
@Override @Override
public String escapeObjectName(String objectName, Class<? extends DatabaseObject> objectType) { public String escapeObjectName(String objectName, Class<? extends DatabaseObject> objectType) {
return objectName; return objectName;
} }
@Override @Override
public int getPriority() { public int getPriority() {
return super.getPriority() + 1; return super.getPriority() + 1;
} }
} }
} }

View File

@@ -1,30 +1,30 @@
package com.commafeed.backend.service.internal; package com.commafeed.backend.service.internal;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class PostLoginActivities { public class PostLoginActivities {
private final UserDAO userDAO; private final UserDAO userDAO;
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
public void executeFor(User user) { public void executeFor(User user) {
// only update lastLogin every once in a while in order to avoid invalidating the cache every time someone logs in // only update lastLogin every once in a while in order to avoid invalidating the cache every time someone logs in
Instant now = Instant.now(); Instant now = Instant.now();
Instant lastLogin = user.getLastLogin(); Instant lastLogin = user.getLastLogin();
if (lastLogin == null || ChronoUnit.MINUTES.between(lastLogin, now) >= 30) { if (lastLogin == null || ChronoUnit.MINUTES.between(lastLogin, now) >= 30) {
user.setLastLogin(now); user.setLastLogin(now);
unitOfWork.run(() -> userDAO.saveOrUpdate(user)); unitOfWork.run(() -> userDAO.saveOrUpdate(user));
} }
} }
} }

View File

@@ -1,61 +1,61 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.CommaFeedApplication; import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.service.UserService; import com.commafeed.backend.service.UserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
@Slf4j @Slf4j
public class DemoAccountCleanupTask extends ScheduledTask { public class DemoAccountCleanupTask extends ScheduledTask {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
private final UserDAO userDAO; private final UserDAO userDAO;
private final UserService userService; private final UserService userService;
@Override @Override
protected void run() { protected void run() {
if (!config.users().createDemoAccount()) { if (!config.users().createDemoAccount()) {
return; return;
} }
log.info("recreating demo user account"); log.info("recreating demo user account");
unitOfWork.run(() -> { unitOfWork.run(() -> {
User demoUser = userDAO.findByName(CommaFeedApplication.USERNAME_DEMO); User demoUser = userDAO.findByName(CommaFeedApplication.USERNAME_DEMO);
if (demoUser == null) { if (demoUser == null) {
return; return;
} }
userService.unregister(demoUser); userService.unregister(demoUser);
userService.createDemoUser(); userService.createDemoUser();
}); });
} }
@Override @Override
protected long getInitialDelay() { protected long getInitialDelay() {
return 1; return 1;
} }
@Override @Override
protected long getPeriod() { protected long getPeriod() {
return getTimeUnit().convert(24, TimeUnit.HOURS); return getTimeUnit().convert(24, TimeUnit.HOURS);
} }
@Override @Override
protected TimeUnit getTimeUnit() { protected TimeUnit getTimeUnit() {
return TimeUnit.MINUTES; return TimeUnit.MINUTES;
} }
} }

View File

@@ -1,42 +1,42 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.service.db.DatabaseCleaningService; import com.commafeed.backend.service.db.DatabaseCleaningService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask { public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final DatabaseCleaningService cleaner; private final DatabaseCleaningService cleaner;
@Override @Override
public void run() { public void run() {
int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); int maxFeedCapacity = config.database().cleanup().maxFeedCapacity();
if (maxFeedCapacity > 0) { if (maxFeedCapacity > 0) {
cleaner.cleanEntriesForFeedsExceedingCapacity(maxFeedCapacity); cleaner.cleanEntriesForFeedsExceedingCapacity(maxFeedCapacity);
} }
} }
@Override @Override
public long getInitialDelay() { public long getInitialDelay() {
return 10; return 10;
} }
@Override @Override
public long getPeriod() { public long getPeriod() {
return 60; return 60;
} }
@Override @Override
public TimeUnit getTimeUnit() { public TimeUnit getTimeUnit() {
return TimeUnit.MINUTES; return TimeUnit.MINUTES;
} }
} }

View File

@@ -1,45 +1,45 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.service.db.DatabaseCleaningService; import com.commafeed.backend.service.db.DatabaseCleaningService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class OldEntriesCleanupTask extends ScheduledTask { public class OldEntriesCleanupTask extends ScheduledTask {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final DatabaseCleaningService cleaner; private final DatabaseCleaningService cleaner;
@Override @Override
public void run() { public void run() {
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
if (!entriesMaxAge.isZero()) { if (!entriesMaxAge.isZero()) {
Instant threshold = Instant.now().minus(entriesMaxAge); Instant threshold = Instant.now().minus(entriesMaxAge);
cleaner.cleanEntriesOlderThan(threshold); cleaner.cleanEntriesOlderThan(threshold);
} }
} }
@Override @Override
public long getInitialDelay() { public long getInitialDelay() {
return 5; return 5;
} }
@Override @Override
public long getPeriod() { public long getPeriod() {
return 60; return 60;
} }
@Override @Override
public TimeUnit getTimeUnit() { public TimeUnit getTimeUnit() {
return TimeUnit.MINUTES; return TimeUnit.MINUTES;
} }
} }

View File

@@ -1,43 +1,43 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.time.Instant; import java.time.Instant;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.service.db.DatabaseCleaningService; import com.commafeed.backend.service.db.DatabaseCleaningService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class OldStatusesCleanupTask extends ScheduledTask { public class OldStatusesCleanupTask extends ScheduledTask {
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final DatabaseCleaningService cleaner; private final DatabaseCleaningService cleaner;
@Override @Override
public void run() { public void run() {
Instant threshold = config.database().cleanup().statusesInstantThreshold(); Instant threshold = config.database().cleanup().statusesInstantThreshold();
if (threshold != null) { if (threshold != null) {
cleaner.cleanStatusesOlderThan(threshold); cleaner.cleanStatusesOlderThan(threshold);
} }
} }
@Override @Override
public long getInitialDelay() { public long getInitialDelay() {
return 15; return 15;
} }
@Override @Override
public long getPeriod() { public long getPeriod() {
return 60; return 60;
} }
@Override @Override
public TimeUnit getTimeUnit() { public TimeUnit getTimeUnit() {
return TimeUnit.MINUTES; return TimeUnit.MINUTES;
} }
} }

View File

@@ -1,37 +1,37 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.service.db.DatabaseCleaningService; import com.commafeed.backend.service.db.DatabaseCleaningService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class OrphanedContentsCleanupTask extends ScheduledTask { public class OrphanedContentsCleanupTask extends ScheduledTask {
private final DatabaseCleaningService cleaner; private final DatabaseCleaningService cleaner;
@Override @Override
public void run() { public void run() {
cleaner.cleanContentsWithoutEntries(); cleaner.cleanContentsWithoutEntries();
} }
@Override @Override
public long getInitialDelay() { public long getInitialDelay() {
return 25; return 25;
} }
@Override @Override
public long getPeriod() { public long getPeriod() {
return 60; return 60;
} }
@Override @Override
public TimeUnit getTimeUnit() { public TimeUnit getTimeUnit() {
return TimeUnit.MINUTES; return TimeUnit.MINUTES;
} }
} }

View File

@@ -1,37 +1,37 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import com.commafeed.backend.service.db.DatabaseCleaningService; import com.commafeed.backend.service.db.DatabaseCleaningService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
@Singleton @Singleton
public class OrphanedFeedsCleanupTask extends ScheduledTask { public class OrphanedFeedsCleanupTask extends ScheduledTask {
private final DatabaseCleaningService cleaner; private final DatabaseCleaningService cleaner;
@Override @Override
public void run() { public void run() {
cleaner.cleanFeedsWithoutSubscriptions(); cleaner.cleanFeedsWithoutSubscriptions();
} }
@Override @Override
public long getInitialDelay() { public long getInitialDelay() {
return 20; return 20;
} }
@Override @Override
public long getPeriod() { public long getPeriod() {
return 60; return 60;
} }
@Override @Override
public TimeUnit getTimeUnit() { public TimeUnit getTimeUnit() {
return TimeUnit.MINUTES; return TimeUnit.MINUTES;
} }
} }

View File

@@ -1,30 +1,30 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
public abstract class ScheduledTask { public abstract class ScheduledTask {
protected abstract void run(); protected abstract void run();
protected abstract long getInitialDelay(); protected abstract long getInitialDelay();
protected abstract long getPeriod(); protected abstract long getPeriod();
protected abstract TimeUnit getTimeUnit(); protected abstract TimeUnit getTimeUnit();
public void register(ScheduledExecutorService executor) { public void register(ScheduledExecutorService executor) {
Runnable runnable = () -> { Runnable runnable = () -> {
try { try {
ScheduledTask.this.run(); ScheduledTask.this.run();
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
} }
}; };
log.info("registering task {} for execution every {} {}, starting in {} {}", getClass().getSimpleName(), getPeriod(), getTimeUnit(), log.info("registering task {} for execution every {} {}, starting in {} {}", getClass().getSimpleName(), getPeriod(), getTimeUnit(),
getInitialDelay(), getTimeUnit()); getInitialDelay(), getTimeUnit());
executor.scheduleWithFixedDelay(runnable, getInitialDelay(), getPeriod(), getTimeUnit()); executor.scheduleWithFixedDelay(runnable, getInitialDelay(), getPeriod(), getTimeUnit());
} }
} }

View File

@@ -1,29 +1,29 @@
package com.commafeed.backend.task; package com.commafeed.backend.task;
import java.util.List; import java.util.List;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import io.quarkus.arc.All; import io.quarkus.arc.All;
@Singleton @Singleton
public class TaskScheduler { public class TaskScheduler {
private final List<ScheduledTask> tasks; private final List<ScheduledTask> tasks;
private final ScheduledExecutorService executor; private final ScheduledExecutorService executor;
public TaskScheduler(@All List<ScheduledTask> tasks) { public TaskScheduler(@All List<ScheduledTask> tasks) {
this.tasks = tasks; this.tasks = tasks;
this.executor = Executors.newScheduledThreadPool(tasks.size()); this.executor = Executors.newScheduledThreadPool(tasks.size());
} }
public void start() { public void start() {
tasks.forEach(task -> task.register(executor)); tasks.forEach(task -> task.register(executor));
} }
public void stop() { public void stop() {
executor.shutdownNow(); executor.shutdownNow();
} }
} }

View File

@@ -1,10 +1,10 @@
package com.commafeed.backend.urlprovider; package com.commafeed.backend.urlprovider;
/** /**
* Tries to find a feed url given the url and page content * Tries to find a feed url given the url and page content
*/ */
public interface FeedURLProvider { public interface FeedURLProvider {
String get(String url, String urlContent); String get(String url, String urlContent);
} }

View File

@@ -1,31 +1,31 @@
package com.commafeed.backend.urlprovider; package com.commafeed.backend.urlprovider;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
@Singleton @Singleton
public class InPageReferenceFeedURLProvider implements FeedURLProvider { public class InPageReferenceFeedURLProvider implements FeedURLProvider {
@Override @Override
public String get(String url, String urlContent) { public String get(String url, String urlContent) {
String foundUrl = null; String foundUrl = null;
Document doc = Jsoup.parse(urlContent, url); Document doc = Jsoup.parse(urlContent, url);
String root = doc.children().get(0).tagName(); String root = doc.children().get(0).tagName();
if ("html".equals(root)) { if ("html".equals(root)) {
Elements atom = doc.select("link[type=application/atom+xml]"); Elements atom = doc.select("link[type=application/atom+xml]");
Elements rss = doc.select("link[type=application/rss+xml]"); Elements rss = doc.select("link[type=application/rss+xml]");
if (!atom.isEmpty()) { if (!atom.isEmpty()) {
foundUrl = atom.get(0).attr("abs:href"); foundUrl = atom.get(0).attr("abs:href");
} else if (!rss.isEmpty()) { } else if (!rss.isEmpty()) {
foundUrl = rss.get(0).attr("abs:href"); foundUrl = rss.get(0).attr("abs:href");
} }
} }
return foundUrl; return foundUrl;
} }
} }

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